diff --git a/api-report/packages/contracts/src/__tests__/activity-ops.test.d.ts b/api-report/packages/contracts/src/__tests__/activity-ops.test.d.ts
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/api-report/packages/contracts/src/__tests__/activity-ops.test.d.ts
@@ -0,0 +1 @@
+export {};
diff --git a/api-report/packages/contracts/src/activity-ops.d.ts b/api-report/packages/contracts/src/activity-ops.d.ts
new file mode 100644
index 0000000..0a00c6c
--- /dev/null
+++ b/api-report/packages/contracts/src/activity-ops.d.ts
@@ -0,0 +1,353 @@
+/**
+ * 运营中心 · 活动模块的契约(L1 活动清单 / L2 活动详情 / L3 实例详情;`docs/API文档-活动模块.md`)。
+ *
+ * 只有 zod schema 与推导类型。两个概念(2026-08-25 用户定):
+ * · **活动** = 装进来的活动包在运营面上的投影 —— 名称 / 分类 / 版本 / 模型来自清单,
+ * 负责人 / 说明 / 图标 / 色相来自运营侧档案(`sys.activity_profile`,活动包本身不载这些);
+ * · **实例** = 这个活动的一次运行(一张 task)—— 五态是对 task 运行态 / 终态的**如实映射**,
+ * 不是第六套状态机(见 `INSTANCE_STATE_LABEL` 旁的口径)。
+ */
+import { z } from 'zod';
+/** 运营态:激活中 / 已禁用 —— 禁用只拦「新启动」,已完成与在途实例都不受影响(2026-08-27 用户定) */
+export declare const ACTIVITY_STATUSES: readonly ["active", "disabled"];
+export declare const activityStatusSchema: z.ZodEnum<{
+ active: "active";
+ disabled: "disabled";
+}>;
+export type ActivityStatus = z.infer;
+export declare const ACTIVITY_STATUS_LABEL: Readonly>;
+/** 业务色相 —— 一卡一色;缺省由前端兜底(`blue`) */
+export declare const ACTIVITY_HUES: readonly ["blue", "teal", "amber", "violet"];
+export declare const activityHueSchema: z.ZodEnum<{
+ blue: "blue";
+ teal: "teal";
+ amber: "amber";
+ violet: "violet";
+}>;
+export type ActivityHue = z.infer;
+/** 凡出人都是两段:展示用 name,筛选 / 提交用 id */
+export declare const staffRefSchema: z.ZodObject<{
+ id: z.core.$ZodBranded;
+ name: z.ZodString;
+}, z.core.$strip>;
+export type StaffRef = z.infer;
+export declare const activitySummarySchema: z.ZodObject<{
+ id: z.ZodString;
+ name: z.ZodString;
+ biz: z.ZodString;
+ status: z.ZodEnum<{
+ active: "active";
+ disabled: "disabled";
+ }>;
+ version: z.ZodString;
+ owner: z.ZodNullable;
+ name: z.ZodString;
+ }, z.core.$strip>>;
+ llm: z.ZodString;
+ instanceCount: z.ZodNumber;
+ createdAt: z.ZodString;
+ ico: z.ZodOptional;
+ hue: z.ZodOptional>;
+}, z.core.$strip>;
+export type ActivitySummary = z.infer;
+export declare const activityDetailSchema: z.ZodObject<{
+ name: z.ZodString;
+ id: z.ZodString;
+ version: z.ZodString;
+ status: z.ZodEnum<{
+ active: "active";
+ disabled: "disabled";
+ }>;
+ createdAt: z.ZodString;
+ biz: z.ZodString;
+ owner: z.ZodNullable;
+ name: z.ZodString;
+ }, z.core.$strip>>;
+ llm: z.ZodString;
+ ico: z.ZodOptional;
+ hue: z.ZodOptional>;
+ desc: z.ZodString;
+ view: z.ZodNullable>;
+}, z.core.$strip>;
+export type ActivityDetail = z.infer;
+/**
+ * 沙箱视图向父页报高的消息类型(postMessage 载荷 `{ type, height }`)。
+ * 视图模版本身**无脚本**(安装静态审拦着);报高脚本由服务端在出口那一刻注入。
+ */
+export declare const ACTIVITY_VIEW_HEIGHT_MESSAGE = "jcai:view-height";
+/**
+ * 实例五态(2026-08-25 用户定),对 task 的如实映射:
+ * pending 已建单、推进器还没领到(`used.steps = 0`)
+ * running 在途(含等人回传 / 监听中 / 挂起 / 落地受阻)
+ * completed 终态 released
+ * rejected 验收被负责人打回、模型还没重新提交的那一段(重新提交后回 running)
+ * failed 终态 blocked / stalled / exhausted,以及手动终止(靠 `reason.kind='terminated'` 区分)
+ */
+export declare const INSTANCE_STATES: readonly ["pending", "running", "completed", "rejected", "failed"];
+export declare const instanceStateSchema: z.ZodEnum<{
+ running: "running";
+ completed: "completed";
+ pending: "pending";
+ failed: "failed";
+ rejected: "rejected";
+}>;
+export type InstanceState = z.infer;
+export declare const INSTANCE_STATE_LABEL: Readonly>;
+export declare const instanceSummarySchema: z.ZodObject<{
+ id: z.ZodString;
+ no: z.ZodString;
+ state: z.ZodEnum<{
+ running: "running";
+ completed: "completed";
+ pending: "pending";
+ failed: "failed";
+ rejected: "rejected";
+ }>;
+ owner: z.ZodObject<{
+ id: z.core.$ZodBranded;
+ name: z.ZodString;
+ }, z.core.$strip>;
+ input: z.ZodString;
+ output: z.ZodNullable;
+ startedAt: z.ZodString;
+}, z.core.$strip>;
+export type InstanceSummary = z.infer;
+export declare const instanceStatsSchema: z.ZodObject<{
+ total: z.ZodNumber;
+ byState: z.ZodRecord, z.ZodNumber>;
+}, z.core.$strip>;
+export type InstanceStats = z.infer;
+export declare const instanceListSchema: z.ZodObject<{
+ stats: z.ZodObject<{
+ total: z.ZodNumber;
+ byState: z.ZodRecord, z.ZodNumber>;
+ }, z.core.$strip>;
+ total: z.ZodNumber;
+ page: z.ZodNumber;
+ size: z.ZodNumber;
+ items: z.ZodArray;
+ owner: z.ZodObject<{
+ id: z.core.$ZodBranded;
+ name: z.ZodString;
+ }, z.core.$strip>;
+ input: z.ZodString;
+ output: z.ZodNullable;
+ startedAt: z.ZodString;
+ }, z.core.$strip>>;
+}, z.core.$strip>;
+export type InstanceList = z.infer;
+export declare const instanceFileSchema: z.ZodObject<{
+ index: z.ZodNumber;
+ name: z.ZodString;
+ contentType: z.ZodString;
+ size: z.ZodNumber;
+}, z.core.$strip>;
+export type InstanceFile = z.infer;
+export declare const INSTANCE_REASON_KINDS: readonly ["rejected", "failed", "terminated"];
+export declare const instanceReasonSchema: z.ZodObject<{
+ kind: z.ZodEnum<{
+ terminated: "terminated";
+ failed: "failed";
+ rejected: "rejected";
+ }>;
+ text: z.ZodString;
+}, z.core.$strip>;
+export type InstanceReason = z.infer;
+export declare const STEP_RESULTS: readonly ["done", "fail", "current", "pending"];
+export declare const instanceStepSchema: z.ZodObject<{
+ seq: z.ZodNumber;
+ name: z.ZodString;
+ by: z.ZodEnum<{
+ human: "human";
+ agent: "agent";
+ }>;
+ byName: z.ZodString;
+ at: z.ZodNullable;
+ durationMs: z.ZodNullable;
+ res: z.ZodEnum<{
+ pending: "pending";
+ done: "done";
+ fail: "fail";
+ current: "current";
+ }>;
+ note: z.ZodString;
+}, z.core.$strip>;
+export type InstanceStep = z.infer;
+/** 起单来源:manual(运营端手动启动)/ upstream(工作流节点)/ mail 等通道名 / testbed(测试台试跑)/ system */
+export declare const instanceSourceSchema: z.ZodObject<{
+ kind: z.ZodString;
+ label: z.ZodString;
+}, z.core.$strip>;
+export type InstanceSource = z.infer;
+export declare const instanceDetailSchema: z.ZodObject<{
+ id: z.ZodString;
+ state: z.ZodEnum<{
+ running: "running";
+ completed: "completed";
+ pending: "pending";
+ failed: "failed";
+ rejected: "rejected";
+ }>;
+ startedAt: z.ZodString;
+ owner: z.ZodObject<{
+ id: z.core.$ZodBranded;
+ name: z.ZodString;
+ }, z.core.$strip>;
+ no: z.ZodString;
+ activityId: z.ZodString;
+ llm: z.ZodString;
+ endedAt: z.ZodNullable;
+ durationMs: z.ZodNullable;
+ reason: z.ZodNullable;
+ text: z.ZodString;
+ }, z.core.$strip>>;
+ input: z.ZodObject<{
+ content: z.ZodString;
+ source: z.ZodObject<{
+ kind: z.ZodString;
+ label: z.ZodString;
+ }, z.core.$strip>;
+ submittedAt: z.ZodString;
+ files: z.ZodArray>;
+ }, z.core.$strip>;
+ output: z.ZodObject<{
+ files: z.ZodArray>;
+ }, z.core.$strip>;
+ steps: z.ZodArray;
+ byName: z.ZodString;
+ at: z.ZodNullable;
+ durationMs: z.ZodNullable;
+ res: z.ZodEnum<{
+ pending: "pending";
+ done: "done";
+ fail: "fail";
+ current: "current";
+ }>;
+ note: z.ZodString;
+ }, z.core.$strip>>;
+}, z.core.$strip>;
+export type InstanceDetail = z.infer;
+/**
+ * 控件种类:text 单行 / textarea 多行 / select 单选(只能选候选)/ combo 选择 + 新增 / list 字符串数组。
+ * 新增种类前后端一起升级;前端遇到不认识的 type 摆「暂不支持的控件」。
+ */
+export declare const LAUNCH_PARAM_TYPES: readonly ["text", "textarea", "select", "combo", "list"];
+export declare const launchParamTypeSchema: z.ZodEnum<{
+ text: "text";
+ textarea: "textarea";
+ select: "select";
+ combo: "combo";
+ list: "list";
+}>;
+export type LaunchParamType = z.infer;
+export declare const launchOptionSchema: z.ZodObject<{
+ value: z.ZodString;
+ label: z.ZodString;
+}, z.core.$strip>;
+export type LaunchOption = z.infer;
+export declare const launchParamSchema: z.ZodObject<{
+ key: z.ZodString;
+ label: z.ZodString;
+ type: z.ZodEnum<{
+ text: "text";
+ textarea: "textarea";
+ select: "select";
+ combo: "combo";
+ list: "list";
+ }>;
+ required: z.ZodOptional;
+ desc: z.ZodOptional;
+ placeholder: z.ZodOptional;
+ options: z.ZodOptional>>;
+ default: z.ZodOptional]>>;
+}, z.core.$strip>;
+export type LaunchParam = z.infer;
+export declare const launchParamsSchema: z.ZodObject<{
+ activityId: z.ZodString;
+ version: z.ZodString;
+ params: z.ZodArray;
+ required: z.ZodOptional;
+ desc: z.ZodOptional;
+ placeholder: z.ZodOptional;
+ options: z.ZodOptional>>;
+ default: z.ZodOptional]>>;
+ }, z.core.$strip>>;
+}, z.core.$strip>;
+export type LaunchParams = z.infer;
+export declare const launchInputSchema: z.ZodObject<{
+ idempotencyKey: z.ZodString;
+ ownerId: z.core.$ZodBranded;
+ params: z.ZodRecord]>>;
+}, z.core.$strip>;
+export type LaunchInput = z.infer;
diff --git a/api-report/packages/contracts/src/api/activity-ops.d.ts b/api-report/packages/contracts/src/api/activity-ops.d.ts
new file mode 100644
index 0000000..739653c
--- /dev/null
+++ b/api-report/packages/contracts/src/api/activity-ops.d.ts
@@ -0,0 +1,522 @@
+/**
+ * 运营中心 · 活动模块的路由契约(`docs/API文档-活动模块.md`)。
+ *
+ * 全部 `access: 'ops'` —— 活动区落在运营中心,与工作流 / 任务看板同端。
+ * `/api/gov/*` 的写路由由 app 中间件自动广播 `{kind:'system', id:'gov'}`。
+ * 设计端的活动库(包版本 / 资产版本 / 装入)在 `/api/gov/activity-packages`(governance.ts),
+ * `/api/gov/activities/*` 整个归运营端(2026-08-27 用户定)。
+ */
+import { z } from 'zod';
+import type { RouteContract } from './http.ts';
+export declare const listOpsActivitiesRoute: {
+ readonly method: "GET";
+ readonly path: "/api/gov/activities";
+ readonly access: "ops";
+ /** 不带 status = 全量;量小不分页 */
+ readonly query: z.ZodObject<{
+ status: z.ZodOptional>;
+ }, z.core.$strip>;
+ readonly output: z.ZodObject<{
+ activities: z.ZodArray;
+ version: z.ZodString;
+ owner: z.ZodNullable;
+ name: z.ZodString;
+ }, z.core.$strip>>;
+ llm: z.ZodString;
+ instanceCount: z.ZodNumber;
+ createdAt: z.ZodString;
+ ico: z.ZodOptional;
+ hue: z.ZodOptional>;
+ }, z.core.$strip>>;
+ }, z.core.$strip>;
+};
+export declare const getOpsActivityRoute: {
+ readonly method: "GET";
+ readonly path: "/api/gov/activities/:activityId";
+ readonly access: "ops";
+ readonly params: z.ZodObject<{
+ activityId: z.ZodString;
+ }, z.core.$strip>;
+ readonly output: z.ZodObject<{
+ name: z.ZodString;
+ id: z.ZodString;
+ version: z.ZodString;
+ status: z.ZodEnum<{
+ active: "active";
+ disabled: "disabled";
+ }>;
+ createdAt: z.ZodString;
+ biz: z.ZodString;
+ owner: z.ZodNullable;
+ name: z.ZodString;
+ }, z.core.$strip>>;
+ llm: z.ZodString;
+ ico: z.ZodOptional;
+ hue: z.ZodOptional>;
+ desc: z.ZodString;
+ view: z.ZodNullable>;
+ }, z.core.$strip>;
+};
+/**
+ * 沙箱要渲染的活动视图 —— 处理器回 `text/html`(不是 JSON),`output` 只是形式上的字符串。
+ * iframe 带不了 Authorization 头,走票据 `?ticket=`(GET,中间件已认)。404 = 该活动没配视图。
+ */
+export declare const opsActivityViewRoute: {
+ readonly method: "GET";
+ readonly path: "/api/gov/activities/:activityId/view";
+ readonly access: "ops";
+ readonly params: z.ZodObject<{
+ activityId: z.ZodString;
+ }, z.core.$strip>;
+ readonly output: z.ZodString;
+};
+export declare const listOpsInstancesRoute: {
+ readonly method: "GET";
+ readonly path: "/api/gov/activities/:activityId/instances";
+ readonly access: "ops";
+ readonly params: z.ZodObject<{
+ activityId: z.ZodString;
+ }, z.core.$strip>;
+ readonly query: z.ZodObject<{
+ id: z.ZodOptional;
+ ownerId: z.ZodOptional;
+ state: z.ZodOptional>;
+ startedFrom: z.ZodOptional;
+ startedTo: z.ZodOptional;
+ page: z.ZodDefault>;
+ size: z.ZodDefault>;
+ }, z.core.$strip>;
+ readonly output: z.ZodObject<{
+ stats: z.ZodObject<{
+ total: z.ZodNumber;
+ byState: z.ZodRecord, z.ZodNumber>;
+ }, z.core.$strip>;
+ total: z.ZodNumber;
+ page: z.ZodNumber;
+ size: z.ZodNumber;
+ items: z.ZodArray;
+ owner: z.ZodObject<{
+ id: z.core.$ZodBranded;
+ name: z.ZodString;
+ }, z.core.$strip>;
+ input: z.ZodString;
+ output: z.ZodNullable;
+ startedAt: z.ZodString;
+ }, z.core.$strip>>;
+ }, z.core.$strip>;
+};
+export declare const opsLaunchParamsRoute: {
+ readonly method: "GET";
+ readonly path: "/api/gov/activities/:activityId/launch-params";
+ readonly access: "ops";
+ readonly params: z.ZodObject<{
+ activityId: z.ZodString;
+ }, z.core.$strip>;
+ readonly output: z.ZodObject<{
+ activityId: z.ZodString;
+ version: z.ZodString;
+ params: z.ZodArray;
+ required: z.ZodOptional;
+ desc: z.ZodOptional;
+ placeholder: z.ZodOptional;
+ options: z.ZodOptional>>;
+ default: z.ZodOptional]>>;
+ }, z.core.$strip>>;
+ }, z.core.$strip>;
+};
+/**
+ * 启动活动 = 创建实例。同一 `idempotencyKey` 重放返回第一次创建的实例;
+ * 412 活动已禁用;400 缺必填 / 类型不符 / select 值不在候选 / 责任人不在职;409 同键正在创建中。
+ */
+export declare const launchOpsInstanceRoute: {
+ readonly method: "POST";
+ readonly path: "/api/gov/activities/:activityId/instances";
+ readonly access: "ops";
+ readonly params: z.ZodObject<{
+ activityId: z.ZodString;
+ }, z.core.$strip>;
+ readonly body: z.ZodObject<{
+ idempotencyKey: z.ZodString;
+ ownerId: z.core.$ZodBranded;
+ params: z.ZodRecord]>>;
+ }, z.core.$strip>;
+ readonly output: z.ZodObject<{
+ id: z.ZodString;
+ state: z.ZodEnum<{
+ running: "running";
+ completed: "completed";
+ pending: "pending";
+ failed: "failed";
+ rejected: "rejected";
+ }>;
+ startedAt: z.ZodString;
+ owner: z.ZodObject<{
+ id: z.core.$ZodBranded;
+ name: z.ZodString;
+ }, z.core.$strip>;
+ no: z.ZodString;
+ activityId: z.ZodString;
+ llm: z.ZodString;
+ endedAt: z.ZodNullable;
+ durationMs: z.ZodNullable;
+ reason: z.ZodNullable;
+ text: z.ZodString;
+ }, z.core.$strip>>;
+ input: z.ZodObject<{
+ content: z.ZodString;
+ source: z.ZodObject<{
+ kind: z.ZodString;
+ label: z.ZodString;
+ }, z.core.$strip>;
+ submittedAt: z.ZodString;
+ files: z.ZodArray>;
+ }, z.core.$strip>;
+ output: z.ZodObject<{
+ files: z.ZodArray>;
+ }, z.core.$strip>;
+ steps: z.ZodArray;
+ byName: z.ZodString;
+ at: z.ZodNullable;
+ durationMs: z.ZodNullable;
+ res: z.ZodEnum<{
+ pending: "pending";
+ done: "done";
+ fail: "fail";
+ current: "current";
+ }>;
+ note: z.ZodString;
+ }, z.core.$strip>>;
+ }, z.core.$strip>;
+};
+export declare const getOpsInstanceRoute: {
+ readonly method: "GET";
+ readonly path: "/api/gov/activities/:activityId/instances/:instanceId";
+ readonly access: "ops";
+ readonly params: z.ZodObject<{
+ activityId: z.ZodString;
+ instanceId: z.ZodString;
+ }, z.core.$strip>;
+ readonly output: z.ZodObject<{
+ id: z.ZodString;
+ state: z.ZodEnum<{
+ running: "running";
+ completed: "completed";
+ pending: "pending";
+ failed: "failed";
+ rejected: "rejected";
+ }>;
+ startedAt: z.ZodString;
+ owner: z.ZodObject<{
+ id: z.core.$ZodBranded;
+ name: z.ZodString;
+ }, z.core.$strip>;
+ no: z.ZodString;
+ activityId: z.ZodString;
+ llm: z.ZodString;
+ endedAt: z.ZodNullable;
+ durationMs: z.ZodNullable;
+ reason: z.ZodNullable;
+ text: z.ZodString;
+ }, z.core.$strip>>;
+ input: z.ZodObject<{
+ content: z.ZodString;
+ source: z.ZodObject<{
+ kind: z.ZodString;
+ label: z.ZodString;
+ }, z.core.$strip>;
+ submittedAt: z.ZodString;
+ files: z.ZodArray>;
+ }, z.core.$strip>;
+ output: z.ZodObject<{
+ files: z.ZodArray>;
+ }, z.core.$strip>;
+ steps: z.ZodArray;
+ byName: z.ZodString;
+ at: z.ZodNullable;
+ durationMs: z.ZodNullable;
+ res: z.ZodEnum<{
+ pending: "pending";
+ done: "done";
+ fail: "fail";
+ current: "current";
+ }>;
+ note: z.ZodString;
+ }, z.core.$strip>>;
+ }, z.core.$strip>;
+};
+/**
+ * 实例文件:默认 JSON + base64(与 `GET /api/gov/upload/:id` 同形);`?raw=1` 直出字节
+ * (RFC 5987 文件名),配 `?ticket=` 给 `` 用。
+ */
+export declare const opsInstanceFileRoute: {
+ readonly method: "GET";
+ readonly path: "/api/gov/activities/:activityId/instances/:instanceId/files/:io/:index";
+ readonly access: "ops";
+ readonly params: z.ZodObject<{
+ activityId: z.ZodString;
+ instanceId: z.ZodString;
+ io: z.ZodEnum<{
+ output: "output";
+ input: "input";
+ }>;
+ index: z.ZodCoercedNumber;
+ }, z.core.$strip>;
+ readonly query: z.ZodObject<{
+ raw: z.ZodOptional;
+ }, z.core.$strip>;
+ readonly output: z.ZodObject<{
+ name: z.ZodString;
+ contentType: z.ZodString;
+ dataBase64: z.ZodString;
+ }, z.core.$strip>;
+};
+/** 任何时刻都可禁用:只拦新启动,在途实例继续跑完;已禁用再禁用 = 幂等 200 */
+export declare const disableOpsActivityRoute: {
+ readonly method: "POST";
+ readonly path: "/api/gov/activities/:activityId/disable";
+ readonly access: "ops";
+ readonly params: z.ZodObject<{
+ activityId: z.ZodString;
+ }, z.core.$strip>;
+ readonly output: z.ZodObject<{
+ name: z.ZodString;
+ id: z.ZodString;
+ version: z.ZodString;
+ status: z.ZodEnum<{
+ active: "active";
+ disabled: "disabled";
+ }>;
+ createdAt: z.ZodString;
+ biz: z.ZodString;
+ owner: z.ZodNullable;
+ name: z.ZodString;
+ }, z.core.$strip>>;
+ llm: z.ZodString;
+ ico: z.ZodOptional;
+ hue: z.ZodOptional>;
+ desc: z.ZodString;
+ view: z.ZodNullable>;
+ }, z.core.$strip>;
+};
+export declare const activateOpsActivityRoute: {
+ readonly method: "POST";
+ readonly path: "/api/gov/activities/:activityId/activate";
+ readonly access: "ops";
+ readonly params: z.ZodObject<{
+ activityId: z.ZodString;
+ }, z.core.$strip>;
+ readonly output: z.ZodObject<{
+ name: z.ZodString;
+ id: z.ZodString;
+ version: z.ZodString;
+ status: z.ZodEnum<{
+ active: "active";
+ disabled: "disabled";
+ }>;
+ createdAt: z.ZodString;
+ biz: z.ZodString;
+ owner: z.ZodNullable;
+ name: z.ZodString;
+ }, z.core.$strip>>;
+ llm: z.ZodString;
+ ico: z.ZodOptional;
+ hue: z.ZodOptional>;
+ desc: z.ZodString;
+ view: z.ZodNullable>;
+ }, z.core.$strip>;
+};
+/** body 空;→ `state='failed'` + `reason.kind='terminated'`;412 实例已终态 */
+export declare const terminateOpsInstanceRoute: {
+ readonly method: "POST";
+ readonly path: "/api/gov/activities/:activityId/instances/:instanceId/terminate";
+ readonly access: "ops";
+ readonly params: z.ZodObject<{
+ activityId: z.ZodString;
+ instanceId: z.ZodString;
+ }, z.core.$strip>;
+ readonly output: z.ZodObject<{
+ id: z.ZodString;
+ state: z.ZodEnum<{
+ running: "running";
+ completed: "completed";
+ pending: "pending";
+ failed: "failed";
+ rejected: "rejected";
+ }>;
+ startedAt: z.ZodString;
+ owner: z.ZodObject<{
+ id: z.core.$ZodBranded;
+ name: z.ZodString;
+ }, z.core.$strip>;
+ no: z.ZodString;
+ activityId: z.ZodString;
+ llm: z.ZodString;
+ endedAt: z.ZodNullable;
+ durationMs: z.ZodNullable;
+ reason: z.ZodNullable;
+ text: z.ZodString;
+ }, z.core.$strip>>;
+ input: z.ZodObject<{
+ content: z.ZodString;
+ source: z.ZodObject<{
+ kind: z.ZodString;
+ label: z.ZodString;
+ }, z.core.$strip>;
+ submittedAt: z.ZodString;
+ files: z.ZodArray>;
+ }, z.core.$strip>;
+ output: z.ZodObject<{
+ files: z.ZodArray>;
+ }, z.core.$strip>;
+ steps: z.ZodArray;
+ byName: z.ZodString;
+ at: z.ZodNullable;
+ durationMs: z.ZodNullable;
+ res: z.ZodEnum<{
+ pending: "pending";
+ done: "done";
+ fail: "fail";
+ current: "current";
+ }>;
+ note: z.ZodString;
+ }, z.core.$strip>>;
+ }, z.core.$strip>;
+};
+/** 全部 11 条 —— 文档总表与覆盖测试按它数 */
+export declare const ACTIVITY_OPS_ROUTES: readonly RouteContract[];
diff --git a/apps/api/src/app/__tests__/activity-ops-instance-state.test.ts b/apps/api/src/app/__tests__/activity-ops-instance-state.test.ts
new file mode 100644
index 0000000..541f519
--- /dev/null
+++ b/apps/api/src/app/__tests__/activity-ops-instance-state.test.ts
@@ -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();
+ });
+});
diff --git a/apps/api/src/app/__tests__/activity-ops-launch-params.test.ts b/apps/api/src/app/__tests__/activity-ops-launch-params.test.ts
new file mode 100644
index 0000000..0d40c0a
--- /dev/null
+++ b/apps/api/src/app/__tests__/activity-ops-launch-params.test.ts
@@ -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);
+ });
+});
diff --git a/apps/api/src/app/__tests__/activity-ops-service.test.ts b/apps/api/src/app/__tests__/activity-ops-service.test.ts
new file mode 100644
index 0000000..024e37d
--- /dev/null
+++ b/apps/api/src/app/__tests__/activity-ops-service.test.ts
@@ -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 = `
+
+
+
+
+
+
+`;
+
+const card = (over: Partial = {}): 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 => ({
+ 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: '一览
' }] },
+ ...over,
+});
+
+/** 一张带取数声明的看板模版:案号 → 核对表 → 料号圈主数据,页头摆实例与案号 */
+const BOARD_HTML = [
+ '',
+ '
· ·
',
+ '
需求 · 料号 条 · NRND
',
+ '
',
+ '
',
+].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>> = {
+ '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 => ({
+ 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();
+ const profileMap = new Map(profiles.map((p) => [p.activityId, p]));
+ const store: ActivityOpsStore = {
+ async countByActivity() {
+ const m = new Map();
+ 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;
+ loops?: Record;
+ 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 => ({
+ 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 = {}) =>
+ 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('INS-20 · W05-20260827100000 · 柴浩');
+ expect(view.html).toContain('需求 REQ-20260827100000 · 料号 2 条 · NRND 1');
+ expect(view.html).toContain('LM2596S-5.0 | | ');
+ expect(view.html).toContain('XL2596S-5.0 | | ');
+ 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('—');
+ expect(v1.html).toContain('料号 0');
+ } 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('需求 REQ-20260827100000 · 料号 0');
+ } else expect.unreachable();
+ const plain = harness({ rows: [released()] });
+ const p = await plain.ops.viewHtml(W05);
+ if (p.kind === 'html') expect(p.html).toContain('一览
');
+ 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): ItemRuntime[] => Object.entries(states).map(([id, state]) => ({ id, state, instance: 1 }));
+ const loop = (states: Record, 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): 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');
+ });
+});
diff --git a/apps/api/src/app/__tests__/activity-ops-steps.test.ts b/apps/api/src/app/__tests__/activity-ops-steps.test.ts
new file mode 100644
index 0000000..a4eb2e4
--- /dev/null
+++ b/apps/api/src/app/__tests__/activity-ops-steps.test.ts
@@ -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 = `
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+const rt = (states: Record): 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');
+ });
+});
diff --git a/apps/api/src/app/__tests__/activity-ops-store.it.test.ts b/apps/api/src/app/__tests__/activity-ops-store.it.test.ts
new file mode 100644
index 0000000..99f2a28
--- /dev/null
+++ b/apps/api/src/app/__tests__/activity-ops-store.it.test.ts
@@ -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 } | 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' });
+ });
+});
diff --git a/apps/api/src/app/__tests__/activity-ops-store.test.ts b/apps/api/src/app/__tests__/activity-ops-store.test.ts
new file mode 100644
index 0000000..b58fe52
--- /dev/null
+++ b/apps/api/src/app/__tests__/activity-ops-store.test.ts
@@ -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');
+ });
+});
diff --git a/apps/api/src/app/__tests__/activity-ops-summaries.test.ts b/apps/api/src/app/__tests__/activity-ops-summaries.test.ts
new file mode 100644
index 0000000..aa91013
--- /dev/null
+++ b/apps/api/src/app/__tests__/activity-ops-summaries.test.ts
@@ -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([]);
+ });
+});
diff --git a/apps/api/src/app/__tests__/activity-ops-view-html.test.ts b/apps/api/src/app/__tests__/activity-ops-view-html.test.ts
new file mode 100644
index 0000000..294d63f
--- /dev/null
+++ b/apps/api/src/app/__tests__/activity-ops-view-html.test.ts
@@ -0,0 +1,18 @@
+/** 沙箱视图出口:模版原文不改,只在
一览