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:
xcs
2026-08-27 23:20:15 +08:00
co-authored by Claude Fable 5
parent 75553371b0
commit 0923fbfe4d
34 changed files with 5137 additions and 163 deletions
@@ -0,0 +1 @@
export {};
+353
View File
@@ -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<typeof activityStatusSchema>;
export declare const ACTIVITY_STATUS_LABEL: Readonly<Record<ActivityStatus, string>>;
/** 业务色相 —— 一卡一色;缺省由前端兜底(`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<typeof activityHueSchema>;
/** 凡出人都是两段:展示用 name,筛选 / 提交用 id */
export declare const staffRefSchema: z.ZodObject<{
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
name: z.ZodString;
}, z.core.$strip>;
export type StaffRef = z.infer<typeof staffRefSchema>;
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<z.ZodObject<{
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
name: z.ZodString;
}, z.core.$strip>>;
llm: z.ZodString;
instanceCount: z.ZodNumber;
createdAt: z.ZodString;
ico: z.ZodOptional<z.ZodString>;
hue: z.ZodOptional<z.ZodEnum<{
blue: "blue";
teal: "teal";
amber: "amber";
violet: "violet";
}>>;
}, z.core.$strip>;
export type ActivitySummary = z.infer<typeof activitySummarySchema>;
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<z.ZodObject<{
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
name: z.ZodString;
}, z.core.$strip>>;
llm: z.ZodString;
ico: z.ZodOptional<z.ZodString>;
hue: z.ZodOptional<z.ZodEnum<{
blue: "blue";
teal: "teal";
amber: "amber";
violet: "violet";
}>>;
desc: z.ZodString;
view: z.ZodNullable<z.ZodObject<{
url: z.ZodString;
}, z.core.$strip>>;
}, z.core.$strip>;
export type ActivityDetail = z.infer<typeof activityDetailSchema>;
/**
* 沙箱视图向父页报高的消息类型(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<typeof instanceStateSchema>;
export declare const INSTANCE_STATE_LABEL: Readonly<Record<InstanceState, string>>;
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<z.ZodString, "StaffId", "out">;
name: z.ZodString;
}, z.core.$strip>;
input: z.ZodString;
output: z.ZodNullable<z.ZodString>;
startedAt: z.ZodString;
}, z.core.$strip>;
export type InstanceSummary = z.infer<typeof instanceSummarySchema>;
export declare const instanceStatsSchema: z.ZodObject<{
total: z.ZodNumber;
byState: z.ZodRecord<z.ZodEnum<{
running: "running";
completed: "completed";
pending: "pending";
failed: "failed";
rejected: "rejected";
}>, z.ZodNumber>;
}, z.core.$strip>;
export type InstanceStats = z.infer<typeof instanceStatsSchema>;
export declare const instanceListSchema: z.ZodObject<{
stats: z.ZodObject<{
total: z.ZodNumber;
byState: z.ZodRecord<z.ZodEnum<{
running: "running";
completed: "completed";
pending: "pending";
failed: "failed";
rejected: "rejected";
}>, z.ZodNumber>;
}, z.core.$strip>;
total: z.ZodNumber;
page: z.ZodNumber;
size: z.ZodNumber;
items: z.ZodArray<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<z.ZodString, "StaffId", "out">;
name: z.ZodString;
}, z.core.$strip>;
input: z.ZodString;
output: z.ZodNullable<z.ZodString>;
startedAt: z.ZodString;
}, z.core.$strip>>;
}, z.core.$strip>;
export type InstanceList = z.infer<typeof instanceListSchema>;
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<typeof instanceFileSchema>;
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<typeof instanceReasonSchema>;
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<z.ZodString>;
durationMs: z.ZodNullable<z.ZodNumber>;
res: z.ZodEnum<{
pending: "pending";
done: "done";
fail: "fail";
current: "current";
}>;
note: z.ZodString;
}, z.core.$strip>;
export type InstanceStep = z.infer<typeof instanceStepSchema>;
/** 起单来源: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<typeof instanceSourceSchema>;
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<z.ZodString, "StaffId", "out">;
name: z.ZodString;
}, z.core.$strip>;
no: z.ZodString;
activityId: z.ZodString;
llm: z.ZodString;
endedAt: z.ZodNullable<z.ZodString>;
durationMs: z.ZodNullable<z.ZodNumber>;
reason: z.ZodNullable<z.ZodObject<{
kind: z.ZodEnum<{
terminated: "terminated";
failed: "failed";
rejected: "rejected";
}>;
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.ZodObject<{
index: z.ZodNumber;
name: z.ZodString;
contentType: z.ZodString;
size: z.ZodNumber;
}, z.core.$strip>>;
}, z.core.$strip>;
output: z.ZodObject<{
files: z.ZodArray<z.ZodObject<{
index: z.ZodNumber;
name: z.ZodString;
contentType: z.ZodString;
size: z.ZodNumber;
}, z.core.$strip>>;
}, z.core.$strip>;
steps: z.ZodArray<z.ZodObject<{
seq: z.ZodNumber;
name: z.ZodString;
by: z.ZodEnum<{
human: "human";
agent: "agent";
}>;
byName: z.ZodString;
at: z.ZodNullable<z.ZodString>;
durationMs: z.ZodNullable<z.ZodNumber>;
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<typeof instanceDetailSchema>;
/**
* 控件种类: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<typeof launchParamTypeSchema>;
export declare const launchOptionSchema: z.ZodObject<{
value: z.ZodString;
label: z.ZodString;
}, z.core.$strip>;
export type LaunchOption = z.infer<typeof launchOptionSchema>;
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<z.ZodBoolean>;
desc: z.ZodOptional<z.ZodString>;
placeholder: z.ZodOptional<z.ZodString>;
options: z.ZodOptional<z.ZodArray<z.ZodObject<{
value: z.ZodString;
label: z.ZodString;
}, z.core.$strip>>>;
default: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
}, z.core.$strip>;
export type LaunchParam = z.infer<typeof launchParamSchema>;
export declare const launchParamsSchema: z.ZodObject<{
activityId: z.ZodString;
version: z.ZodString;
params: z.ZodArray<z.ZodObject<{
key: z.ZodString;
label: z.ZodString;
type: z.ZodEnum<{
text: "text";
textarea: "textarea";
select: "select";
combo: "combo";
list: "list";
}>;
required: z.ZodOptional<z.ZodBoolean>;
desc: z.ZodOptional<z.ZodString>;
placeholder: z.ZodOptional<z.ZodString>;
options: z.ZodOptional<z.ZodArray<z.ZodObject<{
value: z.ZodString;
label: z.ZodString;
}, z.core.$strip>>>;
default: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
}, z.core.$strip>>;
}, z.core.$strip>;
export type LaunchParams = z.infer<typeof launchParamsSchema>;
export declare const launchInputSchema: z.ZodObject<{
idempotencyKey: z.ZodString;
ownerId: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
params: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
}, z.core.$strip>;
export type LaunchInput = z.infer<typeof launchInputSchema>;
+522
View File
@@ -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.ZodEnum<{
active: "active";
disabled: "disabled";
}>>;
}, z.core.$strip>;
readonly output: z.ZodObject<{
activities: z.ZodArray<z.ZodObject<{
id: z.ZodString;
name: z.ZodString;
biz: z.ZodString;
status: z.ZodEnum<{
active: "active";
disabled: "disabled";
}>;
version: z.ZodString;
owner: z.ZodNullable<z.ZodObject<{
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
name: z.ZodString;
}, z.core.$strip>>;
llm: z.ZodString;
instanceCount: z.ZodNumber;
createdAt: z.ZodString;
ico: z.ZodOptional<z.ZodString>;
hue: z.ZodOptional<z.ZodEnum<{
blue: "blue";
teal: "teal";
amber: "amber";
violet: "violet";
}>>;
}, 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<z.ZodObject<{
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
name: z.ZodString;
}, z.core.$strip>>;
llm: z.ZodString;
ico: z.ZodOptional<z.ZodString>;
hue: z.ZodOptional<z.ZodEnum<{
blue: "blue";
teal: "teal";
amber: "amber";
violet: "violet";
}>>;
desc: z.ZodString;
view: z.ZodNullable<z.ZodObject<{
url: z.ZodString;
}, z.core.$strip>>;
}, 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<z.ZodString>;
ownerId: z.ZodOptional<z.ZodString>;
state: z.ZodOptional<z.ZodEnum<{
running: "running";
completed: "completed";
pending: "pending";
failed: "failed";
rejected: "rejected";
}>>;
startedFrom: z.ZodOptional<z.ZodString>;
startedTo: z.ZodOptional<z.ZodString>;
page: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
size: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
}, z.core.$strip>;
readonly output: z.ZodObject<{
stats: z.ZodObject<{
total: z.ZodNumber;
byState: z.ZodRecord<z.ZodEnum<{
running: "running";
completed: "completed";
pending: "pending";
failed: "failed";
rejected: "rejected";
}>, z.ZodNumber>;
}, z.core.$strip>;
total: z.ZodNumber;
page: z.ZodNumber;
size: z.ZodNumber;
items: z.ZodArray<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<z.ZodString, "StaffId", "out">;
name: z.ZodString;
}, z.core.$strip>;
input: z.ZodString;
output: z.ZodNullable<z.ZodString>;
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<z.ZodObject<{
key: z.ZodString;
label: z.ZodString;
type: z.ZodEnum<{
text: "text";
textarea: "textarea";
select: "select";
combo: "combo";
list: "list";
}>;
required: z.ZodOptional<z.ZodBoolean>;
desc: z.ZodOptional<z.ZodString>;
placeholder: z.ZodOptional<z.ZodString>;
options: z.ZodOptional<z.ZodArray<z.ZodObject<{
value: z.ZodString;
label: z.ZodString;
}, z.core.$strip>>>;
default: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
}, 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<z.ZodString, "StaffId", "out">;
params: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodArray<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<z.ZodString, "StaffId", "out">;
name: z.ZodString;
}, z.core.$strip>;
no: z.ZodString;
activityId: z.ZodString;
llm: z.ZodString;
endedAt: z.ZodNullable<z.ZodString>;
durationMs: z.ZodNullable<z.ZodNumber>;
reason: z.ZodNullable<z.ZodObject<{
kind: z.ZodEnum<{
terminated: "terminated";
failed: "failed";
rejected: "rejected";
}>;
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.ZodObject<{
index: z.ZodNumber;
name: z.ZodString;
contentType: z.ZodString;
size: z.ZodNumber;
}, z.core.$strip>>;
}, z.core.$strip>;
output: z.ZodObject<{
files: z.ZodArray<z.ZodObject<{
index: z.ZodNumber;
name: z.ZodString;
contentType: z.ZodString;
size: z.ZodNumber;
}, z.core.$strip>>;
}, z.core.$strip>;
steps: z.ZodArray<z.ZodObject<{
seq: z.ZodNumber;
name: z.ZodString;
by: z.ZodEnum<{
human: "human";
agent: "agent";
}>;
byName: z.ZodString;
at: z.ZodNullable<z.ZodString>;
durationMs: z.ZodNullable<z.ZodNumber>;
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<z.ZodString, "StaffId", "out">;
name: z.ZodString;
}, z.core.$strip>;
no: z.ZodString;
activityId: z.ZodString;
llm: z.ZodString;
endedAt: z.ZodNullable<z.ZodString>;
durationMs: z.ZodNullable<z.ZodNumber>;
reason: z.ZodNullable<z.ZodObject<{
kind: z.ZodEnum<{
terminated: "terminated";
failed: "failed";
rejected: "rejected";
}>;
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.ZodObject<{
index: z.ZodNumber;
name: z.ZodString;
contentType: z.ZodString;
size: z.ZodNumber;
}, z.core.$strip>>;
}, z.core.$strip>;
output: z.ZodObject<{
files: z.ZodArray<z.ZodObject<{
index: z.ZodNumber;
name: z.ZodString;
contentType: z.ZodString;
size: z.ZodNumber;
}, z.core.$strip>>;
}, z.core.$strip>;
steps: z.ZodArray<z.ZodObject<{
seq: z.ZodNumber;
name: z.ZodString;
by: z.ZodEnum<{
human: "human";
agent: "agent";
}>;
byName: z.ZodString;
at: z.ZodNullable<z.ZodString>;
durationMs: z.ZodNullable<z.ZodNumber>;
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=` 给 `<a href download>` 用。
*/
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<unknown>;
}, z.core.$strip>;
readonly query: z.ZodObject<{
raw: z.ZodOptional<z.ZodString>;
}, 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<z.ZodObject<{
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
name: z.ZodString;
}, z.core.$strip>>;
llm: z.ZodString;
ico: z.ZodOptional<z.ZodString>;
hue: z.ZodOptional<z.ZodEnum<{
blue: "blue";
teal: "teal";
amber: "amber";
violet: "violet";
}>>;
desc: z.ZodString;
view: z.ZodNullable<z.ZodObject<{
url: z.ZodString;
}, z.core.$strip>>;
}, 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<z.ZodObject<{
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
name: z.ZodString;
}, z.core.$strip>>;
llm: z.ZodString;
ico: z.ZodOptional<z.ZodString>;
hue: z.ZodOptional<z.ZodEnum<{
blue: "blue";
teal: "teal";
amber: "amber";
violet: "violet";
}>>;
desc: z.ZodString;
view: z.ZodNullable<z.ZodObject<{
url: z.ZodString;
}, z.core.$strip>>;
}, 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<z.ZodString, "StaffId", "out">;
name: z.ZodString;
}, z.core.$strip>;
no: z.ZodString;
activityId: z.ZodString;
llm: z.ZodString;
endedAt: z.ZodNullable<z.ZodString>;
durationMs: z.ZodNullable<z.ZodNumber>;
reason: z.ZodNullable<z.ZodObject<{
kind: z.ZodEnum<{
terminated: "terminated";
failed: "failed";
rejected: "rejected";
}>;
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.ZodObject<{
index: z.ZodNumber;
name: z.ZodString;
contentType: z.ZodString;
size: z.ZodNumber;
}, z.core.$strip>>;
}, z.core.$strip>;
output: z.ZodObject<{
files: z.ZodArray<z.ZodObject<{
index: z.ZodNumber;
name: z.ZodString;
contentType: z.ZodString;
size: z.ZodNumber;
}, z.core.$strip>>;
}, z.core.$strip>;
steps: z.ZodArray<z.ZodObject<{
seq: z.ZodNumber;
name: z.ZodString;
by: z.ZodEnum<{
human: "human";
agent: "agent";
}>;
byName: z.ZodString;
at: z.ZodNullable<z.ZodString>;
durationMs: z.ZodNullable<z.ZodNumber>;
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[];
@@ -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 };
}
+560
View File
@@ -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 };
},
};
}
+237
View File
@@ -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 ?? '' };
},
};
}
+114
View File
@@ -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)}`;
}
+170
View File
@@ -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;
}
+167
View File
@@ -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;
/*
* 资产初始版随包播种。
+22
View File
@@ -0,0 +1,22 @@
-- 运营中心 · 活动模块(2026-08-27)
-- 启动幂等(idempotencyKey):同一枚键只起一单;task_id 为空 = 正在创建(60 秒后可被重占)。
-- 这一行同时是「手动启动 · 谁」的事实来源(实例详情的 input.source)。
create table if not exists sys.activity_launch (
key text primary key,
activity_id text not null,
owner_id text not null,
launched_by text not null,
task_id text,
claimed_at timestamptz not null default now()
);
create unique index if not exists activity_launch_task_key on sys.activity_launch (task_id) where task_id is not null;
-- 运营侧活动档案:负责人 / 说明 / 图标 / 色相(用户定:活动包本身不载这些;seed 给三个真包填上)
create table if not exists sys.activity_profile (
activity_id text primary key,
owner_id text,
description text not null default '',
ico text,
hue text,
updated_at timestamptz not null default now()
);
+442 -158
View File
@@ -1,102 +1,310 @@
# API 文档 —— 活动模块(L1 清单 / L2 活动详情 / L3 实例详情)
> 本文是活动模块前端(运营中心 · 活动区,2026-08-25 重构模版)与服务端之间的接口约定,
> 给后端编写接口、前端取数用。鉴权 / 票据 / 错误体 / 端归属通则:`docs/API文档-鉴权与人员.md` §0,本文不重复。
> 本文是活动模块前端(运营中心 · 活动区,2026-08-25 重构模版)与服务端之间的接口约定,给前端取数、后端维护用。
> 鉴权 / 票据 / 错误体 / 端归属的**通则**在 `docs/API文档-鉴权与人员.md` §0,本文 §0.1 只摘调用时必须知道的几条。
> 需求出处:`docs/活动模块重构需求-2026-08-25.md`;启动参数口径:`ACT-WORK-05-一键启动入参说明.md`(极简四参)。
> 契约落地后唯一真相在 `packages/contracts/src/api/*.ts`(zod schema,前后端共用);两者不一致时以契约为准并回来改本文。
> **契约已落地(2026-08-27)**:`packages/contracts/src/activity-ops.ts`(数据结构 zod)+ `packages/contracts/src/api/activity-ops.ts`
> (11 条路由,花名册 `ACTIVITY_OPS_ROUTES`);前端一律 `import { xxxRoute } from '@contracts'` 经 `shared/typed-fetch` 的 `call(route, { params, query, body })` 调用,
> 类型直接用 `@contracts` 导出的(§1 每张表都标了导出名)。两者不一致时以契约为准并回来改本文。
> 设计端的活动库两条路由已改名让位:`GET/POST /api/gov/activities` → `/api/gov/activity-packages`(前端 import 路由对象,不用改)。
## 0. 通则(本文特有)
## 0. 通则
### 0.1 调用约定
| 项 | 值 |
|---|---|
| 前缀 | `/api/gov/activities/*`;**全部 `access: 'ops'`** —— 活动区落在运营中心,与工作流/任务看板同端 |
| 时间 | 一律 ISO 8601 UTC 字符串(`2026-08-27T02:25:11.000Z`);**展示格式化是前端的事**(`YYYY-MM-DD HH:mm:ss` 本地时区) |
| 人员 | 凡出人,都是 `{ "id": "<staffId>", "name": "柴浩" }` 两段 —— 展示用 name,筛选/提交用 id;人员候选名单用既有 `GET /api/gov/staff`,本文不另做 |
| 广播 | 写路由(启动 / 禁用 / 激活 / 终止)成功后广播 `{kind:'system', id:'gov'}` —— 前端按既有套路失效 `['gov',*]` 重取 |
| 实例状态 | 五态,英文码:`pending`(待执行)/ `running`(进行中)/ `completed`(已完成)/ `rejected`(驳回)/ `failed`(失败);**没有第六态** —— 手动终止归入 `failed`,用 `reason.kind='terminated'` 区分「自己跑挂」(见 §6) |
| 禁用语义 | **任何时刻都可禁用**(2026-08-27 用户重定,废除「有实例不能禁用」):禁用只拦「新启动」—— 已完成的实例是历史留痕,进行中的实例**继续跑完不受影响**;激活即恢复启动能力 |
| 前缀 | `/api/gov/activities/*`;**11 条全部 `access: 'ops'`** —— 登录人的 `ends` 里要有 `ops`,否则 403 |
| 请求头 | `Authorization: Bearer <Keycloak access_token>`;写请求 `content-type: application/json` |
| 票据 | 只有两条路带不了请求头(`<iframe src>` / `<a href download>`):§2.3 视图、§7 文件。GET 时改带 `?ticket=<票据>`(取自 `GET /api/auth/ticket`,**60 秒有效,用的那一刻再取**) |
| 响应 | 除 §2.3(`text/html`)与 §7 `?raw=1`(文件字节)外,全部 `application/json` |
| 时间 | 一律 ISO 8601 UTC 字符串(`2026-08-27T02:25:11.000Z`);**展示格式化是前端的事**(`YYYY-MM-DD HH:mm:ss` 本地时区,用 `shared/at.ts`) |
| 人员 | 凡出人,都是 `StaffRef = { id, name }` —— 展示用 `name`,筛选 / 提交用 `id`(= 名册 staffId);候选名单用既有 `GET /api/gov/staff` |
| 实时刷新 | 写路由(启动 / 禁用 / 激活 / 终止)成功后服务端广播 `{kind:'system', id:'gov'}`,前端既有 SSE 订阅会失效 `['gov', …]` 缓存 —— 查询键放在 `['gov', 'activities', …]` 下就自动重取 |
| 分页 | 只有 §3 实例清单分页(`page` 从 1 起,`size` 默认 8、上限 50);其余不分页 |
状态码怎么读(通则之外本文特有的):
### 0.2 错误体
| 状态 | code | 何时 |
所有非 2xx 都是同一形状:
```json
{ "code": "validation-failed", "message": "启动参数不合要求:candidateInternalPns 必填,至少一项", "detail": [ { "key": "candidateInternalPns", "message": "必填,至少一项" } ] }
```
| 状态 | `code` | 何时 | `detail` |
|---|---|---|---|
| 400 | `validation-failed` | 查询参数 / 路径参数 / 请求体不合 schema;启动参数缺必填 / 类型不对 / select 值不在候选 / 不认识的参数名 / 责任人不在职 | schema 不合 → zod issues 数组;启动参数问题 → `[{ key, message }]`,`key` 是哪一格(责任人那一条 `key='ownerId'`) |
| 401 | `unauthenticated` | 没带 token / 过期 / 票据坏了 | — |
| 403 | `forbidden` | 登录人进不了运营中心 | `{ need, ends }` |
| 404 | `not-found` | 活动不存在;实例不存在或不属于这个活动;活动没配视图;文件序号不存在 | — |
| 409 | `conflict` | 启动:同一 `idempotencyKey` 的实例**正在创建**(60 秒内再来);或这枚键已用在别的活动上 | — |
| 412 | `precondition-failed` | 启动:活动已禁用;终止:实例已终态 | — |
| 500 | `internal` | 服务端出错 | — |
### 0.3 状态口径
| 项 | 值 |
|---|---|
| 活动状态 `ActivityStatus` | `active`(激活中)/ `disabled`(已禁用)。中文在 `@contracts` 的 `ACTIVITY_STATUS_LABEL` |
| 实例状态 `InstanceState` | 五态英文码:`pending`(待执行)/ `running`(进行中)/ `completed`(已完成)/ `rejected`(驳回)/ `failed`(失败);中文在 `INSTANCE_STATE_LABEL`。**没有第六态** —— 手动终止归 `failed`,靠 `reason.kind='terminated'` 区分「自己跑挂」。口径:`pending` = 建了单、推进器还没领到;`running` = 在途(含等人回传 / 监听中);`completed` = 放行完成;`rejected` = 验收被负责人打回、模型还没重新提交的那一段(重新提交后回 `running`);`failed` = 跑挂(受阻 / 停滞熔断 / 预算耗尽)与手动终止 |
| 禁用语义 | **任何时刻都可禁用**:只拦「新启动」(§5 回 412);已完成的实例是历史留痕,进行中的**继续跑完**;激活即恢复 |
| 活动档案 | 负责人 / 说明 / 图标 / 色相来自运营侧档案表,不在活动包里;**没建档时 `owner` 是 `null`、`desc` 是空串、`ico`/`hue` 缺席**,前端摆「—」并兜底图标色 |
## 1. 数据结构(`@contracts` 导出名在括号里)
字段列「必有」= 响应里一定带这个键(可能是 `null` 的会单独写);「可缺」= 键可能不出现。
### 1.1 `StaffRef`(`staffRefSchema`)
| 字段 | 类型 | 说明 |
|---|---|---|
| 400 | `validation-failed` | 入参不合 schema(`detail` 是 zod issues);启动参数缺必填 / 类型不对(`detail` 指明是哪个 `key`) |
| 404 | `not-found` | 活动 / 实例不存在;实例不属于这个活动 |
| 412 | `precondition-failed` | 启动:活动已禁用;终止:实例不在 `running` |
| `id` | string | 名册 staffId |
| `name` | string | 姓名 |
## 1. L1 活动清单
### 1.2 `ActivitySummary`(`activitySummarySchema`)—— L1 卡片
### `GET /api/gov/activities?status=active|disabled` —— access: `ops`
| 字段 | 类型 | 必有 | 说明 |
|---|---|---|---|
| `id` | string | 是 | 活动 id,如 `part-substitution-assessment`;后面所有路径里的 `:activityId` |
| `name` | string | 是 | 活动名 |
| `biz` | string | 是 | 业务分类(卡片副题) |
| `status` | `ActivityStatus` | 是 | `active` / `disabled` |
| `version` | string | 是 | 当前生效的包版本,如 `1.5.7` |
| `owner` | `StaffRef` \| **null** | 是 | 活动负责人;没建档 = `null`,摆「—」 |
| `llm` | string | 是 | 绑定的模型(逻辑名,如 `deepseek-v4-flash`) |
| `instanceCount` | number | 是 | 该活动的实例总数(含测试台试跑单) |
| `createdAt` | string(ISO) | 是 | 首个版本装入的时刻 |
| `ico` | string | 可缺 | 语义图标名;缺省前端兜底 `inbox` |
| `hue` | `'blue' \| 'teal' \| 'amber' \| 'violet'` | 可缺 | 业务色相;缺省前端兜底 `blue` |
L1 两个页签(激活中 / 已禁用)各取一份;不带 `status` = 全量。量小,**不分页**。
### 1.3 `ActivityDetail`(`activityDetailSchema`)—— L2 头部
`ActivitySummary` **去掉 `instanceCount`**,加:
| 字段 | 类型 | 必有 | 说明 |
|---|---|---|---|
| `desc` | string | 是 | 定义说明;没写就是 `''` |
| `view` | `{ url: string }` \| **null** | 是 | 沙箱要渲染的活动视图地址(相对路径,见 §2.3);`null` = 藏起整个沙箱区块 |
### 1.4 `InstanceSummary`(`instanceSummarySchema`)—— L2 表格一行
| 字段 | 类型 | 必有 | 说明 |
|---|---|---|---|
| `id` | string | 是 | 实例的**寻址键**(任务 id,uuid):L3 路由、终止、文件都用它 |
| `no` | string | 是 | 人读实例号 `INS-<序号>`(表格上摆它、模糊搜也认它) |
| `state` | `InstanceState` | 是 | 五态 |
| `owner` | `StaffRef` | 是 | 责任人 |
| `input` | string | 是 | 输入一句话摘要(≤80 字,可为 `''`) |
| `output` | string \| **null** | 是 | 产出一句话摘要;只有 `completed` 才有值(文件名,或「已发口:<口名>」);`null` 摆 `-` |
| `startedAt` | string(ISO) | 是 | 启动时刻 |
### 1.5 `InstanceStats` / `InstanceList`(`instanceStatsSchema` / `instanceListSchema`)
| 字段 | 类型 | 说明 |
|---|---|---|
| `stats.total` | number | 该活动实例总数(**不随检索条件动**,页头统计带用) |
| `stats.byState` | `Record<InstanceState, number>` | 五个键**都在**(没有的是 0) |
| `total` | number | **筛后**条数,分页器按它 |
| `page` | number | 当前页(回显请求值,默认 1) |
| `size` | number | 单页条数(回显请求值,默认 8) |
| `items` | `InstanceSummary[]` | 当前页,启动时间倒序 |
### 1.6 `LaunchParam` / `LaunchParams`(`launchParamSchema` / `launchParamsSchema`)—— 启动表单规格
`LaunchParams`:
| 字段 | 类型 | 说明 |
|---|---|---|
| `activityId` | string | |
| `version` | string | 参数表对应的包版本 |
| `params` | `LaunchParam[]` | **按这个顺序渲染表单** |
`LaunchParam`:
| 字段 | 类型 | 必有 | 说明 |
|---|---|---|---|
| `key` | string | 是 | 提交时 `params` 里的键名(英文) |
| `label` | string | 是 | 表单上的中文名 |
| `type` | `'text' \| 'textarea' \| 'select' \| 'combo' \| 'list'` | 是 | 控件种类:单行 / 多行 / 单选(只能选候选)/ **选择+新增**(候选末项「新增」,选了换输入框自填)/ 字符串数组(逐行自由填,可加多项)。**遇到不认识的 type 摆「暂不支持的控件」**。前端只按 `type` 渲染 —— 活动契约里是数组的字段也可能摆成 `combo`/`select`(只选一颗,提交单个字符串,后端包成数组),前端不必知道 |
| `required` | boolean | 可缺 | 缺席 = 选填。前端没填完禁提交;服务端仍再验一道(400) |
| `desc` | string | 可缺 | 控件下的辅助说明 |
| `placeholder` | string | 可缺 | text / textarea / combo(新增态)/ list 用 |
| `options` | `{ value: string; label: string }[]` | 可缺 | `select` / `combo` 的候选,两段式:`value` 是提交值,`label` 是显示名。**combo 可能没有这个键**(候选读不到),那时就是自由输入 |
| `default` | string \| string[] | 可缺 | 预填值:`list` 给字符串数组,其余给字符串 |
### 1.7 `LaunchInput`(`launchInputSchema`)—— 启动请求体
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `idempotencyKey` | string(1–128) | 是 | 幂等键:**每开一次抽屉生成一枚**(`crypto.randomUUID()`);重复点提交、网络重试都带同一枚 |
| `ownerId` | string | 是 | 责任人 staffId(固定字段,不在参数表里);默认当前登录人 `me.staff.id`,允许改;只列 `GET /api/gov/staff` 里 `state='active'` 的 |
| `params` | `Record<string, string \| string[]>` | 是 | 键 = `LaunchParam.key`;`list` 传 `string[]`,其余传 `string`;`select` 传某个 `options[].value`;`combo` 传候选的 `value` **或**新增的原样字符串(两种都合法);选填没填可以不传或传 `''`。服务端宽收:活动契约里是数组的字段(含 `list`)传 `string` 或 `string[]` 都行,统一归成数组交活动 |
### 1.8 `InstanceFile`(`instanceFileSchema`)
| 字段 | 类型 | 说明 |
|---|---|---|
| `index` | number | 在同一侧(input / output)文件里的序号,从 0 起;§7 下载用 |
| `name` | string | 文件名(含扩展名) |
| `contentType` | string | MIME |
| `size` | number | 字节数 |
### 1.9 `InstanceReason`(`instanceReasonSchema`)
| 字段 | 类型 | 说明 |
|---|---|---|
| `kind` | `'rejected' \| 'failed' \| 'terminated'` | 驳回 / 自己跑挂 / 手动终止 |
| `text` | string | 人话原因,直接摆。驳回:「验收驳回:<负责人写的意见> —— 负责人 <名>」(没写意见时「验收驳回:负责人未写明具体意见,已退回重做」);失败:引擎原话(预算耗尽已翻成「预算耗尽:步数 90/90、活跃时长 120/120 分钟」);终止:「由 <操作人> 手动终止」 |
### 1.10 `InstanceSource`(`instanceSourceSchema`)
| 字段 | 类型 | 说明 |
|---|---|---|
| `kind` | string | `manual`(运营端手动启动)/ `upstream`(上游工作流节点)/ `mail` 等通道名(来件触发)/ `testbed`(测试台试跑)/ `system`(其余)。**开放枚举**,不认识的按 `label` 摆 |
| `label` | string | 人读形式,直接摆:`手动启动 · 柴浩` / `上游 · <工作流主题>` / `邮件 · <发件方>` / `测试台试跑` / `系统起单` |
### 1.11 `InstanceStep`(`instanceStepSchema`)—— L3 时间线一行
| 字段 | 类型 | 说明 |
|---|---|---|
| `seq` | number | 从 1 起,按活动案例模型的步骤序 |
| `name` | string | 步骤名(活动定义的计划项名) |
| `by` | `'agent' \| 'human'` | 这一步谁办:AI / 人 |
| `byName` | string | `agent` → 模型名(同 `llm`);`human` → 责任人姓名 |
| `at` | string(ISO) \| null | 开始时刻;没到这一步 = `null` |
| `durationMs` | number \| null | 用时(毫秒);未完成 = `null`。格式化如 `6m` |
| `res` | `'done' \| 'fail' \| 'current' \| 'pending'` | 已完成 / 出事(性质看 `reason.kind`:驳回 · 失败 · 已终止)/ 正在办 / 未到 |
| `note` | string | 这一步交出什么的一句话;可为 `''` |
### 1.12 `InstanceDetail`(`instanceDetailSchema`)—— L3 整页
| 字段 | 类型 | 必有 | 说明 |
|---|---|---|---|
| `id` | string | 是 | 任务 id |
| `no` | string | 是 | 人读实例号 |
| `activityId` | string | 是 | |
| `state` | `InstanceState` | 是 | |
| `owner` | `StaffRef` | 是 | 责任人 |
| `llm` | string | 是 | 模型名 |
| `startedAt` | string(ISO) | 是 | |
| `endedAt` | string(ISO) \| **null** | 是 | 未结束 = `null`;终止时 = 终止时刻 |
| `durationMs` | number \| **null** | 是 | `endedAt − startedAt`;未结束 = `null` |
| `reason` | `InstanceReason` \| **null** | 是 | `null` = 不需要(待执行 / 进行中 / 正常完成) |
| `input.content` | string | 是 | 输入一句话摘要(= 清单上的 `input`) |
| `input.source` | `InstanceSource` | 是 | 来源 |
| `input.submittedAt` | string(ISO) | 是 | 提交时刻(= `startedAt`) |
| `input.files` | `InstanceFile[]` | 是 | 输入文件元数据(可为空数组) |
| `output.files` | `InstanceFile[]` | 是 | 产出文件元数据;没产出是 `[]`,摆「暂无产出」 |
| `steps` | `InstanceStep[]` | 是 | 时间线 |
注意:详情里**没有** `input`/`output` 那两句摘要字符串(它们是清单行的字段),`input`/`output` 在详情里是上面的结构体。
## 2. L1 / L2 活动
### 2.1 活动清单 —— `GET /api/gov/activities`(`listOpsActivitiesRoute`)
**请求**
| 位置 | 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
| query | `status` | `'active' \| 'disabled'` | 否 | L1 两个页签各取一份;不带 = 全量。别的值 400 |
**响应 200**
| 字段 | 类型 | 说明 |
|---|---|---|
| `activities` | `ActivitySummary[]` | 量小不分页;顺序 = 活动库顺序 |
```json
{
"activities": [
{
"id": "quote-intake",
"name": "询价接收与结构化",
"biz": "报价业务",
"id": "part-substitution-assessment",
"name": "ACT-WORK-05 · 差异评估",
"biz": "需求分析与方案匹配",
"status": "active",
"version": "1.3.0",
"owner": { "id": "8f3c…", "name": "柴浩" },
"llm": "K3 · quote-intake",
"version": "1.5.7",
"owner": null,
"llm": "deepseek-v4-flash",
"instanceCount": 12,
"createdAt": "2026-08-12T01:00:00.000Z",
"ico": "inbox",
"hue": "blue"
"createdAt": "2026-08-26T01:00:00.000Z",
"ico": "git-compare",
"hue": "teal"
}
]
}
```
- `ico` / `hue`:展示元数据(语义图标名 / 业务色相 `blue|teal|amber|violet`),创建活动时可配;**缺省时前端兜底**(`inbox` / `blue`),后端可以暂时不存。
- `llm`:这个活动绑定的专属 agent 的人读标签;要链到 LLM 实例时再加 `llmId`,本期没有。
### 2.2 活动详情 —— `GET /api/gov/activities/:activityId`(`getOpsActivityRoute`)
## 2. L2 活动定义
**请求**:路径参数 `activityId`(string,必填)。
### `GET /api/gov/activities/:activityId` —— access: `ops`
**响应 200**:`ActivityDetail`。**404**:活动不存在。
```json
{
"id": "quote-intake",
"name": "询价接收与结构化",
"biz": "报价业务",
"id": "part-substitution-assessment",
"name": "ACT-WORK-05 · 差异评估",
"biz": "需求分析与方案匹配",
"status": "active",
"version": "1.3.0",
"version": "1.5.7",
"owner": { "id": "8f3c…", "name": "柴浩" },
"llm": "K3 · quote-intake",
"createdAt": "2026-08-12T01:00:00.000Z",
"ico": "inbox",
"hue": "blue",
"desc": "活动定义只读 —— 输入与输出在创建时定死,演进走版本迭代;验收在工作台,这里只看状态。",
"view": { "url": "/api/gov/activities/quote-intake/view" }
"llm": "deepseek-v4-flash",
"createdAt": "2026-08-26T01:00:00.000Z",
"ico": "git-compare",
"hue": "teal",
"desc": "对候选料号逐项比较参数、判四态、定推荐等级,审核后落 P-11 替代关系并回写 P-01。",
"view": { "url": "/api/gov/activities/part-substitution-assessment/view" }
}
```
- `desc`:定义说明,创建时填,只读。
- `view`:沙箱要渲染的活动视图;**为 `null` 时前端藏起整个沙箱区块**(不再摆空 iframe)。
### 2.3 活动视图(沙箱)—— `GET /api/gov/activities/:activityId/view`(`opsActivityViewRoute`)
### `GET /api/gov/activities/:activityId/view` —— access: `ops`(支持 `?ticket=`)
**请求**
→ `content-type: text/html`,服务器托管的活动视图 HTML,前端 `<iframe sandbox="allow-scripts">` 直接渲染。
iframe 带不了 Authorization 头,走票据(`GET /api/auth/ticket`,ADR-012 §6)—— 前端每次装载前取一张拼 `?ticket=`。
**内容高度用 postMessage 通讯自适应(视图脚本向父页报高),协议随第一版视图实现一起定。**404:该活动没配视图。
| 位置 | 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
| path | `activityId` | string | 是 | |
| query | `ticket` | string | 是(iframe 场景) | `<iframe src>` 带不了请求头:**装载那一刻** `call(authTicketRoute)` 取一张拼上,不要渲染时预取(60 秒过期) |
## 3. L2 实例清单(历史活动)
**响应 200**:`content-type: text/html`(`cache-control: no-store`),整份 HTML,`<iframe sandbox="allow-scripts" src="...">` 直接渲染。
**404**:活动不存在 / 该活动没配视图(`detail.view === null` 时压根别请求)。
### `GET /api/gov/activities/:activityId/instances` —— access: `ops`
**沙箱里摆什么**(2026-08-27 定):活动包里名为 `activity-ledger` 的视图 —— **关键台账数据看板**,
摆本活动**最近一次已放行实例**的落账快照(页头:实例号 / 开案与放行时刻 / 负责人 / 案号 / 关联需求 / 评估基准与候选;
正文:P-01 一条 · P-08 逐颗料 + 参数瓷贴 · P-11 主记录 + 差异明细 · P-10 · P-06 本单挂的 + 同料号历史)。
行由服务端按活动清单 `viewTemplates[].data` 的取数声明从**正式表**圈出(在办中的单不摆),渲染完再注入报高脚本。
三种局面页面自己说清、都回 200:有落账(`v-ready`)/ 本活动还没有已放行实例(`v-none`,只留页头与横幅)/
最近那一单已放行但案号在正式表里查无(`v-missing`,多半是起单时关了「落真库」)。前端不需要为这三态做任何事。
查询参数(可组合,AND;都不带 = 全部):
**高度协议**:页面自己会向父页 `postMessage({ type: 'jcai:view-height', height: <number> }, '*')`(load、尺寸变化、每秒兜底)。父页:
| 参数 | 形 | 说明 |
|---|---|---|
| `id` | 字符串 | 实例号**模糊**搜,如 `INS-24` |
| `ownerId` | staffId | 精确到人 |
| `state` | 五态之一 | 精确 |
| `startedFrom` / `startedTo` | `YYYY-MM-DD` | 启动日期段,**含两端整日**;可只给一端 |
| `page` | 整数,从 1 | 默认 1 |
| `size` | 整数 | 默认 8(与前端单页一致),上限 50 |
```ts
window.addEventListener('message', (e) => {
if (e.source !== iframe.contentWindow) return; // 沙箱帧 origin 是 'null',按 source 认,不按 origin
if (e.data?.type !== ACTIVITY_VIEW_HEIGHT_MESSAGE) return; // 常量在 @contracts,= 'jcai:view-height'
setHeight(Number(e.data.height));
});
```
## 3. L2 实例清单 —— `GET /api/gov/activities/:activityId/instances`(`listOpsInstancesRoute`)
**请求**
| 位置 | 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
| path | `activityId` | string | 是 | |
| query | `id` | string | 否 | 实例号**模糊**搜,如 `INS-24`:对 `no` 与 `id` 的子串,不分大小写 |
| query | `ownerId` | string(staffId) | 否 | 精确到人 |
| query | `state` | `InstanceState` | 否 | 精确;别的值 400 |
| query | `startedFrom` | `YYYY-MM-DD` | 否 | 启动日期段起点,**含整日**;格式不对 400 |
| query | `startedTo` | `YYYY-MM-DD` | 否 | 终点,**含整日**;可只给一端 |
| query | `page` | number ≥ 1 | 否 | 默认 1 |
| query | `size` | number 1–50 | 否 | 默认 8(与前端单页一致);>50 → 400 |
条件之间 AND;都不带 = 全部。
**响应 200**:`InstanceList`。**404**:活动不存在。
```json
{
@@ -106,191 +314,231 @@ iframe 带不了 Authorization 头,走票据(`GET /api/auth/ticket`,ADR-012 §6)
"size": 8,
"items": [
{
"id": "INS-2417",
"id": "0c9d3a1e-…",
"no": "INS-2417",
"state": "running",
"owner": { "id": "8f3c…", "name": "柴浩" },
"input": "Itelma 车身控制器询价 · 3 项物料",
"input": "工业电源项目在用 LM2596S-5.0(TI,12V→5V),原厂 PCN 转 NRND…",
"output": null,
"startedAt": "2026-08-25T02:12:00.000Z"
},
{
"id": "INS-2415",
"id": "7b21e6c0-…",
"no": "INS-2415",
"state": "completed",
"owner": { "id": "8f3c…", "name": "柴浩" },
"input": "GAZ 底盘线束询价 · 7 项物料",
"output": "quote-20260824-07.json",
"input": "…",
"output": "已发口:assessment-released",
"startedAt": "2026-08-24T09:03:00.000Z"
}
]
}
```
- **`stats` 是活动整体的五态统计,不随检索条件动**(页头统计带摆的是全局数);`total` 是**筛后**条数,分页器按它走。
- `input` / `output` 都是**一句话摘要**;`output` 为 `null` 前端摆 `-`。排序:启动时间倒序(新的在前)。
- 页头统计带摆 `stats`(全局),分页器按 `total`(筛后)走;筛中提示「筛出 `total` / 共 `stats.total`」。
- 负责人下拉的候选用 `GET /api/gov/staff`(在职的),提交 `ownerId`。
- 前端**不再**本地过滤 / 分页 / 数统计,全部上移到这里。
## 4. 启动参数表
## 4. 启动参数表 —— `GET /api/gov/activities/:activityId/launch-params`(`opsLaunchParamsRoute`)
### `GET /api/gov/activities/:activityId/launch-params` —— access: `ops`
点「启动活动」开抽屉时取。**一活动一份,前端只按 `params` 渲染表单,不写死字段。**
点「启动活动」开抽屉时取。**一活动一份,前端只按此表渲染表单,不写死字段。**
**请求**:路径参数 `activityId`。**响应 200**:`LaunchParams`。**404**:活动不存在。
```json
{
"activityId": "quote-intake",
"version": "1.3.0",
"activityId": "part-substitution-assessment",
"version": "1.5.7",
"params": [
{
"key": "customer_id",
"key": "customerCode",
"label": "客户",
"type": "combo",
"required": true,
"desc": "台账里有就直接选;是新客户就自己填,启动后派数据工单补档案。",
"desc": "台账里有就直接选;是新客户就自己填客户编码,启动后派数据录入卡建档。",
"placeholder": "CU-0142",
"options": [
{ "value": "cust-itelma", "label": "Itelma" },
{ "value": "cust-gaz", "label": "GAZ" }
{ "value": "CU-0142", "label": "Itelma" },
{ "value": "CU-2039", "label": "青岛新星电子有限公司" }
]
},
{
"key": "request_description",
"key": "customerName",
"label": "客户名称(仅客户不在库时填)",
"type": "text",
"desc": "新客户先把名字摆上,启动后建档时让人核。",
"placeholder": "深圳恒星电源有限公司"
},
{
"key": "requestDescription",
"label": "客户需求",
"type": "textarea",
"required": true,
"desc": "保留原始需求语义,据此新建需求主记录(V1)。",
"placeholder": "如:车身控制器询价 · 3 项物料,期望 9 月交期"
"desc": "保留原始需求语义,据此新建 P-01 需求主记录(版本 V1)。",
"placeholder": "工业电源项目在用 LM2596S-5.0(TI,12V→5V),原厂 PCN 转 NRND,客户要求验证替代料"
},
{
"key": "original_internal_pn",
"key": "originalInternalPn",
"label": "原内部料号",
"type": "combo",
"required": true,
"desc": "差异评估基准;库里没有该 PN 也可启动,启动后派数据工单补参数。",
"placeholder": "如 PN-880501",
"options": [
{ "value": "PN-880101", "label": "PN-880101" }
]
"placeholder": "LM2596S-5.0",
"options": [{ "value": "LM2596S-5.0", "label": "LM2596S-5.0" }]
},
{
"key": "candidate_internal_pn",
"key": "candidateInternalPns",
"label": "候选内部料号",
"type": "list",
"type": "combo",
"required": true,
"desc": "被评估物料,一次可传多个、逐个评估 —— 至少 1 个。",
"placeholder": "如 PN-880412"
"desc": "被评估物料;库里有就直接选,没有就自己填料号,启动后经数据工单创建。",
"placeholder": "XL2596S-5.0",
"options": [{ "value": "LM2596S-5.0", "label": "LM2596S-5.0" }, { "value": "LM2596S-ADJ", "label": "LM2596S-ADJ" }]
}
]
}
```
| 字段 | 说明 |
|---|---|
| `key` | 提交时回传的字段名(接口契约,英文) |
| `label` | 表单上的中文名 |
| `type` | 控件种类:`text` 单行 / `textarea` 多行 / `select` 单选(只能选候选) / **`combo` 选择+新增**(候选末项是「新增」,选了换输入框自己填;见下方「combo 语义」) / `list` 字符串数组(一次可传多个,逐行自由填);**新增种类前要前后端一起升级**,前端遇到不认识的 type 摆「暂不支持的控件」 |
| `required` | 必填;前端在没填完时禁提交,**服务端仍要再验一道**(400) |
| `desc` | 参数描述,挂在控件下当辅助说明 |
| `placeholder` | 选填,text / textarea / combo(新增态) / list 用 |
| `options` | `select` / `combo` 必填;**两段式 `{ value, label }`** —— value 是提交的标识(如客户 ID),label 是显示名;标识即显示名时两段相同 |
| `default` | 选填;预填值(`list` 给字符串数组,其余给字符串)—— 上游触发预填场景用 |
W05 的候选内部料号(2026-08-27 用户定)与原内部料号**同款下拉、只选一颗**:清单里它仍是数组字段,前端按 `type='combo'` 渲染、提交单个字符串,后端包成一元数组交给活动 —— 活动契约不变。
**combo 语义(2026-08-27 用户定)**:启动表单大部分是自由输入;客户 / 料号这类「台账里可能有」的参数才用 combo —— 先选,候选里没有就新增自填。**提交候选之外的值是合法的,不拦启动**:服务端按 value 在台账里查,查不到照样启动,启动后派**数据工单**到工作台补这条档案(客户进客户台账、PN 补参数,确认即入库,活动从断点续跑)。combo 提交的值统一回字符串:选中候选回它的 `value`,新增回用户填的原样字符串 —— 服务端不靠类型区分新旧,靠台账存在性检查。
**combo 语义(2026-08-27 用户定)**:启动表单大部分是自由输入;客户 / 料号这类「台账里可能有」的参数才用 combo —— 先选,候选里没有就新增自填。**提交候选之外的值是合法的,不拦启动**:服务端按值在台账里查,查不到照样启动,启动后派**数据工单**到工作台补这条档案(客户进客户台账、PN 补参数,确认即入库,活动从断点续跑)。combo 提交的值统一是字符串:选中候选传它的 `value`,新增传用户填的原样字符串。
候选量大到不宜内联时(如全量物料台账选 PN),后续加 `optionsSource` 引用检索接口,**本期一律内联 `options`**。
候选量大到不宜内联时(如全量物料台账选 PN),后续加 `optionsSource` 引用检索接口;**本期一律内联 `options`**(封顶 500 行)。
## 5. 启动活动(创建实例)
> 活动清单侧怎么声明(前端不用管,给写活动包的人看):`properties.<key>` 上 `title`→label、`description`→desc、`examples[0]`→placeholder、`default`、`required[]`;`enum`+`enumNames`→select;`'x-control'` 定标量控件(`text|textarea|select|combo`);`'x-options': { ledger:'域.对象', value:'列', label:'列' }` 让候选从正式台账现读;`type:'array'` 且元素是字符串→list,**数组字段标 `x-control: combo|select` 就摆成单选**(提交单个字符串,后端包成一元数组);**`'x-order': <数字>` 定表单次序**(清单存在 jsonb 里,书写顺序到不了这里);`format:'file'` 的字段本期不上表单;select 没候选降成 text。
### `POST /api/gov/activities/:activityId/instances` —— access: `ops`
## 5. 启动活动(创建实例)—— `POST /api/gov/activities/:activityId/instances`(`launchOpsInstanceRoute`)
**请求**:路径参数 `activityId`;请求体 `LaunchInput`(§1.7)。
```json
{
"idempotencyKey": "01J9…",
"idempotencyKey": "b5a2d6f0-3e1c-4a7b-9d2e-8f1c0a4b7e93",
"ownerId": "8f3c1a2b-6c1e-4b3a-9f0e-2d5c7a1b9e44",
"params": {
"customer_id": "cust-itelma",
"request_description": "车身控制器询价 · 3 项物料,期望 9 月交期",
"original_internal_pn": "PN-880101",
"candidate_internal_pn": ["PN-880412", "PN-880530"]
"customerCode": "CU-0142",
"requestDescription": "工业电源项目在用 LM2596S-5.0,原厂 PCN 转 NRND,客户要求验证替代料",
"originalInternalPn": "LM2596S-5.0",
"candidateInternalPns": "XL2596S-5.0"
}
}
```
→ 200 一条实例详情(同 §6)。**同一 `idempotencyKey` 重放返回第一次创建的实例,不起第二个**(前端每开一次抽屉生成一枚;重复点提交、网络重试都靠它挡住)。
`params` 的键值形状由 §4 参数表决定:`list` 回字符串数组,其余回字符串;`select` 回 `options` 里某个 `value`;**`combo` 回候选的 `value` 或新增的原样字符串,两种都合法**。
412:活动已禁用(先激活再启动)。400:缺必填 / 类型不符 / select 的值不在 options 里(**combo 不适用这条** —— 候选之外的值是合法新增)。
`candidateInternalPns` 在活动契约里是数组:表单单选传 `"XL2596S-5.0"`,服务端包成 `["XL2596S-5.0"]`;传 `["XL2596S-5.0", "LM2596S-ADJ"]` 也照收。
正常落地初始状态 `pending`(排入队列,调度器领到即转 `running`);**存在性检查(客户 / 料号在不在台账)在启动后做,查不到不拦启动** —— 按入参说明口径,缺口派数据工单到工作台逐项补(确认即入库,活动从断点续跑);响应里不单独回这件事,工单出现在工作台那一侧(工单接口不在本文范围)。
**响应 200**:`InstanceDetail`(§1.12,与 §6 同形)——新建的实例,初始 `state='pending'`(推进器领到即转 `running`,靠 SSE 失效重取)。**同一 `idempotencyKey` 重放也回 200,给的是第一次创建的那一单,不起第二个。**
## 6. L3 实例详情
**错误**
### `GET /api/gov/activities/:activityId/instances/:instanceId` —— access: `ops`
| 状态 | 何时 | `detail` |
|---|---|---|
| 400 | 请求体不合 schema | zod issues |
| 400 | 启动参数缺必填 / 标量格传了数组 / `select` 值不在候选 / 不认识的参数名 | `[{ key, message }]`,逐格点名 —— 前端把 `message` 摆到对应控件下 |
| 400 | 责任人不在名册 / 已停用 | `[{ key: 'ownerId', message }]` |
| 404 | 活动不存在 | — |
| 409 | 同一 `idempotencyKey` 的实例正在创建(60 秒内);或这枚键已用在别的活动上 | — |
| 412 | 活动已禁用(先激活再启动) | — |
成功后:关抽屉、失效 `['gov']`、按返回的 `id` 跳 L3(路由 id = `${activityId}:${id}`)。存在性检查(客户 / 料号在不在台账)在启动后由活动做,查不到不拦启动 —— 响应里不单独回这件事,工单出现在工作台那一侧。
## 6. L3 实例详情 —— `GET /api/gov/activities/:activityId/instances/:instanceId`(`getOpsInstanceRoute`)
**请求**:路径参数 `activityId`、`instanceId`(= 清单行的 `id`,任务 uuid,**不是** `no`)。
**响应 200**:`InstanceDetail`(§1.12)。**404**:实例不存在或不属于这个活动。
```json
{
"id": "INS-2417",
"activityId": "quote-intake",
"id": "0c9d3a1e-…",
"no": "INS-2417",
"activityId": "part-substitution-assessment",
"state": "running",
"owner": { "id": "8f3c…", "name": "柴浩" },
"llm": "K3 · quote-intake",
"llm": "deepseek-v4-flash",
"startedAt": "2026-08-25T02:12:00.000Z",
"endedAt": null,
"durationMs": null,
"reason": null,
"input": {
"content": "Itelma 车身控制器询价 · 3 项物料",
"content": "工业电源项目在用 LM2596S-5.0(TI,12V→5V),原厂 PCN 转 NRND…",
"source": { "kind": "manual", "label": "手动启动 · 柴浩" },
"submittedAt": "2026-08-25T02:12:00.000Z",
"files": [
{ "index": 0, "name": "RFQ-2026-081.eml", "contentType": "message/rfc822", "size": 48210 }
{ "index": 0, "name": "XL2596S-5.0.pdf", "contentType": "application/pdf", "size": 48210 }
]
},
"output": {
"files": [
{ "index": 0, "name": "quote-20260825-07.json", "contentType": "application/json", "size": 6102 }
{ "index": 0, "name": "差异评估报告.docx", "contentType": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "size": 6102 }
]
},
"steps": [
{ "seq": 1, "name": "输入接收", "by": "agent", "byName": "K3 · quote-intake", "at": "2026-08-25T02:12:00.000Z", "durationMs": 60000, "res": "done", "note": "询价单已进入,要素识别完成,实例正式起步。" },
{ "seq": 2, "name": "要素结构化", "by": "agent", "byName": "K3 · quote-intake", "at": "2026-08-25T02:15:00.000Z", "durationMs": 360000, "res": "done", "note": "抽取客户 / 物料 / 交期等字段,落入结构化模型。" },
{ "seq": 3, "name": "定价测算", "by": "agent", "byName": "K3 · quote-intake", "at": "2026-08-25T02:21:00.000Z", "durationMs": null, "res": "current", "note": "按成本模型逐项给出建议价与毛利测算。" },
{ "seq": 4, "name": "负责人验收", "by": "human", "byName": "柴浩", "at": null, "durationMs": null, "res": "pending", "note": "确认测算结果 —— 规划类产出必须由人验收担责。" },
{ "seq": 5, "name": "输出归档", "by": "agent", "byName": "K3 · quote-intake", "at": null, "durationMs": null, "res": "pending", "note": "生成报价文件并归档,产出回写台账。" }
{ "seq": 1, "name": "建立评估上下文:绑定四项、新建需求、存在性核对", "by": "agent", "byName": "deepseek-v4-flash", "at": "2026-08-25T02:12:00.000Z", "durationMs": 60000, "res": "done", "note": "铸案号、建需求 V1、锁规则版本、逐个料号查库、写存在性核对表" },
{ "seq": 2, "name": "数据工单:补建芯片主数据(P-08)", "by": "human", "byName": "柴浩", "at": "2026-08-25T02:13:00.000Z", "durationMs": null, "res": "current", "note": "" },
{ "seq": 3, "name": "评估输入与资料补齐确认", "by": "human", "byName": "柴浩", "at": null, "durationMs": null, "res": "pending", "note": "" }
]
}
```
- `endedAt` / `durationMs`:未结束(null)。`durationMs` 毫秒,前端格式化(`27m`)。
- **`reason`**:终态的原因说明,`null` = 不需要(进行中 / 待执行 / 正常完成)。
`{ "kind": "rejected", "text": "验收驳回:测算毛利低于阈值,退回重新测算 —— 负责人 柴浩。" }`
`{ "kind": "failed", "text": "要素结构化失败:询价单附件缺失,已通知发起人补齐后重试。" }`
`{ "kind": "terminated", "text": "已于 08-27 10:41 由 柴浩 手动终止。" }` —— **手动终止的实例状态归 `failed`,靠 `reason.kind='terminated'` 与「自己跑挂」区分**(过程面当前步记「已终止」,不摆失败原因)。
- `input.source.kind`:`manual`(运营端手动启动)/ `upstream`(上游活动触发)/ `mail`(邮件触发)…… 枚举随触发渠道增加;`label` 是人读形式,前端直接摆。
- `steps[].res`:`done` / `fail` / `current` / `pending`;`fail` 那行的性质由 `reason.kind` 定(驳回 / 失败 / 已终止)。
- `input.files` / `output.files`:只有元数据;**下载走 §7**。没有产出时 `output.files` 是空数组(前端摆「暂无产出」)。
三种终局的 `reason` 示例:
## 7. 实例文件下载
```json
{ "kind": "rejected", "text": "验收驳回:测算毛利低于阈值,退回重新测算 —— 负责人 柴浩" }
{ "kind": "failed", "text": "预算耗尽:步数 90/90、活跃时长 120/120 分钟" }
{ "kind": "terminated", "text": "由 柴浩 手动终止" }
```
### `GET /api/gov/activities/:activityId/instances/:instanceId/files/:io/:index` —— access: `ops`(支持 `?ticket=`)
- 头部:状态丸(`state`)+ 责任人 + 启动 / 结束 · 耗时(`startedAt` / `endedAt` / `durationMs`)+ 模型(`llm`)。
- 驳回 / 失败 / 已终止出原因块(`reason.text`);`reason === null` 不摆。
- 时间线按 `steps` 渲染;`res='fail'` 那一行的字眼按 `reason.kind` 选(驳回 / 失败 / 已终止)。
- 右栏输入(`input.content` / `input.source.label` / `input.submittedAt` / `input.files`)、输出(`output.files`;空数组摆「暂无产出」);文件行的下载走 §7。
`:io` ∈ `input` | `output`,`:index` 对应 §6 文件行的 `index`。
→ `{ "name": "…", "contentType": "…", "dataBase64": "…" }`(与 `GET /api/gov/upload/:id` 同形);可带 `?ticket=` 用 `<a href download>`。
**本期可以只回元数据、这条路先 404** —— 前端下载钮当前就是「随接口开放」的置灰态。
## 7. 实例文件下载 —— `GET /api/gov/activities/:activityId/instances/:instanceId/files/:io/:index`(`opsInstanceFileRoute`)
**请求**
| 位置 | 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
| path | `activityId` | string | 是 | |
| path | `instanceId` | string | 是 | 任务 id |
| path | `io` | `'input' \| 'output'` | 是 | 哪一侧;别的值 400 |
| path | `index` | number ≥ 0 | 是 | 对应 §6 文件行的 `index`;不是整数 → 400 |
| query | `raw` | `'1'` | 否 | 带 `raw=1` 直出文件字节(`content-type` = 文件 MIME,`content-disposition: attachment; filename*=UTF-8''…`);不带回 JSON |
| query | `ticket` | string | `<a href download>` 场景必填 | 点击那一刻取票据拼上(`shared` 里的 `downloadWithTicket(url)`) |
**响应 200**(不带 `raw`):
| 字段 | 类型 | 说明 |
|---|---|---|
| `name` | string | 文件名 |
| `contentType` | string | MIME |
| `dataBase64` | string | 文件字节的 base64(与 `GET /api/gov/upload/:id` 同形) |
**404**:实例不存在 / 该序号没有文件。
下载钮的写法:`downloadWithTicket(`/api/gov/activities/${activityId}/instances/${id}/files/${io}/${index}?raw=1`)`。
## 8. 禁用 / 激活 / 终止
### `POST /api/gov/activities/:activityId/disable` —— access: `ops`
### 8.1 禁用 —— `POST /api/gov/activities/:activityId/disable`(`disableOpsActivityRoute`)
→ 200 活动视图(§2 同形)。**任何时刻都可禁用,没有前置条件**(2026-08-27 用户重定):
禁用后这个活动不再允许启动(§5 返回 412);**已完成的实例是历史留痕,进行中的实例继续跑完,都不受影响**。
已禁用再禁用 = 幂等 200。
**请求**:路径参数 `activityId`;**body 空**。
**响应 200**:`ActivityDetail`(`status='disabled'`)。**404**:活动不存在。已禁用再禁用 = 幂等 200。
任何时刻都可禁用,没有前置条件:禁用后 §5 回 412;已完成的实例是历史留痕,进行中的继续跑完。
### `POST /api/gov/activities/:activityId/activate` —— access: `ops`
### 8.2 激活 —— `POST /api/gov/activities/:activityId/activate`(`activateOpsActivityRoute`)
→ 200 活动视图。已激活再激活 = 幂等 200。
**请求**:路径参数 `activityId`;**body 空**。
**响应 200**:`ActivityDetail`(`status='active'`)。**404**:活动不存在。已激活再激活 = 幂等 200。
### `POST /api/gov/activities/:activityId/instances/:instanceId/terminate` —— access: `ops`
### 8.3 终止实例 —— `POST /api/gov/activities/:activityId/instances/:instanceId/terminate`(`terminateOpsInstanceRoute`)
body 空(终止理由本期不填,`reason.text` 由服务端生成)。
→ 200 实例详情(§6 同形,`state='failed'` + `reason.kind='terminated'`)。412:实例不在 `running`(已终态的不能再终止)。
**请求**:路径参数 `activityId`、`instanceId`;**body 空**(终止理由本期不填,服务端生成「由 <操作人> 手动终止」)。
**响应 200**:`InstanceDetail`,`state='failed'`、`reason.kind='terminated'`、`endedAt` = 终止时刻。
**404**:实例不存在。**412**:实例已终态(已完成 / 已失败 / 已终止的不能再终止;`pending` 与 `running` 都可以)。
前端:二次确认(轻气泡)后调用;成功后由服务端重投影(广播 → 失效 → 重取),不要就地改 state。
## 9. Schema 附录(落地时进 `packages/contracts`,同名)
## 9. 类型附录(`packages/contracts/src/activity-ops.ts`,同名导出)
```ts
type ActivityStatus = 'active' | 'disabled';
@@ -298,40 +546,76 @@ type InstanceState = 'pending' | 'running' | 'completed' | 'rejected' | 'failed'
type StaffRef = { id: string; name: string };
type ActivitySummary = { id: string; name: string; biz: string; status: ActivityStatus; version: string;
owner: StaffRef; llm: string; instanceCount: number; createdAt: string; ico?: string; hue?: 'blue' | 'teal' | 'amber' | 'violet' };
owner: StaffRef | null; llm: string; instanceCount: number; createdAt: string; ico?: string; hue?: 'blue' | 'teal' | 'amber' | 'violet' };
type ActivityDetail = Omit<ActivitySummary, 'instanceCount'> & { desc: string; view: { url: string } | null };
type InstanceSummary = { id: string; state: InstanceState; owner: StaffRef; input: string; output: string | null; startedAt: string };
type InstanceSummary = { id: string; no: string; state: InstanceState; owner: StaffRef; input: string; output: string | null; startedAt: string };
type InstanceStats = { total: number; byState: Record<InstanceState, number> };
type InstanceList = { stats: InstanceStats; total: number; page: number; size: number; items: InstanceSummary[] };
type LaunchParamType = 'text' | 'textarea' | 'select' | 'combo' | 'list';
type LaunchParam = { key: string; label: string; type: LaunchParamType; required?: boolean; desc?: string;
placeholder?: string; options?: { value: string; label: string }[] /* select / combo 必填 */; default?: string | string[] };
placeholder?: string; options?: { value: string; label: string }[]; default?: string | string[] };
type LaunchParams = { activityId: string; version: string; params: LaunchParam[] };
type LaunchInput = { idempotencyKey: string; params: Record<string, string | string[]> };
type LaunchInput = { idempotencyKey: string; ownerId: string; params: Record<string, string | string[]> };
type InstanceFile = { index: number; name: string; contentType: string; size: number };
type InstanceReason = { kind: 'rejected' | 'failed' | 'terminated'; text: string };
type InstanceSource = { kind: string; label: string };
type InstanceStep = { seq: number; name: string; by: 'agent' | 'human'; byName: string;
at: string | null; durationMs: number | null; res: 'done' | 'fail' | 'current' | 'pending'; note: string };
type InstanceDetail = InstanceSummary & { activityId: string; llm: string; endedAt: string | null; durationMs: number | null;
type InstanceDetail = Omit<InstanceSummary, 'input' | 'output'> & { activityId: string; llm: string; endedAt: string | null; durationMs: number | null;
reason: InstanceReason | null;
input: { content: string; source: { kind: string; label: string }; submittedAt: string; files: InstanceFile[] };
input: { content: string; source: InstanceSource; submittedAt: string; files: InstanceFile[] };
output: { files: InstanceFile[] };
steps: InstanceStep[] };
```
路由常量(`packages/contracts/src/api/activity-ops.ts`):
| 常量 | 方法 · 路径 |
|---|---|
| `listOpsActivitiesRoute` | GET `/api/gov/activities` |
| `getOpsActivityRoute` | GET `/api/gov/activities/:activityId` |
| `opsActivityViewRoute` | GET `/api/gov/activities/:activityId/view` |
| `listOpsInstancesRoute` | GET `/api/gov/activities/:activityId/instances` |
| `opsLaunchParamsRoute` | GET `/api/gov/activities/:activityId/launch-params` |
| `launchOpsInstanceRoute` | POST `/api/gov/activities/:activityId/instances` |
| `getOpsInstanceRoute` | GET `/api/gov/activities/:activityId/instances/:instanceId` |
| `opsInstanceFileRoute` | GET `/api/gov/activities/:activityId/instances/:instanceId/files/:io/:index` |
| `disableOpsActivityRoute` | POST `/api/gov/activities/:activityId/disable` |
| `activateOpsActivityRoute` | POST `/api/gov/activities/:activityId/activate` |
| `terminateOpsInstanceRoute` | POST `/api/gov/activities/:activityId/instances/:instanceId/terminate` |
其它导出:`INSTANCE_STATES` / `INSTANCE_STATE_LABEL`(五态中文)、`ACTIVITY_STATUSES` / `ACTIVITY_STATUS_LABEL`、`ACTIVITY_HUES`、`LAUNCH_PARAM_TYPES`、`ACTIVITY_VIEW_HEIGHT_MESSAGE`、`ACTIVITY_OPS_ROUTES`。
调用示例(前端唯一通道 `shared/typed-fetch`):
```ts
import { call } from '@web-shared/typed-fetch.ts';
import { launchOpsInstanceRoute, listOpsInstancesRoute } from '@contracts';
import type { InstanceDetail, InstanceList } from '@contracts';
const list = await call<InstanceList>(listOpsInstancesRoute, { params: { activityId }, query: { state: 'running', page: '2', size: '8' } });
const created = await call<InstanceDetail>(launchOpsInstanceRoute, { params: { activityId }, body: { idempotencyKey, ownerId, params: values } });
// 失败抛 ApiCallError { status, code, message, detail }
```
## 10. 前端对接要点(现状 → 接口落地)
接口已落地,前端仍是占位数据(对接另起一轮,2026-08-27 用户定)。届时照这张表换:
| 现状(占位) | 接口落地后 |
|---|---|
| `DEMO_ACTIVITIES` 静态数组 | §1;L1 页签切换即换 `status` 重取 |
| `demoInstances()` 每活动共用一份 | §3;检索四条件与分页全部上移到查询参数,前端删掉本地过滤/分页 |
| `DEMO_ACTIVITIES` 静态数组 | §2.1;L1 页签切换即换 `status` 重取;`owner` 为 null 摆「—」,`ico`/`hue` 缺省兜底 |
| `demoInstances()` 每活动共用一份 | §3;检索四条件与分页全部上移到查询参数,前端删掉本地过滤/分页;表格摆 `no`,寻址用 `id` |
| 统计带由前端从全量数组数 | §3 `stats`(服务端给,分页下才数得准) |
| `LAUNCH_PARAMS` 占位参数表 | §4;`options` 从纯字符串升级为 `{value,label}` 两段(渲染器小改);控件种类已含 `combo`(选择+新增,前端已实现) |
| `onLaunch` 在清单头落假行 | §5;抽屉每开一次生成 `idempotencyKey`,提交走 mutation,成功失效 `['gov']` |
| 禁用/激活/终止就地改 state | §8;mutation 成功后由服务端重投影(广播 → 失效 → 重取) |
| 中文状态字面量当键 | 五态英文码 + `INSTANCE_STATE_LABEL`,前端一张 `Record<InstanceState, {tone, ico}>` 映射 |
| `LAUNCH_PARAMS` 占位参数表 | §4;`options` 从纯字符串升级为 `{value,label}` 两段(渲染器小改);控件种类已含 `combo` |
| `onLaunch` 在清单头落假行 | §5;抽屉每开一次生成 `idempotencyKey`(`crypto.randomUUID()`),提交走 mutation,400 的 `detail[].key` 对回控件,成功失效 `['gov']` 并跳 L3 |
| 责任人固定字段 | §5 `ownerId`;抽屉加「固定参数」段(名册下拉取 `GET /api/gov/staff` 在职的人,默认当前登录人 `me.staff.id`) |
| 禁用/激活/终止就地改 state | §8;mutation 成功后由服务端重投影(广播 → 失效 → 重取);L2 行内「终止活动」也要二次确认 |
| `demoSteps` / `terminatedSteps` 五步占位 | §6 `steps`;终止归态以 `reason.kind='terminated'` 为准,前端不再落「失败」占位 |
| 沙箱 `src="/sandbox-demo/activity-view.html"` 固定 480 高 | §2 `view.url` + 票据;`view` 为 null 藏起沙箱区;高度 postMessage 随第一版视图定 |
| 输出下载钮置灰 | §7(本期可继续 404,钮保持置灰) |
| 沙箱 `src="/sandbox-demo/activity-view.html"` 固定 480 高 | §2.3 `view.url` + 票据(装载那一刻取);`view` 为 null 藏起沙箱区;高度听 `jcai:view-height` |
| 输出下载钮置灰 | §7 已落地:`downloadWithTicket(url + '?raw=1')`(从 `desk-task/internal/FileViewer.tsx` 抽到 shared) |
| 设计中心活动库 / 测试台的 `useGovActivities` | 路径改名对它们透明(import 的是路由对象),无需改动 |
+13 -2
View File
@@ -245,8 +245,8 @@ body `{ "ends": ["design"] }`(端名数组,顺序 / 重复不敏感)→ `{ "role
| `design` | PUT | `/api/gov/approval-templates` |
| `design` | DELETE | `/api/gov/approval-templates/:id` |
| `design` | PUT | `/api/gov/triggers`(定义归设计) |
| `design` | GET | `/api/gov/activities` |
| `design` | POST | `/api/gov/activities` |
| `design` | GET | `/api/gov/activity-packages`(活动库:包版本 / 资产版本;2026-08-27 从 `/api/gov/activities` 改名让位) |
| `design` | POST | `/api/gov/activity-packages`(装入活动包;同上改名) |
| `design` | GET | `/api/gov/tools` |
| `design` | POST | `/api/gov/tools` |
| `design` | GET | `/api/gov/memory/:activityId` |
@@ -302,6 +302,17 @@ body `{ "ends": ["design"] }`(端名数组,顺序 / 重复不敏感)→ `{ "role
| `ops` | GET | `/api/gov/mail/inbound/:mailId/attachments/:index`(可用票据) |
| `ops` | GET | `/api/gov/mail/inbound/:mailId/raw`(可用票据) |
| `ops` | GET | `/api/gov/mail/outbound` |
| `ops` | GET | `/api/gov/activities`(运营中心 · 活动模块 L1;见《API文档-活动模块》) |
| `ops` | GET | `/api/gov/activities/:activityId` |
| `ops` | GET | `/api/gov/activities/:activityId/view`(text/html,可用票据) |
| `ops` | GET | `/api/gov/activities/:activityId/instances` |
| `ops` | GET | `/api/gov/activities/:activityId/launch-params` |
| `ops` | POST | `/api/gov/activities/:activityId/instances`(启动 = 创建实例,幂等键) |
| `ops` | GET | `/api/gov/activities/:activityId/instances/:instanceId` |
| `ops` | GET | `/api/gov/activities/:activityId/instances/:instanceId/files/:io/:index`(可用票据;`?raw=1` 直出字节) |
| `ops` | POST | `/api/gov/activities/:activityId/disable` |
| `ops` | POST | `/api/gov/activities/:activityId/activate` |
| `ops` | POST | `/api/gov/activities/:activityId/instances/:instanceId/terminate` |
| `design\|ops` | GET | `/api/gov/staff`(测试台选负责人) |
| `design\|ops` | GET | `/api/gov/process` |
| `design\|ops` | GET | `/api/gov/triggers` |
@@ -0,0 +1,115 @@
/**
* 运营中心 · 活动模块的契约:形状照 `docs/API文档-活动模块.md` §9,十一条路由全归 ops。
*/
import { describe, expect, it } from 'vitest';
import {
ACTIVITY_OPS_ROUTES,
INSTANCE_STATES,
INSTANCE_STATE_LABEL,
activityDetailSchema,
activitySummarySchema,
instanceDetailSchema,
instanceListSchema,
instanceStatsSchema,
launchInputSchema,
launchParamsSchema,
listOpsInstancesRoute,
opsInstanceFileRoute,
} from '../index.ts';
const owner = { id: 'staff-1', name: '柴浩' };
const summary = {
id: 'part-substitution-assessment',
name: 'ACT-WORK-05 · 差异评估',
biz: '需求分析与方案匹配',
status: 'active',
version: '1.5.0',
owner,
llm: 'deepseek-v4-flash',
instanceCount: 3,
createdAt: '2026-08-27T01:00:00.000Z',
};
const instance = {
id: 'task-1',
no: 'INS-17',
state: 'running',
owner,
input: '工业电源项目在用 LM2596S-5.0',
output: null,
startedAt: '2026-08-27T02:00:00.000Z',
};
describe('活动 schema', () => {
it('负责人可空、图标色相可缺;详情多说明与视图', () => {
expect(activitySummarySchema.parse({ ...summary, owner: null }).owner).toBeNull();
expect(activitySummarySchema.safeParse({ ...summary, hue: 'pink' }).success).toBe(false);
const detail = activityDetailSchema.parse({ ...summary, instanceCount: undefined, desc: '', view: null });
expect(detail.view).toBeNull();
expect(activityDetailSchema.safeParse({ ...summary, desc: '' }).success).toBe(false);
});
});
describe('实例 schema', () => {
it('五态统计的键必须齐;清单带页码', () => {
const byState = Object.fromEntries(INSTANCE_STATES.map((s) => [s, 0]));
expect(instanceStatsSchema.parse({ total: 0, byState }).byState.rejected).toBe(0);
expect(instanceStatsSchema.safeParse({ total: 0, byState: { pending: 0 } }).success).toBe(false);
expect(instanceListSchema.parse({ stats: { total: 1, byState: { ...byState, running: 1 } }, total: 1, page: 1, size: 8, items: [instance] }).items).toHaveLength(1);
expect(Object.keys(INSTANCE_STATE_LABEL).sort()).toEqual([...INSTANCE_STATES].sort());
});
it('详情:input 是结构体(摘要字段被 omit 掉),reason 三种 kind', () => {
const detail = instanceDetailSchema.parse({
...instance,
input: { content: '…', source: { kind: 'manual', label: '手动启动 · 柴浩' }, submittedAt: instance.startedAt, files: [] },
activityId: 'part-substitution-assessment',
llm: 'deepseek-v4-flash',
endedAt: null,
durationMs: null,
reason: { kind: 'terminated', text: '由 柴浩 手动终止' },
output: { files: [] },
steps: [{ seq: 1, name: '建立评估上下文', by: 'agent', byName: 'deepseek-v4-flash', at: null, durationMs: null, res: 'pending', note: '' }],
});
expect(detail.input.source.kind).toBe('manual');
expect(instanceDetailSchema.safeParse({ ...detail, reason: { kind: 'oops', text: '' } }).success).toBe(false);
});
});
describe('启动 schema', () => {
it('参数表控件五种;入参 list 回数组、其余回字符串;责任人必填', () => {
const table = launchParamsSchema.parse({
activityId: 'a',
version: '1.0.0',
params: [
{ key: 'customerCode', label: '客户', type: 'combo', required: true, options: [{ value: 'CU-1', label: 'Itelma' }] },
{ key: 'pns', label: '候选料号', type: 'list', default: ['PN-1'] },
],
});
expect(table.params[1]?.default).toEqual(['PN-1']);
expect(launchParamsSchema.safeParse({ activityId: 'a', version: '1', params: [{ key: 'x', label: 'x', type: 'file' }] }).success).toBe(false);
expect(launchInputSchema.parse({ idempotencyKey: 'k', ownerId: 's', params: { a: 'x', b: ['y'] } }).params).toEqual({ a: 'x', b: ['y'] });
expect(launchInputSchema.safeParse({ idempotencyKey: 'k', params: {} }).success).toBe(false);
expect(launchInputSchema.safeParse({ idempotencyKey: 'k', ownerId: 's', params: { n: 1 } }).success).toBe(false);
});
});
describe('路由契约', () => {
it('十一条全归运营中心', () => {
expect(ACTIVITY_OPS_ROUTES).toHaveLength(11);
for (const route of ACTIVITY_OPS_ROUTES) expect(route.access, route.path).toBe('ops');
expect(new Set(ACTIVITY_OPS_ROUTES.map((r) => `${r.method} ${r.path}`)).size).toBe(11);
});
it('实例清单查询:页码默认 1、单页默认 8 上限 50、日期要 YYYY-MM-DD', () => {
expect(listOpsInstancesRoute.query.parse({})).toMatchObject({ page: 1, size: 8 });
expect(listOpsInstancesRoute.query.parse({ page: '3', size: '20', startedFrom: '2026-08-01' })).toMatchObject({ page: 3, size: 20 });
expect(listOpsInstancesRoute.query.safeParse({ size: '51' }).success).toBe(false);
expect(listOpsInstancesRoute.query.safeParse({ startedTo: '08-27' }).success).toBe(false);
expect(listOpsInstancesRoute.query.safeParse({ state: 'done' }).success).toBe(false);
});
it('文件路由:io 只认 input / output,index 从字符串转数', () => {
expect(opsInstanceFileRoute.params.parse({ activityId: 'a', instanceId: 't', io: 'output', index: '2' }).index).toBe(2);
expect(opsInstanceFileRoute.params.safeParse({ activityId: 'a', instanceId: 't', io: 'both', index: '0' }).success).toBe(false);
});
});
+209
View File
@@ -0,0 +1,209 @@
/**
* 运营中心 · 活动模块的契约(L1 活动清单 / L2 活动详情 / L3 实例详情;`docs/API文档-活动模块.md`)。
*
* 只有 zod schema 与推导类型。两个概念(2026-08-25 用户定):
* · **活动** = 装进来的活动包在运营面上的投影 —— 名称 / 分类 / 版本 / 模型来自清单,
* 负责人 / 说明 / 图标 / 色相来自运营侧档案(`sys.activity_profile`,活动包本身不载这些);
* · **实例** = 这个活动的一次运行(一张 task)—— 五态是对 task 运行态 / 终态的**如实映射**,
* 不是第六套状态机(见 `INSTANCE_STATE_LABEL` 旁的口径)。
*/
import { z } from 'zod';
import { staffIdSchema } from './id.ts';
/* ── 活动 ───────────────────────────────────────────────────────────── */
/** 运营态:激活中 / 已禁用 —— 禁用只拦「新启动」,已完成与在途实例都不受影响(2026-08-27 用户定) */
export const ACTIVITY_STATUSES = ['active', 'disabled'] as const;
export const activityStatusSchema = z.enum(ACTIVITY_STATUSES);
export type ActivityStatus = z.infer<typeof activityStatusSchema>;
export const ACTIVITY_STATUS_LABEL: Readonly<Record<ActivityStatus, string>> = {
active: '激活中',
disabled: '已禁用',
};
/** 业务色相 —— 一卡一色;缺省由前端兜底(`blue`) */
export const ACTIVITY_HUES = ['blue', 'teal', 'amber', 'violet'] as const;
export const activityHueSchema = z.enum(ACTIVITY_HUES);
export type ActivityHue = z.infer<typeof activityHueSchema>;
/** 凡出人都是两段:展示用 name,筛选 / 提交用 id */
export const staffRefSchema = z.object({ id: staffIdSchema, name: z.string() });
export type StaffRef = z.infer<typeof staffRefSchema>;
export const activitySummarySchema = z.object({
id: z.string().min(1),
name: z.string(),
/** 业务分类 —— 清单 `identity.category` */
biz: z.string(),
status: activityStatusSchema,
/** 当前生效的包版本(最新装入的那一版) */
version: z.string(),
/** 活动负责人 —— 来自运营侧档案;没建档就是 null,前端摆「—」,**不编一个** */
owner: staffRefSchema.nullable(),
/** 绑定的模型(清单声明的逻辑模型名,如 `deepseek-v4-flash`) */
llm: z.string(),
instanceCount: z.number().int().min(0),
/** 首个版本装入的时刻 */
createdAt: z.string(),
ico: z.string().optional(),
hue: activityHueSchema.optional(),
});
export type ActivitySummary = z.infer<typeof activitySummarySchema>;
export const activityDetailSchema = activitySummarySchema.omit({ instanceCount: true }).extend({
/** 定义说明 —— 运营侧档案;没写就是空串 */
desc: z.string(),
/** 沙箱要渲染的活动视图;null = 这个活动没配视图,前端藏起整个沙箱区块 */
view: z.object({ url: z.string() }).nullable(),
});
export type ActivityDetail = z.infer<typeof activityDetailSchema>;
/**
* 沙箱视图向父页报高的消息类型(postMessage 载荷 `{ type, height }`)。
* 视图模版本身**无脚本**(安装静态审拦着);报高脚本由服务端在出口那一刻注入。
*/
export 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 const INSTANCE_STATES = ['pending', 'running', 'completed', 'rejected', 'failed'] as const;
export const instanceStateSchema = z.enum(INSTANCE_STATES);
export type InstanceState = z.infer<typeof instanceStateSchema>;
export const INSTANCE_STATE_LABEL: Readonly<Record<InstanceState, string>> = {
pending: '待执行',
running: '进行中',
completed: '已完成',
rejected: '驳回',
failed: '失败',
};
export const instanceSummarySchema = z.object({
/** 寻址键 = 任务 id */
id: z.string().min(1),
/** 人读实例号 `INS-<序号>` —— 清单上摆它、模糊搜也认它 */
no: z.string(),
state: instanceStateSchema,
owner: staffRefSchema,
/** 输入一句话摘要 */
input: z.string(),
/** 产出一句话摘要;null 前端摆 `-` */
output: z.string().nullable(),
startedAt: z.string(),
});
export type InstanceSummary = z.infer<typeof instanceSummarySchema>;
export const instanceStatsSchema = z.object({
total: z.number().int().min(0),
byState: z.record(instanceStateSchema, z.number().int().min(0)),
});
export type InstanceStats = z.infer<typeof instanceStatsSchema>;
export const instanceListSchema = z.object({
/** 活动整体的五态统计,不随检索条件动 */
stats: instanceStatsSchema,
/** 筛后条数,分页器按它走 */
total: z.number().int().min(0),
page: z.number().int().min(1),
size: z.number().int().min(1),
items: z.array(instanceSummarySchema),
});
export type InstanceList = z.infer<typeof instanceListSchema>;
export const instanceFileSchema = z.object({
index: z.number().int().min(0),
name: z.string(),
contentType: z.string(),
size: z.number().int().min(0),
});
export type InstanceFile = z.infer<typeof instanceFileSchema>;
export const INSTANCE_REASON_KINDS = ['rejected', 'failed', 'terminated'] as const;
export const instanceReasonSchema = z.object({ kind: z.enum(INSTANCE_REASON_KINDS), text: z.string() });
export type InstanceReason = z.infer<typeof instanceReasonSchema>;
export const STEP_RESULTS = ['done', 'fail', 'current', 'pending'] as const;
export const instanceStepSchema = z.object({
seq: z.number().int().min(1),
name: z.string(),
by: z.enum(['agent', 'human']),
byName: z.string(),
at: z.string().nullable(),
durationMs: z.number().int().min(0).nullable(),
res: z.enum(STEP_RESULTS),
note: z.string(),
});
export type InstanceStep = z.infer<typeof instanceStepSchema>;
/** 起单来源:manual(运营端手动启动)/ upstream(工作流节点)/ mail 等通道名 / testbed(测试台试跑)/ system */
export const instanceSourceSchema = z.object({ kind: z.string(), label: z.string() });
export type InstanceSource = z.infer<typeof instanceSourceSchema>;
export const instanceDetailSchema = instanceSummarySchema.omit({ input: true }).extend({
activityId: z.string(),
llm: z.string(),
endedAt: z.string().nullable(),
durationMs: z.number().int().min(0).nullable(),
/** 终态 / 驳回的原因;null = 不需要(进行中 / 待执行 / 正常完成) */
reason: instanceReasonSchema.nullable(),
input: z.object({
content: z.string(),
source: instanceSourceSchema,
submittedAt: z.string(),
files: z.array(instanceFileSchema),
}),
output: z.object({ files: z.array(instanceFileSchema) }),
steps: z.array(instanceStepSchema),
});
export type InstanceDetail = z.infer<typeof instanceDetailSchema>;
/* ── 启动 ───────────────────────────────────────────────────────────── */
/**
* 控件种类:text 单行 / textarea 多行 / select 单选(只能选候选)/ combo 选择 + 新增 / list 字符串数组。
* 新增种类前后端一起升级;前端遇到不认识的 type 摆「暂不支持的控件」。
*/
export const LAUNCH_PARAM_TYPES = ['text', 'textarea', 'select', 'combo', 'list'] as const;
export const launchParamTypeSchema = z.enum(LAUNCH_PARAM_TYPES);
export type LaunchParamType = z.infer<typeof launchParamTypeSchema>;
export const launchOptionSchema = z.object({ value: z.string(), label: z.string() });
export type LaunchOption = z.infer<typeof launchOptionSchema>;
export const launchParamSchema = z.object({
/** 提交时回传的字段名(活动契约 `input.data.properties` 的键) */
key: z.string().min(1),
label: z.string(),
type: launchParamTypeSchema,
required: z.boolean().optional(),
desc: z.string().optional(),
placeholder: z.string().optional(),
/** select / combo 的候选;combo 提交候选之外的值也合法 */
options: z.array(launchOptionSchema).optional(),
default: z.union([z.string(), z.array(z.string())]).optional(),
});
export type LaunchParam = z.infer<typeof launchParamSchema>;
export const launchParamsSchema = z.object({
activityId: z.string(),
version: z.string(),
params: z.array(launchParamSchema),
});
export type LaunchParams = z.infer<typeof launchParamsSchema>;
export const launchInputSchema = z.object({
/** 前端每开一次抽屉生成一枚;同键重放返回第一次创建的实例 */
idempotencyKey: z.string().min(1).max(128),
/** 责任人 —— 固定字段,不进参数表;须是名册里在职的人 */
ownerId: staffIdSchema,
/** 键值形状由参数表决定:list 回字符串数组,其余回字符串 */
params: z.record(z.string(), z.union([z.string(), z.array(z.string())])),
});
export type LaunchInput = z.infer<typeof launchInputSchema>;
+166
View File
@@ -0,0 +1,166 @@
/**
* 运营中心 · 活动模块的路由契约(`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';
import {
activityDetailSchema,
activityStatusSchema,
activitySummarySchema,
instanceDetailSchema,
instanceListSchema,
instanceStateSchema,
launchInputSchema,
launchParamsSchema,
} from '../activity-ops.ts';
const activityParams = z.object({ activityId: z.string().min(1) });
const instanceParams = activityParams.extend({ instanceId: z.string().min(1) });
/** 启动日期段,`YYYY-MM-DD`,含两端整日 */
const day = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, '日期要写成 YYYY-MM-DD');
/* ── L1 ── */
export const listOpsActivitiesRoute = {
method: 'GET',
path: '/api/gov/activities',
access: 'ops',
/** 不带 status = 全量;量小不分页 */
query: z.object({ status: activityStatusSchema.optional() }),
output: z.object({ activities: z.array(activitySummarySchema) }),
} as const satisfies RouteContract;
/* ── L2 ── */
export const getOpsActivityRoute = {
method: 'GET',
path: '/api/gov/activities/:activityId',
access: 'ops',
params: activityParams,
output: activityDetailSchema,
} as const satisfies RouteContract;
/**
* 沙箱要渲染的活动视图 —— 处理器回 `text/html`(不是 JSON),`output` 只是形式上的字符串。
* iframe 带不了 Authorization 头,走票据 `?ticket=`(GET,中间件已认)。404 = 该活动没配视图。
*/
export const opsActivityViewRoute = {
method: 'GET',
path: '/api/gov/activities/:activityId/view',
access: 'ops',
params: activityParams,
output: z.string(),
} as const satisfies RouteContract;
export const listOpsInstancesRoute = {
method: 'GET',
path: '/api/gov/activities/:activityId/instances',
access: 'ops',
params: activityParams,
query: z.object({
/** 实例号模糊搜(对 `no` 与 `id` 的子串) */
id: z.string().optional(),
ownerId: z.string().optional(),
state: instanceStateSchema.optional(),
startedFrom: day.optional(),
startedTo: day.optional(),
page: z.coerce.number().int().min(1).default(1),
/** 默认 8(与前端单页一致),上限 50 */
size: z.coerce.number().int().min(1).max(50).default(8),
}),
output: instanceListSchema,
} as const satisfies RouteContract;
/* ── 启动 ── */
export const opsLaunchParamsRoute = {
method: 'GET',
path: '/api/gov/activities/:activityId/launch-params',
access: 'ops',
params: activityParams,
output: launchParamsSchema,
} as const satisfies RouteContract;
/**
* 启动活动 = 创建实例。同一 `idempotencyKey` 重放返回第一次创建的实例;
* 412 活动已禁用;400 缺必填 / 类型不符 / select 值不在候选 / 责任人不在职;409 同键正在创建中。
*/
export const launchOpsInstanceRoute = {
method: 'POST',
path: '/api/gov/activities/:activityId/instances',
access: 'ops',
params: activityParams,
body: launchInputSchema,
output: instanceDetailSchema,
} as const satisfies RouteContract;
/* ── L3 ── */
export const getOpsInstanceRoute = {
method: 'GET',
path: '/api/gov/activities/:activityId/instances/:instanceId',
access: 'ops',
params: instanceParams,
output: instanceDetailSchema,
} as const satisfies RouteContract;
/**
* 实例文件:默认 JSON + base64(与 `GET /api/gov/upload/:id` 同形);`?raw=1` 直出字节
* (RFC 5987 文件名),配 `?ticket=` 给 `<a href download>` 用。
*/
export const opsInstanceFileRoute = {
method: 'GET',
path: '/api/gov/activities/:activityId/instances/:instanceId/files/:io/:index',
access: 'ops',
params: instanceParams.extend({ io: z.enum(['input', 'output']), index: z.coerce.number().int().min(0) }),
query: z.object({ raw: z.string().optional() }),
output: z.object({ name: z.string(), contentType: z.string(), dataBase64: z.string() }),
} as const satisfies RouteContract;
/* ── 禁用 / 激活 / 终止 ── */
/** 任何时刻都可禁用:只拦新启动,在途实例继续跑完;已禁用再禁用 = 幂等 200 */
export const disableOpsActivityRoute = {
method: 'POST',
path: '/api/gov/activities/:activityId/disable',
access: 'ops',
params: activityParams,
output: activityDetailSchema,
} as const satisfies RouteContract;
export const activateOpsActivityRoute = {
method: 'POST',
path: '/api/gov/activities/:activityId/activate',
access: 'ops',
params: activityParams,
output: activityDetailSchema,
} as const satisfies RouteContract;
/** body 空;→ `state='failed'` + `reason.kind='terminated'`;412 实例已终态 */
export const terminateOpsInstanceRoute = {
method: 'POST',
path: '/api/gov/activities/:activityId/instances/:instanceId/terminate',
access: 'ops',
params: instanceParams,
output: instanceDetailSchema,
} as const satisfies RouteContract;
/** 全部 11 条 —— 文档总表与覆盖测试按它数 */
export const ACTIVITY_OPS_ROUTES: readonly RouteContract[] = [
listOpsActivitiesRoute,
getOpsActivityRoute,
opsActivityViewRoute,
listOpsInstancesRoute,
opsLaunchParamsRoute,
launchOpsInstanceRoute,
getOpsInstanceRoute,
opsInstanceFileRoute,
disableOpsActivityRoute,
activateOpsActivityRoute,
terminateOpsInstanceRoute,
];
+5 -2
View File
@@ -537,10 +537,12 @@ export const saveWorkGroupRoute = {
* 安装两审在服务端跑:静态审读清单(工具契约在否、模型名绑没绑、模版有没有脚本、
* 声明与已物化的台账冲不冲),回放审重演历史(首装无历史即免,以样例集替代)。
* **恒为新增、版本并存**:装入不覆盖旧版本,在途任务按其启动时版本跑完。
* **路径(2026-08-27)**:`/api/gov/activity-packages` —— 从前叫 `/api/gov/activities`;那个前缀整个让给了运营端
* 活动模块(L1/L2/L3,`api/activity-ops.ts`),这里管的是**包**(版本 / 资产 / 装入),名副其实。
*/
export const installActivityRoute = {
method: 'POST',
path: '/api/gov/activities',
path: '/api/gov/activity-packages',
access: 'design',
body: z.object({
/** 清单六区:身份 / 七件契约 / 声明 / 资产初始版 / 样例集 / 代码入口 */
@@ -606,9 +608,10 @@ export const registerToolRoute = {
/* ── 活动库管理:两维分类 × 包版本 × 资产版本 ─────────────────────────── */
/** 路径同 `installActivityRoute` 的说明:`/api/gov/activity-packages`(2026-08-27 让位给运营端活动模块) */
export const listActivitiesRoute = {
method: 'GET',
path: '/api/gov/activities',
path: '/api/gov/activity-packages',
access: 'design',
output: z.object({
activities: z.array(
+1
View File
@@ -4,3 +4,4 @@ export * from './governance.ts';
export * from './org-directory.ts';
export * from './ingest.ts';
export * from './mail.ts';
export * from './activity-ops.ts';
+1
View File
@@ -15,4 +15,5 @@ export * from './process-package.ts';
export * from './manifest.ts';
export * from './org.ts';
export * from './mail.ts';
export * from './activity-ops.ts';
export * from './api/index.ts';