编排层③:活动装入/换版入口 —— 开发完能装进来,不必跑种子

此前活动只能靠 seed 装入,治理台没有任何入口 —— 这是「逐个开发活动」的拦路石。
补 POST /api/gov/activities(无代码通道:交清单 + .js 产物,都是数据),
两审在服务端跑;不过审按发布门同样的式样回 412 + ApiError.detail 带审计结论
(直接回业务体的话类型化客户端拿不到详情)。活动库给「装入 / 换版」卡:选文件或粘贴两份。
实测:0.1.0 → 0.2.0 版本并存;声明未注册的工具即 412 并点名到工具。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
2026-07-28 10:01:25 +08:00
co-authored by Claude Fable 5
parent c8cfd61929
commit 5619d86ce4
4 changed files with 644 additions and 2 deletions
+49
View File
@@ -19,6 +19,7 @@ import {
listActivityMemoryRoute,
listGovTasksRoute,
listLedgerObjectsRoute,
installActivityRoute,
listProcessPackagesRoute,
listToolsRoute,
listTriggersRoute,
@@ -33,6 +34,8 @@ import {
upsertTriggerRoute,
validateProcessRoute,
} from '@contracts';
import { InstallRejectedError } from '@modules/activity-registry';
import type { InstallVerdict } from '@modules/activity-registry';
import { NotPublishableError } from '@modules/process';
import type { Container } from '../container.ts';
import type { AppEnv } from '../http-env.ts';
@@ -63,6 +66,7 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
const readTrigger = guard('trigger', 'read');
const governTrigger = guard('trigger', 'govern');
const readActivity = guard('activity', 'read');
const governActivity = guard('activity', 'govern');
const readTool = guard('tool', 'read');
const governTool = guard('tool', 'govern');
const readData = guard('data', 'read');
@@ -351,6 +355,51 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
/* ── 活动库管理:两维分类 × 两条版本序列 ─────────────────────────── */
/**
* 装入 / 换版 —— 经**安装通道**,无代码通道(ADR-004):交清单 + .js 产物,都是数据。
* 两审在这里跑;不过审即 412 并把审计结论原样回给人看,不静默失败。
* 恒为新增、版本并存:装入不覆盖旧版本,在途任务按启动时版本跑完。
*/
app.post(installActivityRoute.path, async (c) => {
const denied = await governActivity(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
const parsed = installActivityRoute.body.safeParse(await c.req.json());
if (!parsed.success) {
const body: ApiError = { code: 'validation-failed', message: '活动清单不合 schema', detail: parsed.error.issues };
return c.json(body, 400);
}
const { manifest, artifact } = parsed.data;
const reply = (verdict: InstallVerdict, accepted: boolean) => ({
accepted,
activityId: manifest.identity.activityId,
packageVersion: manifest.identity.packageVersion,
staticFindings: verdict.staticFindings.map((f) => ({ code: f.code, severity: f.severity, message: f.message })),
replay: {
oldPassStillPass: verdict.replay.oldPassStillPass,
checked: verdict.replay.checked,
skippedNoHistory: verdict.replay.skippedNoHistory,
regressions: verdict.replay.regressions.map((r) => ({ taskId: r.taskId, detail: r.detail })),
},
});
try {
return c.json(reply(await container.registry.install(manifest, artifact), true));
} catch (error) {
if (error instanceof InstallRejectedError) {
/*
* 两审未过:不上架。**按 ApiError 形状回**(与发布门一致)——
* 类型化客户端只从 `detail` 取详情,直接回业务体的话前端拿不到审计结论。
*/
const body: ApiError = {
code: 'precondition-failed',
message: error.message,
detail: reply(error.verdict, false),
};
return c.json(body, 412);
}
throw error;
}
});
app.get(listActivitiesRoute.path, async (c) => {
const denied = await readActivity(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
@@ -6,7 +6,37 @@
* 区首页只负责导航,治理动作一律落在下钻详情页的**就地确认带**(影响预览三必答)。
*/
import { Ico } from '@web-shared/Ico.tsx';
import { Card, Chip, Clear, Entry, Note, PageHead, Prop, QueryGate, St } from '@web-shared/gov/primitives.tsx';
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { installActivityRoute } from '@contracts';
import { ApiCallError, call } from '@web-shared/typed-fetch.ts';
import {
Acts,
Card,
Chip,
Clear,
Entry,
Field,
Note,
PageHead,
Prop,
QueryGate,
St,
} from '@web-shared/gov/primitives.tsx';
/** 装入回参 —— 与 `installActivityRoute.output` 同形 */
type InstallReply = {
accepted: boolean;
activityId: string;
packageVersion: string;
staticFindings: ReadonlyArray<{ code: string; severity: string; message: string }>;
replay: {
oldPassStillPass: boolean;
checked: number;
skippedNoHistory: boolean;
regressions: ReadonlyArray<{ taskId: string; detail: string }>;
};
};
import { PARA } from '@web-shared/gov/tone.ts';
import type { Nav } from '@web-shared/gov/gov-route.ts';
import {
@@ -27,6 +57,7 @@ export function LibraryHome({ nav }: HomeProps) {
每活动两条版本序列——包版本走安装两审,资产版本走进化管线。
<b>审批点与网关不在这里</b>:它们是流程层的系统内置要素,零开发、不入库。
</Note>
<InstallCard />
{activities.length === 0 ? (
<Clear>货架是空的 —— 装一个活动进来,编排里才引用得到它。</Clear>
) : (
@@ -187,3 +218,141 @@ export function ActPage({ id, nav }: { id: string; nav: Nav }) {
);
}
/* ---------------- 装入 / 换版(安装通道) ---------------- */
/**
* 开发完一个活动,从这里装进来 —— **无代码通道**(ADR-004):交的是清单 + .js 产物,都是数据。
*
* 两审在服务端跑:静态审读清单,回放审重演历史(首装无历史即免)。
* 恒为新增、版本并存:装入不覆盖旧版本,在途任务按其启动时版本跑完。
*/
function InstallCard() {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [manifestText, setManifestText] = useState('');
const [artifact, setArtifact] = useState('');
const install = useMutation({
mutationFn: () =>
call<InstallReply>(installActivityRoute, {
body: { manifest: JSON.parse(manifestText) as unknown, artifact },
}),
onSuccess: (out) => {
if (out.accepted) {
void queryClient.invalidateQueries({ queryKey: ['gov', 'activities'] });
setOpen(false);
}
},
});
const pick = (setter: (text: string) => void) => async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file !== undefined) setter(await file.text());
};
/** 两审未过的结论:412 走的是 ApiCallError,详情在 detail 里 */
const rejected =
install.error instanceof ApiCallError && install.error.status === 412
? (install.error.detail as InstallReply | undefined)
: install.data?.accepted === false
? install.data
: undefined;
if (!open) {
return (
<Card
ico="package"
title="装入 / 换版"
extra={
<button className="g-btn primary" onClick={() => setOpen(true)}>
装一个活动
</button>
}
>
<Note>
交<b>清单</b>(manifest.json)+ <b>产物</b>(index.js)两份文件,两审当场跑:
静态审读清单(工具契约在否、模型名绑没绑、声明与已物化台账冲不冲),
回放审重演历史成功单 —— <b>旧成不坏</b>是生效硬门,首装无历史即免。
恒为新增、版本并存:装入不覆盖旧版本,在途任务按启动时版本跑完。
</Note>
</Card>
);
}
return (
<Card ico="package" title="装入 / 换版">
<Prop ico="file-text" title="清单 manifest.json" extra="身份 / 七件契约 / 声明 / 资产初始版 / 样例集 / 代码入口">
<Field k="选文件" hint="活动包 dist/manifest.json">
<input className="g-input" type="file" accept=".json,application/json" onChange={pick(setManifestText)} />
</Field>
<Field k="或直接粘贴">
<textarea
className="g-input"
rows={4}
value={manifestText}
placeholder='{ "identity": { "activityId": "rfq-intake", … } }'
onChange={(e) => setManifestText(e.target.value)}
/>
</Field>
</Prop>
<Prop ico="terminal" title="产物 index.js" extra="只当数据存 —— 不 import、不 eval">
<Field k="选文件" hint="活动包 dist/index.js">
<input className="g-input" type="file" accept=".js,.mjs,text/javascript" onChange={pick(setArtifact)} />
</Field>
<Field k="或直接粘贴">
<textarea
className="g-input"
rows={3}
value={artifact}
placeholder="export default defineActivity({ … })"
onChange={(e) => setArtifact(e.target.value)}
/>
</Field>
</Prop>
{rejected !== undefined && (
<Note tone="warn">
<b>两审未过,没有上架。</b>
{rejected.staticFindings.length > 0 && (
<> 静态审:{rejected.staticFindings.map((f) => f.message).join(';')}。</>
)}
{rejected.replay.regressions.length > 0 && (
<> 回放审:{rejected.replay.regressions.map((r) => `${r.taskId} ${r.detail}`).join(';')}。</>
)}
</Note>
)}
{install.data?.accepted === true && (
<Note tone="ok">
已装入 {install.data.activityId} v{install.data.packageVersion} —— 静态审{' '}
{install.data.staticFindings.length} 条提示;回放审
{install.data.replay.skippedNoHistory ? '免(无历史)' : `查了 ${install.data.replay.checked} 单`}。
</Note>
)}
<Acts
align="end"
items={[
{ label: '取消', ico: 'x', onClick: () => setOpen(false) },
{
label: install.isPending ? '两审中…' : '装入并送审',
ico: 'circle-check',
primary: true,
disabled: install.isPending || manifestText.trim() === '' || artifact.trim() === '',
ask: {
title: '经安装通道装入活动包',
note: <Note>无代码通道:交的是数据。两审过了才上架;不过审会把是哪一条拦的说清楚。</Note>,
impact: {
who: '此后新起的任务用新包版本;在途任务按启动时版本跑完',
undo: '版本并存,可停用回退',
when: '确认后当场跑两审',
},
okLabel: '确认装入',
onOk: () => install.mutate(),
},
},
]}
/>
</Card>
);
}