Archived
feat(ops): 运营中心 · 活动模块接口落地 —— L1/L2/L3 十一条(全 ops)+ 设计端活动库两条改名让位
`apps/api/src/app/activity-ops/*`(service / store / instance-state / instance-steps / launch-params / summaries / view-html / view-scope)+ `routes/activities.ts`;契约 `activity-ops.ts` / `api/activity-ops.ts` (与队友按文档补的那版 43 个导出同名,多 INSTANCE_REASON_KINDS / STEP_RESULTS / launchOptionSchema); 迁移 0035_activity_ops;《API文档-活动模块》按契约回写。 设计端活动库 GET/POST `/api/gov/activities` 改名 `/api/gov/activity-packages`,`/api/gov/activities/*` 整个让给运营端(前端 import 路由对象,零改动);activity-registry CONTRACT.md 与路由总表同步。 2026-08-27 另一会话做的,一直留在工作树没入库;与同批另两条提交互相依赖,以合并提交为验证单位。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0167qWjeyBwvfdGovd7FWbCu
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
export {};
|
||||||
+353
@@ -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>;
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,560 @@
|
|||||||
|
/**
|
||||||
|
* 运营中心 · 活动模块的用例编排(app 层;`docs/API文档-活动模块.md`)。
|
||||||
|
*
|
||||||
|
* 读是跨模块的投影:活动 = 注册表卡片 + 清单 + 运营侧档案 + 实例计数;
|
||||||
|
* 实例 = `sys.task` 一行 + 任务记忆里的裁决与引擎痕 + loop 运行态 + 输入 / 产出文件。
|
||||||
|
* 写一律走既有 port:`task.start`(`origin:'ops'`)、`task.control('terminate')`、`registry.setEnabled`。
|
||||||
|
*
|
||||||
|
* 启动的幂等:`sys.activity_launch` 按键占位(一句 SQL 判 + 占),同键重放回第一次那一单;
|
||||||
|
* 键占了、单还没建成的那 60 秒里再来 = 409(不是第二单,也不是无限等)。
|
||||||
|
*/
|
||||||
|
import type {
|
||||||
|
LedgerQuery,
|
||||||
|
ViewData,
|
||||||
|
ActivityDetail,
|
||||||
|
ActivityHue,
|
||||||
|
ActivityStatus,
|
||||||
|
ActivitySummary,
|
||||||
|
InstanceDetail,
|
||||||
|
InstanceFile,
|
||||||
|
InstanceList,
|
||||||
|
InstanceSource,
|
||||||
|
InstanceState,
|
||||||
|
InstanceSummary,
|
||||||
|
LaunchInput,
|
||||||
|
LaunchOption,
|
||||||
|
LaunchParams,
|
||||||
|
StaffId,
|
||||||
|
StaffRef,
|
||||||
|
TaskId,
|
||||||
|
TerminalState,
|
||||||
|
} from '@contracts';
|
||||||
|
import { ACTIVITY_HUES, TERMINAL_STATES, staffIdSchema, taskIdSchema } from '@contracts';
|
||||||
|
import type { ActivityCard } from '@modules/activity-registry';
|
||||||
|
import type { ItemRuntime, PlanItem } from '@modules/loop-engine';
|
||||||
|
import { StaffNotFoundError } from '@modules/org';
|
||||||
|
import { renderView } from '@modules/interaction';
|
||||||
|
import type { StartTaskInput } from '@modules/task';
|
||||||
|
import { MIME_OF } from '../file-compose.ts';
|
||||||
|
import type { FileFormat } from '../file-compose.ts';
|
||||||
|
import { fieldSpecsOf, launchParamsOf, validateLaunchParams } from './launch-params.ts';
|
||||||
|
import type { FieldSpec, LaunchIssue, OptionsSource } from './launch-params.ts';
|
||||||
|
import { instanceStateOf, verdictFactOf } from './instance-state.ts';
|
||||||
|
import type { StateVerdict } from './instance-state.ts';
|
||||||
|
import { stepsOf } from './instance-steps.ts';
|
||||||
|
import { applyInstanceFilters, inputFilesOf, inputSummaryOf, outputSummaryOf, pageOf, statsOf } from './summaries.ts';
|
||||||
|
import type { InstanceQuery } from './summaries.ts';
|
||||||
|
import { LEDGER_TEMPLATE, injectHeightScript } from './view-html.ts';
|
||||||
|
import { resolveViewData, viewScopeOf } from './view-scope.ts';
|
||||||
|
import type { ViewRead } from './view-scope.ts';
|
||||||
|
import { caseStamp } from '../case-stamp.ts';
|
||||||
|
import type { ActivityOpsStore, TaskRow } from './store.ts';
|
||||||
|
|
||||||
|
/** 键占了、单还没建成:这么久之后允许重占(进程崩在 claim 与 start 之间不该把这枚键锁死) */
|
||||||
|
export const LAUNCH_CLAIM_STALE_MS = 60_000;
|
||||||
|
/** combo 候选内联上限(文档 §4:本期一律内联;更大的表以后走检索接口) */
|
||||||
|
export const OPTIONS_CAP = 500;
|
||||||
|
|
||||||
|
/** 输入 / 产出文件的存取 —— app 里现成的两段(`upload-store.ts` / `asset-archive.ts`),按接口注入好替身 */
|
||||||
|
export type InstanceFileStore = {
|
||||||
|
readUpload(ref: string): Promise<{ name: string; contentType: string; bytes: Uint8Array } | undefined>;
|
||||||
|
listArchived(taskId: TaskId): Promise<ReadonlyArray<{ address: string; key: Record<string, string>; format: FileFormat; name: string; size: number }>>;
|
||||||
|
readArchived(taskId: TaskId, address: string, key: Readonly<Record<string, string>>): Promise<{ bytes: Uint8Array; name: string; format: FileFormat } | undefined>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 依赖只声明**用到的那几面**(不是整个 `ActivityRegistryService` / `TaskService` …):
|
||||||
|
* 真服务结构上都装得进来(server.ts 装配处由 tsc 核),测试只需拼几个字面量,不用造整本清单。
|
||||||
|
*/
|
||||||
|
/** 清单里运营面要看的几处(台账声明只为沙箱看板铺 tally 读数) */
|
||||||
|
export type ManifestFace = {
|
||||||
|
readonly identity: { readonly name: string };
|
||||||
|
readonly contract: { readonly input: { readonly data: Readonly<Record<string, unknown>> } };
|
||||||
|
readonly declarations: {
|
||||||
|
readonly models: readonly string[];
|
||||||
|
readonly ledger: ReadonlyArray<{ readonly domain: string; readonly object: string; readonly fields: ReadonlyArray<{ readonly name: string; readonly tally?: readonly string[] | undefined }> }>;
|
||||||
|
};
|
||||||
|
readonly assets: { readonly viewTemplates: ReadonlyArray<{ readonly name: string; readonly html: string; readonly data?: ViewData | undefined }> };
|
||||||
|
};
|
||||||
|
export type RegistryFace = {
|
||||||
|
list(): Promise<ActivityCard[]>;
|
||||||
|
get(activityId: string): Promise<ActivityCard | undefined>;
|
||||||
|
manifestOf(activityId: string, packageVersion?: string): Promise<ManifestFace>;
|
||||||
|
setEnabled(activityId: string, enabled: boolean): Promise<void>;
|
||||||
|
};
|
||||||
|
export type TaskFace = {
|
||||||
|
start(input: StartTaskInput): Promise<{ readonly id: TaskId }>;
|
||||||
|
control(taskId: TaskId, semantic: 'terminate', by: StaffId, reason?: string): Promise<unknown>;
|
||||||
|
};
|
||||||
|
export type OrgFace = {
|
||||||
|
listStaff(): Promise<{ staff: ReadonlyArray<{ readonly id: string; readonly name: string }> }>;
|
||||||
|
getStaff(staffId: StaffId): Promise<{ readonly name: string; readonly state: string }>;
|
||||||
|
};
|
||||||
|
export type HistoryEntry = { readonly kind: string; readonly title: string; readonly at: string; readonly detail?: Record<string, unknown> | undefined };
|
||||||
|
export type MemoryFace = { history(taskId: TaskId): Promise<ReadonlyArray<HistoryEntry>> };
|
||||||
|
export type LedgerFace = {
|
||||||
|
describe(address: { domain: string; object: string }): Promise<{ readonly columns: ReadonlyArray<{ readonly name: string }> } | undefined>;
|
||||||
|
query(query: LedgerQuery): Promise<{ readonly rows: ReadonlyArray<Readonly<Record<string, unknown>>> }>;
|
||||||
|
};
|
||||||
|
export type LoopFace = {
|
||||||
|
readonly model: { readonly steps?: ReadonlyArray<{ readonly item: string; readonly intent: string }> | undefined };
|
||||||
|
readonly parsed: { readonly items: readonly PlanItem[] };
|
||||||
|
readonly runtime: { readonly items: readonly ItemRuntime[] };
|
||||||
|
readonly pending?: { readonly verdict?: 'pass' | 'reject' | undefined; readonly comment?: string | undefined } | undefined;
|
||||||
|
};
|
||||||
|
export type LoopStateFace = { load(taskId: TaskId): Promise<LoopFace | undefined> };
|
||||||
|
|
||||||
|
export type ActivityOpsDeps = {
|
||||||
|
readonly registry: RegistryFace;
|
||||||
|
readonly task: TaskFace;
|
||||||
|
readonly org: OrgFace;
|
||||||
|
readonly memory: MemoryFace;
|
||||||
|
readonly ledger: LedgerFace;
|
||||||
|
readonly loopState: LoopStateFace;
|
||||||
|
readonly store: ActivityOpsStore;
|
||||||
|
readonly files: InstanceFileStore;
|
||||||
|
readonly clock: { now(): Date };
|
||||||
|
/** 读不到候选这类**不该炸**的事,说一声就好 */
|
||||||
|
readonly warn?: (line: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Actor = { readonly id: StaffId; readonly name: string };
|
||||||
|
|
||||||
|
export type LaunchOutcome =
|
||||||
|
| { readonly kind: 'ok'; readonly detail: InstanceDetail; readonly created: boolean }
|
||||||
|
| { readonly kind: 'not-found' }
|
||||||
|
| { readonly kind: 'disabled' }
|
||||||
|
| { readonly kind: 'invalid'; readonly issues: readonly LaunchIssue[] }
|
||||||
|
| { readonly kind: 'owner-invalid'; readonly message: string }
|
||||||
|
| { readonly kind: 'busy'; readonly message: string };
|
||||||
|
|
||||||
|
export type TerminateOutcome = { readonly kind: 'ok'; readonly detail: InstanceDetail } | { readonly kind: 'not-found' } | { readonly kind: 'terminal' };
|
||||||
|
|
||||||
|
export type FileOutcome =
|
||||||
|
| { readonly kind: 'file'; readonly name: string; readonly contentType: string; readonly bytes: Uint8Array }
|
||||||
|
| { readonly kind: 'no-instance' }
|
||||||
|
| { readonly kind: 'no-file' };
|
||||||
|
|
||||||
|
export type ViewOutcome = { readonly kind: 'html'; readonly html: string } | { readonly kind: 'no-activity' } | { readonly kind: 'no-view' };
|
||||||
|
|
||||||
|
export type ActivityOps = {
|
||||||
|
listActivities(status?: ActivityStatus): Promise<ActivitySummary[]>;
|
||||||
|
getActivity(activityId: string): Promise<ActivityDetail | undefined>;
|
||||||
|
viewHtml(activityId: string): Promise<ViewOutcome>;
|
||||||
|
listInstances(activityId: string, query: InstanceQuery & { page: number; size: number }): Promise<InstanceList | undefined>;
|
||||||
|
launchParams(activityId: string): Promise<LaunchParams | undefined>;
|
||||||
|
launch(activityId: string, input: LaunchInput, by: Actor): Promise<LaunchOutcome>;
|
||||||
|
instanceDetail(activityId: string, instanceId: string): Promise<InstanceDetail | undefined>;
|
||||||
|
instanceFile(activityId: string, instanceId: string, io: 'input' | 'output', index: number): Promise<FileOutcome>;
|
||||||
|
setEnabled(activityId: string, enabled: boolean): Promise<ActivityDetail | undefined>;
|
||||||
|
terminate(activityId: string, instanceId: string, by: Actor): Promise<TerminateOutcome>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const NAME_FALLBACK = '员工';
|
||||||
|
|
||||||
|
function isTerminal(value: string | null): value is TerminalState {
|
||||||
|
return value !== null && (TERMINAL_STATES as readonly string[]).includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hueOf(raw: string | null): ActivityHue | undefined {
|
||||||
|
return raw !== null && (ACTIVITY_HUES as readonly string[]).includes(raw) ? (raw as ActivityHue) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前生效的包版本:非 uninstalled 的最新一版(store 按 installedAt 升序给) */
|
||||||
|
function versionOf(card: ActivityCard): string {
|
||||||
|
const live = card.packageVersions.filter((p) => p.state !== 'uninstalled');
|
||||||
|
const pick = (live.length > 0 ? live : card.packageVersions).at(-1);
|
||||||
|
return pick?.version ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function createdAtOf(card: ActivityCard): string {
|
||||||
|
let earliest: string | undefined;
|
||||||
|
for (const p of card.packageVersions) if (earliest === undefined || p.installedAt < earliest) earliest = p.installedAt;
|
||||||
|
return earliest ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function llmOf(manifest: ManifestFace): string {
|
||||||
|
return manifest.declarations.models[0] ?? '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
function channelLabel(channel: string): string {
|
||||||
|
if (channel === 'mail') return '邮件';
|
||||||
|
if (channel === 'im') return '即时消息';
|
||||||
|
return channel === '' ? '通道' : channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createActivityOps(deps: ActivityOpsDeps): ActivityOps {
|
||||||
|
const warn = deps.warn ?? ((line: string) => process.stderr.write(`${line}\n`));
|
||||||
|
|
||||||
|
async function namesOf(): Promise<Map<string, string>> {
|
||||||
|
const { staff } = await deps.org.listStaff();
|
||||||
|
return new Map(staff.map((s) => [s.id, s.name]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function staffRef(id: string, names: ReadonlyMap<string, string>): StaffRef {
|
||||||
|
return { id: staffIdSchema.parse(id), name: names.get(id) ?? NAME_FALLBACK };
|
||||||
|
}
|
||||||
|
|
||||||
|
function summaryOf(
|
||||||
|
card: ActivityCard,
|
||||||
|
manifest: ManifestFace,
|
||||||
|
profile: { ownerId: string | null; description: string; ico: string | null; hue: string | null } | undefined,
|
||||||
|
instanceCount: number,
|
||||||
|
names: ReadonlyMap<string, string>,
|
||||||
|
): ActivitySummary {
|
||||||
|
const hue = hueOf(profile?.hue ?? null);
|
||||||
|
return {
|
||||||
|
id: card.activityId,
|
||||||
|
name: card.name,
|
||||||
|
biz: card.category,
|
||||||
|
status: card.enabled ? 'active' : 'disabled',
|
||||||
|
version: versionOf(card),
|
||||||
|
owner: profile?.ownerId === null || profile?.ownerId === undefined ? null : staffRef(profile.ownerId, names),
|
||||||
|
llm: llmOf(manifest),
|
||||||
|
instanceCount,
|
||||||
|
createdAt: createdAtOf(card),
|
||||||
|
...(profile?.ico === null || profile?.ico === undefined || profile.ico === '' ? {} : { ico: profile.ico }),
|
||||||
|
...(hue === undefined ? {} : { hue }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function detailOf(card: ActivityCard): Promise<ActivityDetail> {
|
||||||
|
const [manifest, profiles, names] = await Promise.all([deps.registry.manifestOf(card.activityId), deps.store.profilesOf(), namesOf()]);
|
||||||
|
const profile = profiles.get(card.activityId);
|
||||||
|
const { instanceCount: _drop, ...rest } = summaryOf(card, manifest, profile, 0, names);
|
||||||
|
void _drop;
|
||||||
|
const hasView = manifest.assets.viewTemplates.some((v) => v.name === LEDGER_TEMPLATE);
|
||||||
|
return { ...rest, desc: profile?.description ?? '', view: hasView ? { url: `/api/gov/activities/${card.activityId}/view` } : null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** combo / select 的候选从正式台账现读;表没物化、列不存在、查询失败 → 没有候选(不炸,说一声) */
|
||||||
|
async function resolveOptions(source: OptionsSource): Promise<readonly LaunchOption[] | undefined> {
|
||||||
|
const [domain, object] = source.ledger.split('.', 2);
|
||||||
|
if (domain === undefined || object === undefined || domain === '' || object === '') return undefined;
|
||||||
|
const address = { domain, object };
|
||||||
|
const described = await deps.ledger.describe(address).catch(() => undefined);
|
||||||
|
if (described === undefined) {
|
||||||
|
warn(`[activity-ops] 启动参数候选读不到:${source.ledger} 尚未物化`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const columns = new Set(described.columns.map((c) => c.name));
|
||||||
|
if (!columns.has(source.value) || !columns.has(source.label)) {
|
||||||
|
warn(`[activity-ops] 启动参数候选读不到:${source.ledger} 没有列 ${source.value} / ${source.label}`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const page = await deps.ledger
|
||||||
|
.query({ address, where: {}, orderBy: [{ field: source.label, dir: 'asc' }], limit: OPTIONS_CAP, offset: 0 })
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
warn(`[activity-ops] 启动参数候选查询失败:${source.ledger} —— ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
if (page === undefined) return undefined;
|
||||||
|
return page.rows
|
||||||
|
.map((row) => {
|
||||||
|
const value = row[source.value];
|
||||||
|
const label = row[source.label];
|
||||||
|
const v = value === undefined || value === null ? '' : String(value);
|
||||||
|
const l = label === undefined || label === null || String(label) === '' ? v : String(label);
|
||||||
|
return { value: v, label: l };
|
||||||
|
})
|
||||||
|
.filter((o) => o.value !== '');
|
||||||
|
}
|
||||||
|
|
||||||
|
type InstanceCtx = { readonly names: ReadonlyMap<string, string>; readonly specs: readonly FieldSpec[] };
|
||||||
|
|
||||||
|
/** 一行 task → 状态裁定;非终态才需要翻记忆与 loop 运行态(判驳回) */
|
||||||
|
async function verdictOf(row: TaskRow, names: ReadonlyMap<string, string>): Promise<{ verdict: StateVerdict; history: ReadonlyArray<HistoryEntry> | undefined }> {
|
||||||
|
const terminal = isTerminal(row.terminal) ? row.terminal : undefined;
|
||||||
|
if (terminal !== undefined) {
|
||||||
|
return { verdict: instanceStateOf({ terminal, reason: row.reason ?? undefined, runtime: row.runtime, usedSteps: row.usedSteps, lastVerdict: undefined, pending: undefined }), history: undefined };
|
||||||
|
}
|
||||||
|
const taskId = taskIdSchema.parse(row.id);
|
||||||
|
const [history, loop] = await Promise.all([deps.memory.history(taskId), deps.loopState.load(taskId)]);
|
||||||
|
const pending = loop?.pending;
|
||||||
|
return {
|
||||||
|
verdict: instanceStateOf({
|
||||||
|
terminal: undefined,
|
||||||
|
reason: row.reason ?? undefined,
|
||||||
|
runtime: row.runtime,
|
||||||
|
usedSteps: row.usedSteps,
|
||||||
|
lastVerdict: verdictFactOf(history, (id) => names.get(id)),
|
||||||
|
pending: pending === undefined ? undefined : { verdict: pending.verdict, comment: pending.comment },
|
||||||
|
}),
|
||||||
|
history,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function inputTextOf(row: TaskRow, specs: readonly FieldSpec[]): Promise<string> {
|
||||||
|
const fromData = inputSummaryOf(row.data, specs);
|
||||||
|
if (fromData !== '') return fromData;
|
||||||
|
const comm = await deps.store.inboundCommOf(row.id);
|
||||||
|
return inputSummaryOf({}, [], comm?.subject !== undefined && comm.subject !== '' ? comm.subject : row.title);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function outputTextOf(row: TaskRow, state: InstanceState, history: ReadonlyArray<HistoryEntry> | undefined): Promise<string | null> {
|
||||||
|
if (state !== 'completed') return null;
|
||||||
|
const taskId = taskIdSchema.parse(row.id);
|
||||||
|
const archived = await deps.files.listArchived(taskId);
|
||||||
|
const entries = history ?? (await deps.memory.history(taskId));
|
||||||
|
const fired = [...entries]
|
||||||
|
.reverse()
|
||||||
|
.find((e) => e.kind === 'release-verdict' && Array.isArray(e.detail?.['ports']))
|
||||||
|
?.detail?.['ports'];
|
||||||
|
const ports = Array.isArray(fired) ? fired.filter((p): p is string => typeof p === 'string') : [];
|
||||||
|
return outputSummaryOf(archived.map((f) => f.name), ports);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function summarizeRow(row: TaskRow, ctx: InstanceCtx): Promise<{ summary: InstanceSummary; verdict: StateVerdict; history: ReadonlyArray<HistoryEntry> | undefined }> {
|
||||||
|
const { verdict, history } = await verdictOf(row, ctx.names);
|
||||||
|
return {
|
||||||
|
summary: {
|
||||||
|
id: row.id,
|
||||||
|
no: row.no,
|
||||||
|
state: verdict.state,
|
||||||
|
owner: staffRef(row.ownerId, ctx.names),
|
||||||
|
input: await inputTextOf(row, ctx.specs),
|
||||||
|
output: null,
|
||||||
|
startedAt: row.startedAt,
|
||||||
|
},
|
||||||
|
verdict,
|
||||||
|
history,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sourceOf(row: TaskRow, names: ReadonlyMap<string, string>): Promise<InstanceSource> {
|
||||||
|
if (row.workflowId !== null) {
|
||||||
|
const wf = await deps.store.workflowOf(row.workflowId);
|
||||||
|
return { kind: 'upstream', label: wf === undefined || wf.subject === '' ? '上游工作流' : `上游 · ${wf.subject}` };
|
||||||
|
}
|
||||||
|
const claim = await deps.store.launchOf(row.id);
|
||||||
|
if (claim !== undefined || row.origin === 'ops') {
|
||||||
|
const who = claim === undefined ? '运营端' : (names.get(claim.launchedBy) ?? NAME_FALLBACK);
|
||||||
|
return { kind: 'manual', label: `手动启动 · ${who}` };
|
||||||
|
}
|
||||||
|
const comm = await deps.store.inboundCommOf(row.id);
|
||||||
|
if (comm !== undefined) {
|
||||||
|
const kind = comm.channel === '' ? 'mail' : comm.channel;
|
||||||
|
return { kind, label: `${channelLabel(comm.channel)} · ${comm.party !== '' ? comm.party : comm.subject}` };
|
||||||
|
}
|
||||||
|
if (row.origin === 'testbed') return { kind: 'testbed', label: '测试台试跑' };
|
||||||
|
return { kind: 'system', label: '系统起单' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 沙箱看板:最近一次**已放行**实例的落账快照(2026-08-27 用户定;在办的单不摆,行全从正式表圈)。
|
||||||
|
* 案号 = 声明的前缀 + 开案时刻(与模型铸主键那串同源);逐表读经 `describe → query → catch`,一张表读不了
|
||||||
|
* 不拖垮整屏(说一声);案号查无 → `board:state = missing`,没放行过的活动 → `none`,模版自己亮横幅。
|
||||||
|
*/
|
||||||
|
async function renderBoard(activityId: string, manifest: ManifestFace, html: string, spec: ViewData): Promise<string> {
|
||||||
|
const declared = manifest.declarations.ledger;
|
||||||
|
const instance = await deps.store.latestReleased(activityId);
|
||||||
|
if (instance === undefined) return renderView(html, viewScopeOf({ spec, sets: {}, declared, state: 'none' }));
|
||||||
|
const caseRef = `${spec.case.prefix}${caseStamp(instance.startedAt)}`;
|
||||||
|
const owner = await deps.org.getStaff(staffIdSchema.parse(instance.ownerId)).then((s) => s.name, () => '');
|
||||||
|
const read: ViewRead = async (address, where, orderBy) => {
|
||||||
|
const described = await deps.ledger.describe(address).catch(() => undefined);
|
||||||
|
if (described === undefined) return undefined;
|
||||||
|
const page = await deps.ledger.query({ address, where, orderBy: [...orderBy], limit: OPTIONS_CAP, offset: 0 }).catch(() => undefined);
|
||||||
|
return page?.rows;
|
||||||
|
};
|
||||||
|
const resolved = await resolveViewData(spec, { caseRef, read });
|
||||||
|
for (const name of new Set(resolved.skipped)) warn(`[activity-ops] 沙箱看板读不到 ${name}(${activityId} · ${caseRef}):表未物化或查询失败,这一块按空摆`);
|
||||||
|
const scope = viewScopeOf({
|
||||||
|
spec,
|
||||||
|
sets: resolved.sets,
|
||||||
|
declared,
|
||||||
|
instance: { no: instance.no, startedAt: instance.startedAt, finishedAt: instance.finishedAt, owner },
|
||||||
|
caseRef,
|
||||||
|
state: resolved.caseFound ? 'ready' : 'missing',
|
||||||
|
});
|
||||||
|
return renderView(html, scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function activityOf(activityId: string): Promise<{ card: ActivityCard; manifest: ManifestFace } | undefined> {
|
||||||
|
const card = await deps.registry.get(activityId);
|
||||||
|
if (card === undefined) return undefined;
|
||||||
|
return { card, manifest: await deps.registry.manifestOf(activityId) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function detailRow(row: TaskRow, manifest: ManifestFace, names: ReadonlyMap<string, string>): Promise<InstanceDetail> {
|
||||||
|
const specs = fieldSpecsOf(manifest.contract.input.data);
|
||||||
|
const { summary, verdict, history } = await summarizeRow(row, { names, specs });
|
||||||
|
const taskId = taskIdSchema.parse(row.id);
|
||||||
|
const [entries, loop, source, archived] = await Promise.all([
|
||||||
|
history === undefined ? deps.memory.history(taskId) : Promise.resolve(history),
|
||||||
|
deps.loopState.load(taskId),
|
||||||
|
sourceOf(row, names),
|
||||||
|
deps.files.listArchived(taskId),
|
||||||
|
]);
|
||||||
|
const llm = llmOf(manifest);
|
||||||
|
const steps = stepsOf({
|
||||||
|
items: loop?.parsed.items ?? [],
|
||||||
|
runtimeItems: loop?.runtime.items ?? [],
|
||||||
|
intents: new Map((loop?.model.steps ?? []).map((s) => [s.item, s.intent])),
|
||||||
|
history: entries.map((e) => ({ kind: e.kind, title: e.title, at: e.at })),
|
||||||
|
state: verdict.state,
|
||||||
|
ownerName: summary.owner.name,
|
||||||
|
llm,
|
||||||
|
});
|
||||||
|
const startedMs = Date.parse(row.startedAt);
|
||||||
|
const finishedMs = row.finishedAt === null ? undefined : Date.parse(row.finishedAt);
|
||||||
|
const durationMs = finishedMs !== undefined && Number.isFinite(startedMs) && Number.isFinite(finishedMs) && finishedMs >= startedMs ? finishedMs - startedMs : null;
|
||||||
|
const inputFiles: InstanceFile[] = inputFilesOf(row.data, specs).map((f) => ({ index: f.index, name: f.name, contentType: f.contentType, size: f.size }));
|
||||||
|
const outputFiles: InstanceFile[] = archived.map((f, index) => ({ index, name: f.name, contentType: MIME_OF[f.format], size: f.size }));
|
||||||
|
// 详情里 `output` 是文件清单(文档 §6),清单上那一句摘要在详情里不再出现
|
||||||
|
const { input: content, output: _outputText, ...rest } = summary;
|
||||||
|
void _outputText;
|
||||||
|
return {
|
||||||
|
...rest,
|
||||||
|
activityId: row.activityId,
|
||||||
|
llm,
|
||||||
|
endedAt: row.finishedAt,
|
||||||
|
durationMs,
|
||||||
|
reason: verdict.reason,
|
||||||
|
input: { content, source, submittedAt: row.startedAt, files: inputFiles },
|
||||||
|
output: { files: outputFiles },
|
||||||
|
steps,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function instanceDetail(activityId: string, instanceId: string): Promise<InstanceDetail | undefined> {
|
||||||
|
const found = await activityOf(activityId);
|
||||||
|
if (found === undefined) return undefined;
|
||||||
|
const row = await deps.store.getTask(activityId, instanceId);
|
||||||
|
if (row === undefined) return undefined;
|
||||||
|
const names = await namesOf();
|
||||||
|
return detailRow(row, found.manifest, names);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
async listActivities(status) {
|
||||||
|
const cards = await deps.registry.list();
|
||||||
|
const [profiles, counts, names] = await Promise.all([deps.store.profilesOf(), deps.store.countByActivity(), namesOf()]);
|
||||||
|
const out: ActivitySummary[] = [];
|
||||||
|
for (const card of cards) {
|
||||||
|
const manifest = await deps.registry.manifestOf(card.activityId);
|
||||||
|
const summary = summaryOf(card, manifest, profiles.get(card.activityId), counts.get(card.activityId) ?? 0, names);
|
||||||
|
if (status === undefined || summary.status === status) out.push(summary);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getActivity(activityId) {
|
||||||
|
const card = await deps.registry.get(activityId);
|
||||||
|
return card === undefined ? undefined : detailOf(card);
|
||||||
|
},
|
||||||
|
|
||||||
|
async viewHtml(activityId) {
|
||||||
|
const found = await activityOf(activityId);
|
||||||
|
if (found === undefined) return { kind: 'no-activity' };
|
||||||
|
const template = found.manifest.assets.viewTemplates.find((v) => v.name === LEDGER_TEMPLATE);
|
||||||
|
if (template === undefined) return { kind: 'no-view' };
|
||||||
|
const html = template.data === undefined ? template.html : await renderBoard(activityId, found.manifest, template.html, template.data);
|
||||||
|
return { kind: 'html', html: injectHeightScript(html) };
|
||||||
|
},
|
||||||
|
|
||||||
|
async listInstances(activityId, query) {
|
||||||
|
const found = await activityOf(activityId);
|
||||||
|
if (found === undefined) return undefined;
|
||||||
|
const specs = fieldSpecsOf(found.manifest.contract.input.data);
|
||||||
|
const names = await namesOf();
|
||||||
|
const rows = await deps.store.listTasks(activityId);
|
||||||
|
const byId = new Map(rows.map((r) => [r.id, r]));
|
||||||
|
const summarized = await Promise.all(rows.map((row) => summarizeRow(row, { names, specs })));
|
||||||
|
const all = summarized.map((s) => s.summary);
|
||||||
|
const stats = statsOf(all);
|
||||||
|
const filtered = applyInstanceFilters(all, query);
|
||||||
|
const page = pageOf(filtered, query.page, query.size);
|
||||||
|
const items = await Promise.all(
|
||||||
|
page.map(async (summary) => {
|
||||||
|
const row = byId.get(summary.id);
|
||||||
|
const hit = summarized.find((s) => s.summary.id === summary.id);
|
||||||
|
const output = row === undefined || hit === undefined ? null : await outputTextOf(row, summary.state, hit.history);
|
||||||
|
return { ...summary, output };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return { stats, total: filtered.length, page: query.page, size: query.size, items };
|
||||||
|
},
|
||||||
|
|
||||||
|
async launchParams(activityId) {
|
||||||
|
const found = await activityOf(activityId);
|
||||||
|
if (found === undefined) return undefined;
|
||||||
|
const params = await launchParamsOf(found.manifest.contract.input.data, resolveOptions);
|
||||||
|
return { activityId, version: versionOf(found.card), params };
|
||||||
|
},
|
||||||
|
|
||||||
|
async launch(activityId, input, by) {
|
||||||
|
const found = await activityOf(activityId);
|
||||||
|
if (found === undefined) return { kind: 'not-found' };
|
||||||
|
if (!found.card.enabled) return { kind: 'disabled' };
|
||||||
|
try {
|
||||||
|
const owner = await deps.org.getStaff(input.ownerId);
|
||||||
|
if (owner.state !== 'active') return { kind: 'owner-invalid', message: `责任人 ${owner.name} 已停用,换一位在职的人` };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof StaffNotFoundError) return { kind: 'owner-invalid', message: '责任人不在名册里' };
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const schema = found.manifest.contract.input.data;
|
||||||
|
const params = await launchParamsOf(schema, resolveOptions);
|
||||||
|
const checked = validateLaunchParams(params, input.params, fieldSpecsOf(schema));
|
||||||
|
if (!checked.ok) return { kind: 'invalid', issues: checked.issues };
|
||||||
|
|
||||||
|
const claim = await deps.store.claimLaunch({ key: input.idempotencyKey, activityId, ownerId: input.ownerId, launchedBy: by.id });
|
||||||
|
if (claim !== 'claimed') {
|
||||||
|
if (claim.activityId !== activityId) return { kind: 'busy', message: '这枚幂等键已用在别的活动上 —— 换一枚' };
|
||||||
|
if (claim.taskId !== null) {
|
||||||
|
const detail = await instanceDetail(activityId, claim.taskId);
|
||||||
|
return detail === undefined ? { kind: 'busy', message: '同一枚幂等键起的那一单已不在,换一枚键再启动' } : { kind: 'ok', detail, created: false };
|
||||||
|
}
|
||||||
|
const age = deps.clock.now().getTime() - Date.parse(claim.claimedAt);
|
||||||
|
if (!(age >= LAUNCH_CLAIM_STALE_MS)) return { kind: 'busy', message: '同一枚幂等键的实例正在创建,稍后再试' };
|
||||||
|
const won = await deps.store.reclaimLaunch(input.idempotencyKey, LAUNCH_CLAIM_STALE_MS, input.ownerId, by.id);
|
||||||
|
if (!won) return { kind: 'busy', message: '同一枚幂等键的实例正在创建,稍后再试' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = await deps.task.start({ activityId, ownerId: input.ownerId, title: found.manifest.identity.name, origin: 'ops', data: checked.data });
|
||||||
|
await deps.store.bindLaunch(input.idempotencyKey, task.id);
|
||||||
|
const detail = await instanceDetail(activityId, task.id);
|
||||||
|
return detail === undefined ? { kind: 'busy', message: '实例已建、暂时读不到,稍后刷新' } : { kind: 'ok', detail, created: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
instanceDetail,
|
||||||
|
|
||||||
|
async instanceFile(activityId, instanceId, io, index) {
|
||||||
|
const found = await activityOf(activityId);
|
||||||
|
if (found === undefined) return { kind: 'no-instance' };
|
||||||
|
const row = await deps.store.getTask(activityId, instanceId);
|
||||||
|
if (row === undefined) return { kind: 'no-instance' };
|
||||||
|
const taskId = taskIdSchema.parse(row.id);
|
||||||
|
if (io === 'input') {
|
||||||
|
const file = inputFilesOf(row.data, fieldSpecsOf(found.manifest.contract.input.data))[index];
|
||||||
|
if (file === undefined) return { kind: 'no-file' };
|
||||||
|
const stored = await deps.files.readUpload(file.ref);
|
||||||
|
return stored === undefined ? { kind: 'no-file' } : { kind: 'file', name: stored.name, contentType: stored.contentType, bytes: stored.bytes };
|
||||||
|
}
|
||||||
|
const archived = (await deps.files.listArchived(taskId))[index];
|
||||||
|
if (archived === undefined) return { kind: 'no-file' };
|
||||||
|
const read = await deps.files.readArchived(taskId, archived.address, archived.key);
|
||||||
|
return read === undefined ? { kind: 'no-file' } : { kind: 'file', name: read.name, contentType: MIME_OF[read.format], bytes: read.bytes };
|
||||||
|
},
|
||||||
|
|
||||||
|
async setEnabled(activityId, enabled) {
|
||||||
|
const card = await deps.registry.get(activityId);
|
||||||
|
if (card === undefined) return undefined;
|
||||||
|
if (card.enabled !== enabled) await deps.registry.setEnabled(activityId, enabled);
|
||||||
|
const fresh = await deps.registry.get(activityId);
|
||||||
|
return fresh === undefined ? undefined : detailOf(fresh);
|
||||||
|
},
|
||||||
|
|
||||||
|
async terminate(activityId, instanceId, by) {
|
||||||
|
const found = await activityOf(activityId);
|
||||||
|
if (found === undefined) return { kind: 'not-found' };
|
||||||
|
const row = await deps.store.getTask(activityId, instanceId);
|
||||||
|
if (row === undefined) return { kind: 'not-found' };
|
||||||
|
if (row.terminal !== null) return { kind: 'terminal' };
|
||||||
|
await deps.task.control(taskIdSchema.parse(row.id), 'terminate', by.id, `由 ${by.name} 手动终止`);
|
||||||
|
const detail = await instanceDetail(activityId, instanceId);
|
||||||
|
return detail === undefined ? { kind: 'not-found' } : { kind: 'ok', detail };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
/**
|
||||||
|
* 运营中心 · 活动模块的 app 层存取 —— 对 `sys.task` / `sys.activity_launch` / `sys.activity_profile` /
|
||||||
|
* `sys.workflow` / `sys.task_comm` 的 raw SQL 读(先例 `asset-archive.ts` / `upload-store.ts` / `workers/task-claim.ts`)。
|
||||||
|
*
|
||||||
|
* 为什么不在 task 模块加 `listByActivity`:清单按活动取、带序号与计数,是**运营面的读投影**,
|
||||||
|
* 不是任务运行时的能力;app 是唯一能同时认识多个模块的层(编码规范 §5)。写一律仍走 port。
|
||||||
|
* `seq` / `activity_launch` / `activity_profile` 都是手写迁移(0034 / 0035)建的,不在 Drizzle schema 里。
|
||||||
|
*/
|
||||||
|
import { sql } from 'drizzle-orm';
|
||||||
|
|
||||||
|
type Db = { execute(query: unknown): Promise<unknown> };
|
||||||
|
|
||||||
|
function rowsOf<T>(result: unknown): T[] {
|
||||||
|
if (Array.isArray(result)) return result as T[];
|
||||||
|
const rows = (result as { rows?: unknown }).rows;
|
||||||
|
return Array.isArray(rows) ? (rows as T[]) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 时刻一律回 ISO 8601 UTC(契约 §0)。pg 的 timestamptz 在 raw `db.execute` 这一路可能回**文本**
|
||||||
|
* (`2026-08-27 16:55:14.383+08`,实测),Date 与文本两种都归一;认不出的原样交出去(不编)。
|
||||||
|
*/
|
||||||
|
function iso(value: unknown): string {
|
||||||
|
if (value instanceof Date) return value.toISOString();
|
||||||
|
const text = String(value);
|
||||||
|
const ms = Date.parse(text);
|
||||||
|
return Number.isFinite(ms) ? new Date(ms).toISOString() : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function record(value: unknown): Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `sys.task` 的一行,运营面要的那几列 */
|
||||||
|
export type TaskRow = {
|
||||||
|
readonly id: string;
|
||||||
|
/** 人读实例号 `INS-<seq>` */
|
||||||
|
readonly no: string;
|
||||||
|
readonly activityId: string;
|
||||||
|
readonly workflowId: string | null;
|
||||||
|
readonly origin: string | null;
|
||||||
|
readonly ownerId: string;
|
||||||
|
readonly title: string;
|
||||||
|
readonly runtime: string;
|
||||||
|
readonly terminal: string | null;
|
||||||
|
readonly reason: string | null;
|
||||||
|
readonly envelope: Record<string, unknown>;
|
||||||
|
readonly data: Record<string, unknown>;
|
||||||
|
readonly usedSteps: number;
|
||||||
|
readonly startedAt: string;
|
||||||
|
readonly finishedAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LaunchClaim = {
|
||||||
|
readonly key: string;
|
||||||
|
readonly activityId: string;
|
||||||
|
readonly ownerId: string;
|
||||||
|
readonly launchedBy: string;
|
||||||
|
readonly taskId: string | null;
|
||||||
|
readonly claimedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ActivityProfile = {
|
||||||
|
readonly activityId: string;
|
||||||
|
readonly ownerId: string | null;
|
||||||
|
readonly description: string;
|
||||||
|
readonly ico: string | null;
|
||||||
|
readonly hue: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ActivityOpsStore = {
|
||||||
|
countByActivity(): Promise<ReadonlyMap<string, number>>;
|
||||||
|
listTasks(activityId: string): Promise<TaskRow[]>;
|
||||||
|
getTask(activityId: string, taskId: string): Promise<TaskRow | undefined>;
|
||||||
|
/** 最近一次已放行的实例(沙箱看板摆它):按放行时刻倒序,没放行过回 undefined */
|
||||||
|
latestReleased(activityId: string): Promise<TaskRow | undefined>;
|
||||||
|
/** 占键:插得进去回 `claimed`;键已在就回那一行(判与占一句 SQL,两台后端安全) */
|
||||||
|
claimLaunch(input: { key: string; activityId: string; ownerId: string; launchedBy: string }): Promise<'claimed' | LaunchClaim>;
|
||||||
|
/** 键在、单没建成(task_id 空)且已过 `staleMs` → 重占;抢到回 true */
|
||||||
|
reclaimLaunch(key: string, staleMs: number, ownerId: string, launchedBy: string): Promise<boolean>;
|
||||||
|
bindLaunch(key: string, taskId: string): Promise<void>;
|
||||||
|
launchOf(taskId: string): Promise<LaunchClaim | undefined>;
|
||||||
|
profilesOf(): Promise<ReadonlyMap<string, ActivityProfile>>;
|
||||||
|
upsertProfile(profile: ActivityProfile): Promise<void>;
|
||||||
|
workflowOf(workflowId: string): Promise<{ subject: string; source: Record<string, unknown> } | undefined>;
|
||||||
|
inboundCommOf(taskId: string): Promise<{ channel: string; party: string; subject: string } | undefined>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RawTask = {
|
||||||
|
id: string;
|
||||||
|
seq: unknown;
|
||||||
|
activity_id: string;
|
||||||
|
workflow_id: string | null;
|
||||||
|
origin: string | null;
|
||||||
|
owner_id: string;
|
||||||
|
title: string;
|
||||||
|
runtime: string;
|
||||||
|
terminal: string | null;
|
||||||
|
reason: string | null;
|
||||||
|
envelope: unknown;
|
||||||
|
input_data: unknown;
|
||||||
|
used_steps: unknown;
|
||||||
|
started_at: unknown;
|
||||||
|
finished_at: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
function taskOf(r: RawTask): TaskRow {
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
no: `INS-${String(r.seq ?? '')}`,
|
||||||
|
activityId: r.activity_id,
|
||||||
|
workflowId: r.workflow_id,
|
||||||
|
origin: r.origin,
|
||||||
|
ownerId: r.owner_id,
|
||||||
|
title: r.title,
|
||||||
|
runtime: r.runtime,
|
||||||
|
terminal: r.terminal,
|
||||||
|
reason: r.reason,
|
||||||
|
envelope: record(r.envelope),
|
||||||
|
data: record(r.input_data),
|
||||||
|
usedSteps: Number(r.used_steps ?? 0),
|
||||||
|
startedAt: iso(r.started_at),
|
||||||
|
finishedAt: r.finished_at === null || r.finished_at === undefined ? null : iso(r.finished_at),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type RawClaim = { key: string; activity_id: string; owner_id: string; launched_by: string; task_id: string | null; claimed_at: unknown };
|
||||||
|
|
||||||
|
function claimOf(r: RawClaim): LaunchClaim {
|
||||||
|
return { key: r.key, activityId: r.activity_id, ownerId: r.owner_id, launchedBy: r.launched_by, taskId: r.task_id, claimedAt: iso(r.claimed_at) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const TASK_COLUMNS = sql`id, seq, activity_id, workflow_id, origin, owner_id, title, runtime, terminal, reason, envelope, input_data, used_steps, started_at, finished_at`;
|
||||||
|
|
||||||
|
export function createActivityOpsStore(db: Db): ActivityOpsStore {
|
||||||
|
return {
|
||||||
|
async countByActivity() {
|
||||||
|
const rows = rowsOf<{ activity_id: string; n: unknown }>(
|
||||||
|
await db.execute(sql`select activity_id, count(*)::int as n from sys.task group by activity_id`),
|
||||||
|
);
|
||||||
|
return new Map(rows.map((r) => [r.activity_id, Number(r.n ?? 0)]));
|
||||||
|
},
|
||||||
|
|
||||||
|
async listTasks(activityId) {
|
||||||
|
const rows = rowsOf<RawTask>(
|
||||||
|
await db.execute(sql`select ${TASK_COLUMNS} from sys.task where activity_id = ${activityId} order by started_at desc, seq desc`),
|
||||||
|
);
|
||||||
|
return rows.map(taskOf);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getTask(activityId, taskId) {
|
||||||
|
const rows = rowsOf<RawTask>(
|
||||||
|
await db.execute(sql`select ${TASK_COLUMNS} from sys.task where activity_id = ${activityId} and id = ${taskId} limit 1`),
|
||||||
|
);
|
||||||
|
const one = rows[0];
|
||||||
|
return one === undefined ? undefined : taskOf(one);
|
||||||
|
},
|
||||||
|
|
||||||
|
async latestReleased(activityId) {
|
||||||
|
const rows = rowsOf<RawTask>(
|
||||||
|
await db.execute(sql`
|
||||||
|
select ${TASK_COLUMNS} from sys.task
|
||||||
|
where activity_id = ${activityId} and terminal = 'released'
|
||||||
|
order by finished_at desc nulls last, started_at desc limit 1`),
|
||||||
|
);
|
||||||
|
const one = rows[0];
|
||||||
|
return one === undefined ? undefined : taskOf(one);
|
||||||
|
},
|
||||||
|
|
||||||
|
async claimLaunch({ key, activityId, ownerId, launchedBy }) {
|
||||||
|
const inserted = rowsOf<{ key: string }>(
|
||||||
|
await db.execute(sql`
|
||||||
|
insert into sys.activity_launch (key, activity_id, owner_id, launched_by)
|
||||||
|
values (${key}, ${activityId}, ${ownerId}, ${launchedBy})
|
||||||
|
on conflict (key) do nothing
|
||||||
|
returning key`),
|
||||||
|
);
|
||||||
|
if (inserted.length > 0) return 'claimed';
|
||||||
|
const existing = rowsOf<RawClaim>(
|
||||||
|
await db.execute(sql`select key, activity_id, owner_id, launched_by, task_id, claimed_at from sys.activity_launch where key = ${key} limit 1`),
|
||||||
|
)[0];
|
||||||
|
// 插不进去又读不到:另一台刚回滚 —— 当作它还在创建,让调用方按 busy 处置
|
||||||
|
return existing === undefined ? { key, activityId, ownerId, launchedBy, taskId: null, claimedAt: new Date(0).toISOString() } : claimOf(existing);
|
||||||
|
},
|
||||||
|
|
||||||
|
async reclaimLaunch(key, staleMs, ownerId, launchedBy) {
|
||||||
|
const rows = rowsOf<{ key: string }>(
|
||||||
|
await db.execute(sql`
|
||||||
|
update sys.activity_launch
|
||||||
|
set claimed_at = now(), owner_id = ${ownerId}, launched_by = ${launchedBy}
|
||||||
|
where key = ${key} and task_id is null and claimed_at < now() - (${staleMs} || ' milliseconds')::interval
|
||||||
|
returning key`),
|
||||||
|
);
|
||||||
|
return rows.length > 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
async bindLaunch(key, taskId) {
|
||||||
|
await db.execute(sql`update sys.activity_launch set task_id = ${taskId} where key = ${key}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async launchOf(taskId) {
|
||||||
|
const one = rowsOf<RawClaim>(
|
||||||
|
await db.execute(sql`select key, activity_id, owner_id, launched_by, task_id, claimed_at from sys.activity_launch where task_id = ${taskId} limit 1`),
|
||||||
|
)[0];
|
||||||
|
return one === undefined ? undefined : claimOf(one);
|
||||||
|
},
|
||||||
|
|
||||||
|
async profilesOf() {
|
||||||
|
const rows = rowsOf<{ activity_id: string; owner_id: string | null; description: string; ico: string | null; hue: string | null }>(
|
||||||
|
await db.execute(sql`select activity_id, owner_id, description, ico, hue from sys.activity_profile`),
|
||||||
|
);
|
||||||
|
return new Map(
|
||||||
|
rows.map((r) => [r.activity_id, { activityId: r.activity_id, ownerId: r.owner_id, description: r.description ?? '', ico: r.ico, hue: r.hue }]),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async upsertProfile(p) {
|
||||||
|
await db.execute(sql`
|
||||||
|
insert into sys.activity_profile (activity_id, owner_id, description, ico, hue, updated_at)
|
||||||
|
values (${p.activityId}, ${p.ownerId}, ${p.description}, ${p.ico}, ${p.hue}, now())
|
||||||
|
on conflict (activity_id) do update
|
||||||
|
set owner_id = excluded.owner_id, description = excluded.description, ico = excluded.ico, hue = excluded.hue, updated_at = now()`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async workflowOf(workflowId) {
|
||||||
|
const one = rowsOf<{ subject: string; source: unknown }>(await db.execute(sql`select subject, source from sys.workflow where id = ${workflowId} limit 1`))[0];
|
||||||
|
return one === undefined ? undefined : { subject: one.subject ?? '', source: record(one.source) };
|
||||||
|
},
|
||||||
|
|
||||||
|
async inboundCommOf(taskId) {
|
||||||
|
const one = rowsOf<{ channel: string; party: string; subject: string }>(
|
||||||
|
await db.execute(sql`select channel, party, subject from sys.task_comm where task_id = ${taskId} and direction = 'in' order by at limit 1`),
|
||||||
|
)[0];
|
||||||
|
return one === undefined ? undefined : { channel: one.channel ?? '', party: one.party ?? '', subject: one.subject ?? '' };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* 清单上的一句话与检索 —— 输入摘要 / 输入文件 / 产出摘要 / 过滤 / 统计 / 分页,全是纯函数。
|
||||||
|
*/
|
||||||
|
import type { InstanceState, InstanceStats, InstanceSummary } from '@contracts';
|
||||||
|
import { INSTANCE_STATES } from '@contracts';
|
||||||
|
import type { FieldSpec } from './launch-params.ts';
|
||||||
|
|
||||||
|
export const SUMMARY_MAX = 80;
|
||||||
|
|
||||||
|
/** 压空白、截到一行能摆下的长度;截了才加省略号(加了等于说「后面还有」) */
|
||||||
|
export function clip(text: string, max = SUMMARY_MAX): string {
|
||||||
|
const one = text.replace(/\s+/g, ' ').trim();
|
||||||
|
return one.length > max ? `${one.slice(0, max - 1)}…` : one;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 输入一句话:清单标了 `x-control: textarea` 的那格最像「这单是什么」(客户需求 / 本轮说明),
|
||||||
|
* 没有就取最长的那个顶层字符串;都没有退回来件主题;再没有就空串。文件字段不算。
|
||||||
|
*/
|
||||||
|
export function inputSummaryOf(data: Readonly<Record<string, unknown>>, specs: readonly FieldSpec[], fallback?: string): string {
|
||||||
|
const fileKeys = new Set(specs.filter((s) => s.format === 'file').map((s) => s.key));
|
||||||
|
for (const spec of specs) {
|
||||||
|
if (spec.control !== 'textarea') continue;
|
||||||
|
const value = data[spec.key];
|
||||||
|
if (typeof value === 'string' && value.trim() !== '') return clip(value);
|
||||||
|
}
|
||||||
|
let longest = '';
|
||||||
|
for (const [key, value] of Object.entries(data)) {
|
||||||
|
if (fileKeys.has(key) || typeof value !== 'string') continue;
|
||||||
|
if (value.trim().length > longest.trim().length) longest = value;
|
||||||
|
}
|
||||||
|
if (longest.trim() !== '') return clip(longest);
|
||||||
|
return fallback === undefined ? '' : clip(fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InputFileRef = { readonly index: number; readonly name: string; readonly contentType: string; readonly size: number; readonly ref: string };
|
||||||
|
|
||||||
|
/** `format: 'file'` 的字段里那些 `{name, contentType, size, ref}`;`index` 跨字段连续编号 */
|
||||||
|
export function inputFilesOf(data: Readonly<Record<string, unknown>>, specs: readonly FieldSpec[]): InputFileRef[] {
|
||||||
|
const out: InputFileRef[] = [];
|
||||||
|
for (const spec of specs) {
|
||||||
|
if (spec.format !== 'file') continue;
|
||||||
|
const raw = data[spec.key];
|
||||||
|
const list = Array.isArray(raw) ? raw : raw === undefined || raw === null ? [] : [raw];
|
||||||
|
for (const one of list) {
|
||||||
|
if (typeof one !== 'object' || one === null) continue;
|
||||||
|
const file = one as Record<string, unknown>;
|
||||||
|
const ref = file['ref'];
|
||||||
|
if (typeof ref !== 'string' || ref === '') continue;
|
||||||
|
const size = file['size'];
|
||||||
|
out.push({
|
||||||
|
index: out.length,
|
||||||
|
name: typeof file['name'] === 'string' ? file['name'] : '未命名',
|
||||||
|
contentType: typeof file['contentType'] === 'string' ? file['contentType'] : 'application/octet-stream',
|
||||||
|
size: typeof size === 'number' && Number.isFinite(size) ? size : 0,
|
||||||
|
ref,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 产出一句话:有存档文件摆文件名;没有就摆发了哪几口;都没有 = null(前端摆 `-`) */
|
||||||
|
export function outputSummaryOf(archivedNames: readonly string[], firedPorts: readonly string[]): string | null {
|
||||||
|
if (archivedNames.length > 0) return clip(archivedNames.join('、'));
|
||||||
|
if (firedPorts.length > 0) return clip(`已发口:${firedPorts.join('、')}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InstanceQuery = {
|
||||||
|
readonly id?: string | undefined;
|
||||||
|
readonly ownerId?: string | undefined;
|
||||||
|
readonly state?: InstanceState | undefined;
|
||||||
|
readonly startedFrom?: string | undefined;
|
||||||
|
readonly startedTo?: string | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
function dayBound(day: string, end: boolean): number | undefined {
|
||||||
|
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(day);
|
||||||
|
if (m === null) return undefined;
|
||||||
|
const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])];
|
||||||
|
// 服务进程的本地时区:单时区部署;「含两端整日」
|
||||||
|
return end ? new Date(y, mo - 1, d, 23, 59, 59, 999).getTime() : new Date(y, mo - 1, d, 0, 0, 0, 0).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 四条件 AND;`id` 模糊对实例号与任务 id 的子串(不分大小写) */
|
||||||
|
export function applyInstanceFilters(items: readonly InstanceSummary[], q: InstanceQuery): InstanceSummary[] {
|
||||||
|
const needle = q.id?.trim().toLowerCase() ?? '';
|
||||||
|
const from = q.startedFrom === undefined ? undefined : dayBound(q.startedFrom, false);
|
||||||
|
const to = q.startedTo === undefined ? undefined : dayBound(q.startedTo, true);
|
||||||
|
return items.filter((it) => {
|
||||||
|
if (needle !== '' && !it.no.toLowerCase().includes(needle) && !it.id.toLowerCase().includes(needle)) return false;
|
||||||
|
if (q.ownerId !== undefined && q.ownerId !== '' && it.owner.id !== q.ownerId) return false;
|
||||||
|
if (q.state !== undefined && it.state !== q.state) return false;
|
||||||
|
if (from !== undefined || to !== undefined) {
|
||||||
|
const t = Date.parse(it.startedAt);
|
||||||
|
if (!Number.isFinite(t)) return false;
|
||||||
|
if (from !== undefined && t < from) return false;
|
||||||
|
if (to !== undefined && t > to) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function statsOf(items: readonly InstanceSummary[]): InstanceStats {
|
||||||
|
const byState = Object.fromEntries(INSTANCE_STATES.map((s) => [s, 0])) as Record<InstanceState, number>;
|
||||||
|
for (const it of items) byState[it.state] += 1;
|
||||||
|
return { total: items.length, byState };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pageOf<T>(items: readonly T[], page: number, size: number): T[] {
|
||||||
|
const start = (Math.max(1, page) - 1) * Math.max(1, size);
|
||||||
|
return items.slice(start, start + Math.max(1, size));
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* L2 沙箱视图 —— 活动包里名为 `activity-ledger` 的视图模版(关键台账数据看板:最近一次已放行实例的落账快照,
|
||||||
|
* 行由基座按清单 `viewTemplates[].data` 的声明从正式表圈出,见 `view-scope.ts`)。
|
||||||
|
*
|
||||||
|
* 模版本身**无脚本**(安装静态审 `templateIsSafe` 拦着);而 iframe 是 `sandbox="allow-scripts"`
|
||||||
|
* 无同源,父页量不到它的高 —— 报高的脚本由服务端在**出口那一刻**注入,协议只有一条消息:
|
||||||
|
* `postMessage({ type: 'jcai:view-height', height }, '*')`(load + ResizeObserver + 1s 兜底)。
|
||||||
|
*/
|
||||||
|
import { ACTIVITY_VIEW_HEIGHT_MESSAGE } from '@contracts';
|
||||||
|
|
||||||
|
export const LEDGER_TEMPLATE = 'activity-ledger';
|
||||||
|
|
||||||
|
export function heightScript(): string {
|
||||||
|
return (
|
||||||
|
`<script>(function(){var T=${JSON.stringify(ACTIVITY_VIEW_HEIGHT_MESSAGE)};` +
|
||||||
|
'function h(){var b=document.body;return Math.max(document.documentElement.scrollHeight,b?b.scrollHeight:0)}' +
|
||||||
|
"function post(){try{window.parent.postMessage({type:T,height:h()},'*')}catch(e){}}" +
|
||||||
|
"window.addEventListener('load',post);" +
|
||||||
|
"if(typeof ResizeObserver!=='undefined'){new ResizeObserver(post).observe(document.documentElement)}" +
|
||||||
|
'setInterval(post,1000);post()})();</script>'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 插在 `</body>` 之前(没有就追加在末尾);模版原文一个字不改 */
|
||||||
|
export function injectHeightScript(html: string): string {
|
||||||
|
const script = heightScript();
|
||||||
|
const at = html.toLowerCase().lastIndexOf('</body>');
|
||||||
|
return at < 0 ? `${html}\n${script}` : `${html.slice(0, at)}${script}${html.slice(at)}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
/**
|
||||||
|
* 沙箱看板的取数与作用域(零 IO;读表经注入)—— 执行清单 `viewTemplates[].data` 那份声明。
|
||||||
|
*
|
||||||
|
* 分工:模版归活动、「哪几行是这一单的」归活动声明(案号前缀、哪张表按哪一列圈),这里只做两件机械的事:
|
||||||
|
* ① `resolveViewData`:按声明逐条求集合 —— `by.column ∈ values(by.from)`,`from` 只能指 `case`
|
||||||
|
* (案号本身)或前面已求出的 `<as>.<列>`;每个值单独查一次(正式表里历次运行的行并排躺着,
|
||||||
|
* `where` 只有等值),结果按 `from` 值的顺序拼接,再按 `not` 排除;
|
||||||
|
* ② `viewScopeOf`:把行集摊成渲染器要的扁平作用域 —— `<as>` 行数组、`<as>.<列>` 首行标量、
|
||||||
|
* `<as 去符号>_rows` 行数、`<as 去符号>_<列>_<值>` tally 计数(先铺 0,与引擎读数同一套命名习惯)、
|
||||||
|
* `case:*` 实例事实、`board:state` 三态(模版拿它亮横幅 / 藏区块)。
|
||||||
|
*
|
||||||
|
* 表未物化 / 查询失败 → 那个集合为空并记在 `skipped`(看板不因一张表 500);案号那一行查无 → `caseFound=false`
|
||||||
|
* (多半是起单时关了「落真库」,主数据只留在活动缓存),看板如实说「没落账」而不是摆一屏空表装没事。
|
||||||
|
*/
|
||||||
|
import type { ViewData, ViewDataRows } from '@contracts';
|
||||||
|
import { VIEW_DATA_CASE_REF } from '@contracts';
|
||||||
|
|
||||||
|
export type ViewRow = Readonly<Record<string, unknown>>;
|
||||||
|
export type ViewAddress = { readonly domain: string; readonly object: string };
|
||||||
|
export type ViewOrder = ReadonlyArray<{ readonly field: string; readonly dir: 'asc' | 'desc' }>;
|
||||||
|
/** 读一张正式表(等值过滤);表读不了回 undefined —— 由调用方决定怎么说 */
|
||||||
|
export type ViewRead = (address: ViewAddress, where: Readonly<Record<string, string>>, orderBy: ViewOrder) => Promise<readonly ViewRow[] | undefined>;
|
||||||
|
|
||||||
|
export type ViewSets = Readonly<Record<string, readonly ViewRow[]>>;
|
||||||
|
export type ResolvedViewData = { readonly sets: ViewSets; readonly caseFound: boolean; readonly skipped: readonly string[] };
|
||||||
|
|
||||||
|
export type BoardState = 'ready' | 'missing' | 'none';
|
||||||
|
export const BOARD_STATE_KEY = 'board:state';
|
||||||
|
|
||||||
|
export type DeclaredLedger = {
|
||||||
|
readonly domain: string;
|
||||||
|
readonly object: string;
|
||||||
|
readonly fields: ReadonlyArray<{ readonly name: string; readonly tally?: readonly string[] | undefined }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ViewInstance = {
|
||||||
|
readonly no: string;
|
||||||
|
readonly startedAt: string;
|
||||||
|
readonly finishedAt: string | null;
|
||||||
|
readonly owner: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function addressOf(ledger: string): ViewAddress | undefined {
|
||||||
|
const at = ledger.indexOf('.');
|
||||||
|
if (at <= 0 || at === ledger.length - 1) return undefined;
|
||||||
|
return { domain: ledger.slice(0, at), object: ledger.slice(at + 1) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `<as>.<列>` 从最后一个点拆(列名是标识符,不含点;`as` 本身可以带点) */
|
||||||
|
function refOf(from: string): { as: string; column: string } | undefined {
|
||||||
|
const at = from.lastIndexOf('.');
|
||||||
|
if (at <= 0 || at === from.length - 1) return undefined;
|
||||||
|
return { as: from.slice(0, at), column: from.slice(at + 1) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一列的值集:去空、转字符串、去重、保序 */
|
||||||
|
function valuesOf(rows: readonly ViewRow[], column: string): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
const raw = row[column];
|
||||||
|
if (raw === undefined || raw === null || raw === '') continue;
|
||||||
|
const text = String(raw);
|
||||||
|
if (seen.has(text)) continue;
|
||||||
|
seen.add(text);
|
||||||
|
out.push(text);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceValues(from: string, caseRef: string, sets: Record<string, readonly ViewRow[]>): string[] | undefined {
|
||||||
|
if (from === VIEW_DATA_CASE_REF) return [caseRef];
|
||||||
|
const ref = refOf(from);
|
||||||
|
if (ref === undefined) return undefined;
|
||||||
|
const rows = sets[ref.as];
|
||||||
|
return rows === undefined ? undefined : valuesOf(rows, ref.column);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveViewData(spec: ViewData, io: { readonly caseRef: string; readonly read: ViewRead }): Promise<ResolvedViewData> {
|
||||||
|
const sets: Record<string, readonly ViewRow[]> = {};
|
||||||
|
const skipped: string[] = [];
|
||||||
|
const caseAddress = addressOf(spec.case.ledger);
|
||||||
|
const caseRows = caseAddress === undefined ? undefined : await io.read(caseAddress, { [spec.case.key]: io.caseRef }, []);
|
||||||
|
const caseFound = (caseRows?.length ?? 0) > 0;
|
||||||
|
if (caseRows === undefined) skipped.push(spec.case.ledger);
|
||||||
|
for (const entry of spec.rows) {
|
||||||
|
sets[entry.as] = await resolveEntry(entry, io.caseRef, sets, io.read, skipped);
|
||||||
|
}
|
||||||
|
return { sets, caseFound, skipped };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveEntry(entry: ViewDataRows, caseRef: string, sets: Record<string, readonly ViewRow[]>, read: ViewRead, skipped: string[]): Promise<readonly ViewRow[]> {
|
||||||
|
const address = addressOf(entry.ledger);
|
||||||
|
const values = sourceValues(entry.by.from, caseRef, sets);
|
||||||
|
if (address === undefined || values === undefined) {
|
||||||
|
skipped.push(entry.as);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (values.length === 0) return [];
|
||||||
|
const orderBy = entry.orderBy ?? [];
|
||||||
|
const out: ViewRow[] = [];
|
||||||
|
for (const value of values) {
|
||||||
|
const rows = await read(address, { [entry.by.column]: value }, orderBy);
|
||||||
|
if (rows === undefined) {
|
||||||
|
skipped.push(entry.as);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
out.push(...rows);
|
||||||
|
}
|
||||||
|
if (entry.not === undefined) return out;
|
||||||
|
const banned = new Set(sourceValues(entry.not.from, caseRef, sets) ?? []);
|
||||||
|
const notColumn = entry.not.column;
|
||||||
|
return out.filter((row) => {
|
||||||
|
const raw = row[notColumn];
|
||||||
|
return raw === undefined || raw === null || !banned.has(String(raw));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 与引擎读数同一套习惯:非字母数字一律下划线(`sub_eval.verdict` → `sub_eval_verdict`) */
|
||||||
|
export function slugOf(as: string): string {
|
||||||
|
return as.toLowerCase().replace(/[^a-z0-9]+/g, '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 屏上摆的时刻:本地、到分、带年(看板隔着日子看,「08-27 21:51」不够) */
|
||||||
|
export function localMinute(iso: string | null): string {
|
||||||
|
if (iso === null) return '';
|
||||||
|
const at = new Date(iso);
|
||||||
|
if (Number.isNaN(at.getTime())) return '';
|
||||||
|
const two = (n: number): string => String(n).padStart(2, '0');
|
||||||
|
return `${at.getFullYear()}-${two(at.getMonth() + 1)}-${two(at.getDate())} ${two(at.getHours())}:${two(at.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function viewScopeOf(input: {
|
||||||
|
readonly spec: ViewData | undefined;
|
||||||
|
readonly sets: ViewSets;
|
||||||
|
readonly declared: ReadonlyArray<DeclaredLedger>;
|
||||||
|
readonly instance?: ViewInstance | undefined;
|
||||||
|
readonly caseRef?: string | undefined;
|
||||||
|
readonly state: BoardState;
|
||||||
|
}): Record<string, unknown> {
|
||||||
|
const scope: Record<string, unknown> = {
|
||||||
|
[BOARD_STATE_KEY]: input.state,
|
||||||
|
'case:instanceNo': input.instance?.no ?? '',
|
||||||
|
'case:openedAt': input.instance === undefined ? '' : localMinute(input.instance.startedAt),
|
||||||
|
'case:releasedAt': input.instance === undefined ? '' : localMinute(input.instance.finishedAt),
|
||||||
|
'case:owner': input.instance?.owner ?? '',
|
||||||
|
'case:ref': input.caseRef ?? '',
|
||||||
|
};
|
||||||
|
for (const entry of input.spec?.rows ?? []) {
|
||||||
|
const rows = input.sets[entry.as] ?? [];
|
||||||
|
const slug = slugOf(entry.as);
|
||||||
|
scope[entry.as] = rows;
|
||||||
|
scope[`${slug}_rows`] = rows.length;
|
||||||
|
const first = rows[0];
|
||||||
|
if (first !== undefined) for (const [column, value] of Object.entries(first)) scope[`${entry.as}.${column}`] = value;
|
||||||
|
const address = addressOf(entry.ledger);
|
||||||
|
const declaration = address === undefined ? undefined : input.declared.find((d) => d.domain === address.domain && d.object === address.object);
|
||||||
|
for (const field of declaration?.fields ?? []) {
|
||||||
|
if (field.tally === undefined) continue;
|
||||||
|
for (const value of field.tally) scope[`${slug}_${field.name}_${slugOf(value)}`] = 0;
|
||||||
|
for (const row of rows) {
|
||||||
|
const raw = row[field.name];
|
||||||
|
if (raw === undefined || raw === null || raw === '') continue;
|
||||||
|
const key = `${slug}_${field.name}_${slugOf(String(raw))}`;
|
||||||
|
scope[key] = Number(scope[key] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return scope;
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
/**
|
||||||
|
* 运营中心 · 活动模块路由(`docs/API文档-活动模块.md`)。归属在契约的 access 上(全 ops),中间件统一判;
|
||||||
|
* 这里只做 zod 校验、调用用例、把结果翻成状态码。写路由的广播由 `/api/gov/*` 中间件自动发。
|
||||||
|
*/
|
||||||
|
import type { Context, Hono } from 'hono';
|
||||||
|
import type { ApiError } from '@contracts';
|
||||||
|
import {
|
||||||
|
activateOpsActivityRoute,
|
||||||
|
disableOpsActivityRoute,
|
||||||
|
getOpsActivityRoute,
|
||||||
|
getOpsInstanceRoute,
|
||||||
|
launchOpsInstanceRoute,
|
||||||
|
listOpsActivitiesRoute,
|
||||||
|
listOpsInstancesRoute,
|
||||||
|
opsActivityViewRoute,
|
||||||
|
opsInstanceFileRoute,
|
||||||
|
opsLaunchParamsRoute,
|
||||||
|
terminateOpsInstanceRoute,
|
||||||
|
} from '@contracts';
|
||||||
|
import type { AppEnv } from '../http-env.ts';
|
||||||
|
import { staffOf } from '../http-env.ts';
|
||||||
|
import type { ActivityOps } from '../activity-ops/service.ts';
|
||||||
|
|
||||||
|
export type ActivityRouteDeps = { readonly activityOps: ActivityOps };
|
||||||
|
|
||||||
|
function invalid(c: Context<AppEnv>, message: string, detail: unknown): Response {
|
||||||
|
const body: ApiError = { code: 'validation-failed', message, detail };
|
||||||
|
return c.json(body, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
function notFound(c: Context<AppEnv>, message: string): Response {
|
||||||
|
const body: ApiError = { code: 'not-found', message };
|
||||||
|
return c.json(body, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
function precondition(c: Context<AppEnv>, message: string): Response {
|
||||||
|
const body: ApiError = { code: 'precondition-failed', message };
|
||||||
|
return c.json(body, 412);
|
||||||
|
}
|
||||||
|
|
||||||
|
function unauthenticated(c: Context<AppEnv>): Response {
|
||||||
|
const body: ApiError = { code: 'unauthenticated', message: '这一步要知道是谁按的 —— 请求没带身份' };
|
||||||
|
return c.json(body, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function jsonBody(c: Context<AppEnv>): Promise<unknown> {
|
||||||
|
const text = await c.req.text();
|
||||||
|
if (text.trim() === '') return {};
|
||||||
|
try {
|
||||||
|
return JSON.parse(text) as unknown;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerActivityRoutes(app: Hono<AppEnv>, deps: ActivityRouteDeps): void {
|
||||||
|
const { activityOps } = deps;
|
||||||
|
// 通用 Context 上 param 是 string | undefined;路径模版里有这一段,空串只会落成 404
|
||||||
|
const activityId = (c: Context<AppEnv>): string => c.req.param('activityId') ?? '';
|
||||||
|
const instanceId = (c: Context<AppEnv>): string => c.req.param('instanceId') ?? '';
|
||||||
|
|
||||||
|
/* ── L1 ── */
|
||||||
|
|
||||||
|
app.get(listOpsActivitiesRoute.path, async (c) => {
|
||||||
|
const query = listOpsActivitiesRoute.query.safeParse(c.req.query());
|
||||||
|
if (!query.success) return invalid(c, '查询参数不合法', query.error.issues);
|
||||||
|
return c.json({ activities: await activityOps.listActivities(query.data.status) });
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── L2 ── */
|
||||||
|
|
||||||
|
app.get(getOpsActivityRoute.path, async (c) => {
|
||||||
|
const detail = await activityOps.getActivity(activityId(c));
|
||||||
|
return detail === undefined ? notFound(c, `活动不存在:${activityId(c)}`) : c.json(detail);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get(opsActivityViewRoute.path, async (c) => {
|
||||||
|
const view = await activityOps.viewHtml(activityId(c));
|
||||||
|
if (view.kind === 'no-activity') return notFound(c, `活动不存在:${activityId(c)}`);
|
||||||
|
if (view.kind === 'no-view') return notFound(c, '该活动没配视图');
|
||||||
|
return c.html(view.html, 200, { 'cache-control': 'no-store' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get(listOpsInstancesRoute.path, async (c) => {
|
||||||
|
const query = listOpsInstancesRoute.query.safeParse(c.req.query());
|
||||||
|
if (!query.success) return invalid(c, '查询参数不合法', query.error.issues);
|
||||||
|
const list = await activityOps.listInstances(activityId(c), query.data);
|
||||||
|
return list === undefined ? notFound(c, `活动不存在:${activityId(c)}`) : c.json(list);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── 启动 ── */
|
||||||
|
|
||||||
|
app.get(opsLaunchParamsRoute.path, async (c) => {
|
||||||
|
const table = await activityOps.launchParams(activityId(c));
|
||||||
|
return table === undefined ? notFound(c, `活动不存在:${activityId(c)}`) : c.json(table);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post(launchOpsInstanceRoute.path, async (c) => {
|
||||||
|
const parsed = launchOpsInstanceRoute.body.safeParse(await jsonBody(c));
|
||||||
|
if (!parsed.success) return invalid(c, '启动入参不合法', parsed.error.issues);
|
||||||
|
const me = staffOf(c);
|
||||||
|
if (me === undefined) return unauthenticated(c);
|
||||||
|
const outcome = await activityOps.launch(activityId(c), parsed.data, { id: me.id, name: me.name });
|
||||||
|
switch (outcome.kind) {
|
||||||
|
case 'ok':
|
||||||
|
return c.json(outcome.detail);
|
||||||
|
case 'not-found':
|
||||||
|
return notFound(c, `活动不存在:${activityId(c)}`);
|
||||||
|
case 'disabled':
|
||||||
|
return precondition(c, '活动已禁用,先激活再启动');
|
||||||
|
case 'invalid':
|
||||||
|
return invalid(c, `启动参数不合要求:${outcome.issues.map((i) => `${i.key} ${i.message}`).join('; ')}`, outcome.issues);
|
||||||
|
case 'owner-invalid':
|
||||||
|
return invalid(c, outcome.message, [{ key: 'ownerId', message: outcome.message }]);
|
||||||
|
case 'busy': {
|
||||||
|
const body: ApiError = { code: 'conflict', message: outcome.message };
|
||||||
|
return c.json(body, 409);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── L3 ── */
|
||||||
|
|
||||||
|
app.get(getOpsInstanceRoute.path, async (c) => {
|
||||||
|
const detail = await activityOps.instanceDetail(activityId(c), instanceId(c));
|
||||||
|
return detail === undefined ? notFound(c, `实例不存在:${instanceId(c)}`) : c.json(detail);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get(opsInstanceFileRoute.path, async (c) => {
|
||||||
|
const params = opsInstanceFileRoute.params.safeParse({
|
||||||
|
activityId: activityId(c),
|
||||||
|
instanceId: instanceId(c),
|
||||||
|
io: c.req.param('io'),
|
||||||
|
index: c.req.param('index'),
|
||||||
|
});
|
||||||
|
if (!params.success) return invalid(c, '文件序号不合法', params.error.issues);
|
||||||
|
const file = await activityOps.instanceFile(params.data.activityId, params.data.instanceId, params.data.io, params.data.index);
|
||||||
|
if (file.kind === 'no-instance') return notFound(c, `实例不存在:${params.data.instanceId}`);
|
||||||
|
if (file.kind === 'no-file') return notFound(c, '这个文件不在了');
|
||||||
|
if (c.req.query('raw') === '1') {
|
||||||
|
return c.body(file.bytes.slice().buffer as ArrayBuffer, 200, {
|
||||||
|
'content-type': file.contentType,
|
||||||
|
// 文件名走 RFC 5987:中文名在各家浏览器上才落得对
|
||||||
|
'content-disposition': `attachment; filename*=UTF-8''${encodeURIComponent(file.name)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return c.json({ name: file.name, contentType: file.contentType, dataBase64: Buffer.from(file.bytes).toString('base64') });
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── 禁用 / 激活 / 终止 ── */
|
||||||
|
|
||||||
|
const setEnabled = (enabled: boolean) => async (c: Context<AppEnv>) => {
|
||||||
|
const detail = await activityOps.setEnabled(activityId(c), enabled);
|
||||||
|
return detail === undefined ? notFound(c, `活动不存在:${activityId(c)}`) : c.json(detail);
|
||||||
|
};
|
||||||
|
app.post(disableOpsActivityRoute.path, setEnabled(false));
|
||||||
|
app.post(activateOpsActivityRoute.path, setEnabled(true));
|
||||||
|
|
||||||
|
app.post(terminateOpsInstanceRoute.path, async (c) => {
|
||||||
|
const me = staffOf(c);
|
||||||
|
if (me === undefined) return unauthenticated(c);
|
||||||
|
const outcome = await activityOps.terminate(activityId(c), instanceId(c), { id: me.id, name: me.name });
|
||||||
|
if (outcome.kind === 'not-found') return notFound(c, `实例不存在:${instanceId(c)}`);
|
||||||
|
if (outcome.kind === 'terminal') return precondition(c, '实例已终态,不能再终止');
|
||||||
|
return c.json(outcome.detail);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -41,7 +41,11 @@
|
|||||||
- **进化必过治理**:候选 → 回放 → 人放行,状态机不许跳过回放
|
- **进化必过治理**:候选 → 回放 → 人放行,状态机不许跳过回放
|
||||||
- **安装恒为新增、版本并存**:卸载只置 state,在途任务按其启动时版本跑完
|
- **安装恒为新增、版本并存**:卸载只置 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` 逐个读清单组装。此前治理台看不到活动的契约:
|
由 `manifestOf` 逐个读清单组装。此前治理台看不到活动的契约:
|
||||||
编排的人引用一个活动却不知道它吃什么,边映射只能靠猜;
|
编排的人引用一个活动却不知道它吃什么,边映射只能靠猜;
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ export function createRegistryService(deps: RegistryServiceDeps): ActivityRegist
|
|||||||
|
|
||||||
const verdict: InstallVerdict = {
|
const verdict: InstallVerdict = {
|
||||||
accepted: mayTakeEffect(staticOk, replay),
|
accepted: mayTakeEffect(staticOk, replay),
|
||||||
|
stored: false,
|
||||||
staticFindings: findings,
|
staticFindings: findings,
|
||||||
replay: {
|
replay: {
|
||||||
oldPassStillPass: replay.oldPassStillPass,
|
oldPassStillPass: replay.oldPassStillPass,
|
||||||
@@ -90,6 +91,8 @@ export function createRegistryService(deps: RegistryServiceDeps): ActivityRegist
|
|||||||
|
|
||||||
// 安装恒为新增、版本并存;在途任务按其启动时版本跑完
|
// 安装恒为新增、版本并存;在途任务按其启动时版本跑完
|
||||||
const isNewPackageVersion = await deps.store.savePackage(manifest, artifact);
|
const isNewPackageVersion = await deps.store.savePackage(manifest, artifact);
|
||||||
|
// 同版重装 = 什么都没写:如实回给调用方,别让安装脚本再打印「已装」
|
||||||
|
verdict.stored = isNewPackageVersion;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* 资产初始版随包播种。
|
* 资产初始版随包播种。
|
||||||
|
|||||||
@@ -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
@@ -1,102 +1,310 @@
|
|||||||
# API 文档 —— 活动模块(L1 清单 / L2 活动详情 / L3 实例详情)
|
# API 文档 —— 活动模块(L1 清单 / L2 活动详情 / L3 实例详情)
|
||||||
|
|
||||||
> 本文是活动模块前端(运营中心 · 活动区,2026-08-25 重构模版)与服务端之间的接口约定,
|
> 本文是活动模块前端(运营中心 · 活动区,2026-08-25 重构模版)与服务端之间的接口约定,给前端取数、后端维护用。
|
||||||
> 给后端编写接口、前端取数用。鉴权 / 票据 / 错误体 / 端归属通则:`docs/API文档-鉴权与人员.md` §0,本文不重复。
|
> 鉴权 / 票据 / 错误体 / 端归属的**通则**在 `docs/API文档-鉴权与人员.md` §0,本文 §0.1 只摘调用时必须知道的几条。
|
||||||
> 需求出处:`docs/活动模块重构需求-2026-08-25.md`;启动参数口径:`ACT-WORK-05-一键启动入参说明.md`(极简四参)。
|
> 需求出处:`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'`** —— 活动区落在运营中心,与工作流/任务看板同端 |
|
| 前缀 | `/api/gov/activities/*`;**11 条全部 `access: 'ops'`** —— 登录人的 `ends` 里要有 `ops`,否则 403 |
|
||||||
| 时间 | 一律 ISO 8601 UTC 字符串(`2026-08-27T02:25:11.000Z`);**展示格式化是前端的事**(`YYYY-MM-DD HH:mm:ss` 本地时区) |
|
| 请求头 | `Authorization: Bearer <Keycloak access_token>`;写请求 `content-type: application/json` |
|
||||||
| 人员 | 凡出人,都是 `{ "id": "<staffId>", "name": "柴浩" }` 两段 —— 展示用 name,筛选/提交用 id;人员候选名单用既有 `GET /api/gov/staff`,本文不另做 |
|
| 票据 | 只有两条路带不了请求头(`<iframe src>` / `<a href download>`):§2.3 视图、§7 文件。GET 时改带 `?ticket=<票据>`(取自 `GET /api/auth/ticket`,**60 秒有效,用的那一刻再取**) |
|
||||||
| 广播 | 写路由(启动 / 禁用 / 激活 / 终止)成功后广播 `{kind:'system', id:'gov'}` —— 前端按既有套路失效 `['gov',*]` 重取 |
|
| 响应 | 除 §2.3(`text/html`)与 §7 `?raw=1`(文件字节)外,全部 `application/json` |
|
||||||
| 实例状态 | 五态,英文码:`pending`(待执行)/ `running`(进行中)/ `completed`(已完成)/ `rejected`(驳回)/ `failed`(失败);**没有第六态** —— 手动终止归入 `failed`,用 `reason.kind='terminated'` 区分「自己跑挂」(见 §6) |
|
| 时间 | 一律 ISO 8601 UTC 字符串(`2026-08-27T02:25:11.000Z`);**展示格式化是前端的事**(`YYYY-MM-DD HH:mm:ss` 本地时区,用 `shared/at.ts`) |
|
||||||
| 禁用语义 | **任何时刻都可禁用**(2026-08-27 用户重定,废除「有实例不能禁用」):禁用只拦「新启动」—— 已完成的实例是历史留痕,进行中的实例**继续跑完不受影响**;激活即恢复启动能力 |
|
| 人员 | 凡出人,都是 `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`) |
|
| `id` | string | 名册 staffId |
|
||||||
| 404 | `not-found` | 活动 / 实例不存在;实例不属于这个活动 |
|
| `name` | string | 姓名 |
|
||||||
| 412 | `precondition-failed` | 启动:活动已禁用;终止:实例不在 `running` |
|
|
||||||
|
|
||||||
## 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
|
```json
|
||||||
{
|
{
|
||||||
"activities": [
|
"activities": [
|
||||||
{
|
{
|
||||||
"id": "quote-intake",
|
"id": "part-substitution-assessment",
|
||||||
"name": "询价接收与结构化",
|
"name": "ACT-WORK-05 · 差异评估",
|
||||||
"biz": "报价业务",
|
"biz": "需求分析与方案匹配",
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"version": "1.3.0",
|
"version": "1.5.7",
|
||||||
"owner": { "id": "8f3c…", "name": "柴浩" },
|
"owner": null,
|
||||||
"llm": "K3 · quote-intake",
|
"llm": "deepseek-v4-flash",
|
||||||
"instanceCount": 12,
|
"instanceCount": 12,
|
||||||
"createdAt": "2026-08-12T01:00:00.000Z",
|
"createdAt": "2026-08-26T01:00:00.000Z",
|
||||||
"ico": "inbox",
|
"ico": "git-compare",
|
||||||
"hue": "blue"
|
"hue": "teal"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- `ico` / `hue`:展示元数据(语义图标名 / 业务色相 `blue|teal|amber|violet`),创建活动时可配;**缺省时前端兜底**(`inbox` / `blue`),后端可以暂时不存。
|
### 2.2 活动详情 —— `GET /api/gov/activities/:activityId`(`getOpsActivityRoute`)
|
||||||
- `llm`:这个活动绑定的专属 agent 的人读标签;要链到 LLM 实例时再加 `llmId`,本期没有。
|
|
||||||
|
|
||||||
## 2. L2 活动定义
|
**请求**:路径参数 `activityId`(string,必填)。
|
||||||
|
|
||||||
### `GET /api/gov/activities/:activityId` —— access: `ops`
|
**响应 200**:`ActivityDetail`。**404**:活动不存在。
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"id": "quote-intake",
|
"id": "part-substitution-assessment",
|
||||||
"name": "询价接收与结构化",
|
"name": "ACT-WORK-05 · 差异评估",
|
||||||
"biz": "报价业务",
|
"biz": "需求分析与方案匹配",
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"version": "1.3.0",
|
"version": "1.5.7",
|
||||||
"owner": { "id": "8f3c…", "name": "柴浩" },
|
"owner": { "id": "8f3c…", "name": "柴浩" },
|
||||||
"llm": "K3 · quote-intake",
|
"llm": "deepseek-v4-flash",
|
||||||
"createdAt": "2026-08-12T01:00:00.000Z",
|
"createdAt": "2026-08-26T01:00:00.000Z",
|
||||||
"ico": "inbox",
|
"ico": "git-compare",
|
||||||
"hue": "blue",
|
"hue": "teal",
|
||||||
"desc": "活动定义只读 —— 输入与输出在创建时定死,演进走版本迭代;验收在工作台,这里只看状态。",
|
"desc": "对候选料号逐项比较参数、判四态、定推荐等级,审核后落 P-11 替代关系并回写 P-01。",
|
||||||
"view": { "url": "/api/gov/activities/quote-intake/view" }
|
"view": { "url": "/api/gov/activities/part-substitution-assessment/view" }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- `desc`:定义说明,创建时填,只读。
|
### 2.3 活动视图(沙箱)—— `GET /api/gov/activities/:activityId/view`(`opsActivityViewRoute`)
|
||||||
- `view`:沙箱要渲染的活动视图;**为 `null` 时前端藏起整个沙箱区块**(不再摆空 iframe)。
|
|
||||||
|
|
||||||
### `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、尺寸变化、每秒兜底)。父页:
|
||||||
|
|
||||||
| 参数 | 形 | 说明 |
|
```ts
|
||||||
|---|---|---|
|
window.addEventListener('message', (e) => {
|
||||||
| `id` | 字符串 | 实例号**模糊**搜,如 `INS-24` |
|
if (e.source !== iframe.contentWindow) return; // 沙箱帧 origin 是 'null',按 source 认,不按 origin
|
||||||
| `ownerId` | staffId | 精确到人 |
|
if (e.data?.type !== ACTIVITY_VIEW_HEIGHT_MESSAGE) return; // 常量在 @contracts,= 'jcai:view-height'
|
||||||
| `state` | 五态之一 | 精确 |
|
setHeight(Number(e.data.height));
|
||||||
| `startedFrom` / `startedTo` | `YYYY-MM-DD` | 启动日期段,**含两端整日**;可只给一端 |
|
});
|
||||||
| `page` | 整数,从 1 | 默认 1 |
|
```
|
||||||
| `size` | 整数 | 默认 8(与前端单页一致),上限 50 |
|
|
||||||
|
## 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
|
```json
|
||||||
{
|
{
|
||||||
@@ -106,191 +314,231 @@ iframe 带不了 Authorization 头,走票据(`GET /api/auth/ticket`,ADR-012 §6)
|
|||||||
"size": 8,
|
"size": 8,
|
||||||
"items": [
|
"items": [
|
||||||
{
|
{
|
||||||
"id": "INS-2417",
|
"id": "0c9d3a1e-…",
|
||||||
|
"no": "INS-2417",
|
||||||
"state": "running",
|
"state": "running",
|
||||||
"owner": { "id": "8f3c…", "name": "柴浩" },
|
"owner": { "id": "8f3c…", "name": "柴浩" },
|
||||||
"input": "Itelma 车身控制器询价 · 3 项物料",
|
"input": "工业电源项目在用 LM2596S-5.0(TI,12V→5V),原厂 PCN 转 NRND…",
|
||||||
"output": null,
|
"output": null,
|
||||||
"startedAt": "2026-08-25T02:12:00.000Z"
|
"startedAt": "2026-08-25T02:12:00.000Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "INS-2415",
|
"id": "7b21e6c0-…",
|
||||||
|
"no": "INS-2415",
|
||||||
"state": "completed",
|
"state": "completed",
|
||||||
"owner": { "id": "8f3c…", "name": "柴浩" },
|
"owner": { "id": "8f3c…", "name": "柴浩" },
|
||||||
"input": "GAZ 底盘线束询价 · 7 项物料",
|
"input": "…",
|
||||||
"output": "quote-20260824-07.json",
|
"output": "已发口:assessment-released",
|
||||||
"startedAt": "2026-08-24T09:03:00.000Z"
|
"startedAt": "2026-08-24T09:03:00.000Z"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- **`stats` 是活动整体的五态统计,不随检索条件动**(页头统计带摆的是全局数);`total` 是**筛后**条数,分页器按它走。
|
- 页头统计带摆 `stats`(全局),分页器按 `total`(筛后)走;筛中提示「筛出 `total` / 共 `stats.total`」。
|
||||||
- `input` / `output` 都是**一句话摘要**;`output` 为 `null` 前端摆 `-`。排序:启动时间倒序(新的在前)。
|
- 负责人下拉的候选用 `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
|
```json
|
||||||
{
|
{
|
||||||
"activityId": "quote-intake",
|
"activityId": "part-substitution-assessment",
|
||||||
"version": "1.3.0",
|
"version": "1.5.7",
|
||||||
"params": [
|
"params": [
|
||||||
{
|
{
|
||||||
"key": "customer_id",
|
"key": "customerCode",
|
||||||
"label": "客户",
|
"label": "客户",
|
||||||
"type": "combo",
|
"type": "combo",
|
||||||
"required": true,
|
"required": true,
|
||||||
"desc": "台账里有就直接选;是新客户就自己填,启动后派数据工单补档案。",
|
"desc": "台账里有就直接选;是新客户就自己填客户编码,启动后派数据录入卡建档。",
|
||||||
|
"placeholder": "CU-0142",
|
||||||
"options": [
|
"options": [
|
||||||
{ "value": "cust-itelma", "label": "Itelma" },
|
{ "value": "CU-0142", "label": "Itelma" },
|
||||||
{ "value": "cust-gaz", "label": "GAZ" }
|
{ "value": "CU-2039", "label": "青岛新星电子有限公司" }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "request_description",
|
"key": "customerName",
|
||||||
|
"label": "客户名称(仅客户不在库时填)",
|
||||||
|
"type": "text",
|
||||||
|
"desc": "新客户先把名字摆上,启动后建档时让人核。",
|
||||||
|
"placeholder": "深圳恒星电源有限公司"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "requestDescription",
|
||||||
"label": "客户需求",
|
"label": "客户需求",
|
||||||
"type": "textarea",
|
"type": "textarea",
|
||||||
"required": true,
|
"required": true,
|
||||||
"desc": "保留原始需求语义,据此新建需求主记录(V1)。",
|
"desc": "保留原始需求语义,据此新建 P-01 需求主记录(版本 V1)。",
|
||||||
"placeholder": "如:车身控制器询价 · 3 项物料,期望 9 月交期"
|
"placeholder": "工业电源项目在用 LM2596S-5.0(TI,12V→5V),原厂 PCN 转 NRND,客户要求验证替代料"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "original_internal_pn",
|
"key": "originalInternalPn",
|
||||||
"label": "原内部料号",
|
"label": "原内部料号",
|
||||||
"type": "combo",
|
"type": "combo",
|
||||||
"required": true,
|
"required": true,
|
||||||
"desc": "差异评估基准;库里没有该 PN 也可启动,启动后派数据工单补参数。",
|
"desc": "差异评估基准;库里没有该 PN 也可启动,启动后派数据工单补参数。",
|
||||||
"placeholder": "如 PN-880501",
|
"placeholder": "LM2596S-5.0",
|
||||||
"options": [
|
"options": [{ "value": "LM2596S-5.0", "label": "LM2596S-5.0" }]
|
||||||
{ "value": "PN-880101", "label": "PN-880101" }
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "candidate_internal_pn",
|
"key": "candidateInternalPns",
|
||||||
"label": "候选内部料号",
|
"label": "候选内部料号",
|
||||||
"type": "list",
|
"type": "combo",
|
||||||
"required": true,
|
"required": true,
|
||||||
"desc": "被评估物料,一次可传多个、逐个评估 —— 至少 1 个。",
|
"desc": "被评估物料;库里有就直接选,没有就自己填料号,启动后经数据工单创建。",
|
||||||
"placeholder": "如 PN-880412"
|
"placeholder": "XL2596S-5.0",
|
||||||
|
"options": [{ "value": "LM2596S-5.0", "label": "LM2596S-5.0" }, { "value": "LM2596S-ADJ", "label": "LM2596S-ADJ" }]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| 字段 | 说明 |
|
W05 的候选内部料号(2026-08-27 用户定)与原内部料号**同款下拉、只选一颗**:清单里它仍是数组字段,前端按 `type='combo'` 渲染、提交单个字符串,后端包成一元数组交给活动 —— 活动契约不变。
|
||||||
|---|---|
|
|
||||||
| `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` 给字符串数组,其余给字符串)—— 上游触发预填场景用 |
|
|
||||||
|
|
||||||
**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
|
```json
|
||||||
{
|
{
|
||||||
"idempotencyKey": "01J9…",
|
"idempotencyKey": "b5a2d6f0-3e1c-4a7b-9d2e-8f1c0a4b7e93",
|
||||||
|
"ownerId": "8f3c1a2b-6c1e-4b3a-9f0e-2d5c7a1b9e44",
|
||||||
"params": {
|
"params": {
|
||||||
"customer_id": "cust-itelma",
|
"customerCode": "CU-0142",
|
||||||
"request_description": "车身控制器询价 · 3 项物料,期望 9 月交期",
|
"requestDescription": "工业电源项目在用 LM2596S-5.0,原厂 PCN 转 NRND,客户要求验证替代料",
|
||||||
"original_internal_pn": "PN-880101",
|
"originalInternalPn": "LM2596S-5.0",
|
||||||
"candidate_internal_pn": ["PN-880412", "PN-880530"]
|
"candidateInternalPns": "XL2596S-5.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
→ 200 一条实例详情(同 §6)。**同一 `idempotencyKey` 重放返回第一次创建的实例,不起第二个**(前端每开一次抽屉生成一枚;重复点提交、网络重试都靠它挡住)。
|
`candidateInternalPns` 在活动契约里是数组:表单单选传 `"XL2596S-5.0"`,服务端包成 `["XL2596S-5.0"]`;传 `["XL2596S-5.0", "LM2596S-ADJ"]` 也照收。
|
||||||
`params` 的键值形状由 §4 参数表决定:`list` 回字符串数组,其余回字符串;`select` 回 `options` 里某个 `value`;**`combo` 回候选的 `value` 或新增的原样字符串,两种都合法**。
|
|
||||||
412:活动已禁用(先激活再启动)。400:缺必填 / 类型不符 / select 的值不在 options 里(**combo 不适用这条** —— 候选之外的值是合法新增)。
|
|
||||||
|
|
||||||
正常落地初始状态 `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
|
```json
|
||||||
{
|
{
|
||||||
"id": "INS-2417",
|
"id": "0c9d3a1e-…",
|
||||||
"activityId": "quote-intake",
|
"no": "INS-2417",
|
||||||
|
"activityId": "part-substitution-assessment",
|
||||||
"state": "running",
|
"state": "running",
|
||||||
"owner": { "id": "8f3c…", "name": "柴浩" },
|
"owner": { "id": "8f3c…", "name": "柴浩" },
|
||||||
"llm": "K3 · quote-intake",
|
"llm": "deepseek-v4-flash",
|
||||||
"startedAt": "2026-08-25T02:12:00.000Z",
|
"startedAt": "2026-08-25T02:12:00.000Z",
|
||||||
"endedAt": null,
|
"endedAt": null,
|
||||||
"durationMs": null,
|
"durationMs": null,
|
||||||
"reason": null,
|
"reason": null,
|
||||||
"input": {
|
"input": {
|
||||||
"content": "Itelma 车身控制器询价 · 3 项物料",
|
"content": "工业电源项目在用 LM2596S-5.0(TI,12V→5V),原厂 PCN 转 NRND…",
|
||||||
"source": { "kind": "manual", "label": "手动启动 · 柴浩" },
|
"source": { "kind": "manual", "label": "手动启动 · 柴浩" },
|
||||||
"submittedAt": "2026-08-25T02:12:00.000Z",
|
"submittedAt": "2026-08-25T02:12:00.000Z",
|
||||||
"files": [
|
"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": {
|
"output": {
|
||||||
"files": [
|
"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": [
|
"steps": [
|
||||||
{ "seq": 1, "name": "输入接收", "by": "agent", "byName": "K3 · quote-intake", "at": "2026-08-25T02:12:00.000Z", "durationMs": 60000, "res": "done", "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": "要素结构化", "by": "agent", "byName": "K3 · quote-intake", "at": "2026-08-25T02:15:00.000Z", "durationMs": 360000, "res": "done", "note": "抽取客户 / 物料 / 交期等字段,落入结构化模型。" },
|
{ "seq": 2, "name": "数据工单:补建芯片主数据(P-08)", "by": "human", "byName": "柴浩", "at": "2026-08-25T02:13:00.000Z", "durationMs": null, "res": "current", "note": "" },
|
||||||
{ "seq": 3, "name": "定价测算", "by": "agent", "byName": "K3 · quote-intake", "at": "2026-08-25T02:21:00.000Z", "durationMs": null, "res": "current", "note": "按成本模型逐项给出建议价与毛利测算。" },
|
{ "seq": 3, "name": "评估输入与资料补齐确认", "by": "human", "byName": "柴浩", "at": null, "durationMs": null, "res": "pending", "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": "生成报价文件并归档,产出回写台账。" }
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- `endedAt` / `durationMs`:未结束(null)。`durationMs` 毫秒,前端格式化(`27m`)。
|
三种终局的 `reason` 示例:
|
||||||
- **`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` 是空数组(前端摆「暂无产出」)。
|
|
||||||
|
|
||||||
## 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`。
|
## 7. 实例文件下载 —— `GET /api/gov/activities/:activityId/instances/:instanceId/files/:io/:index`(`opsInstanceFileRoute`)
|
||||||
→ `{ "name": "…", "contentType": "…", "dataBase64": "…" }`(与 `GET /api/gov/upload/:id` 同形);可带 `?ticket=` 用 `<a href download>`。
|
|
||||||
**本期可以只回元数据、这条路先 404** —— 前端下载钮当前就是「随接口开放」的置灰态。
|
**请求**
|
||||||
|
|
||||||
|
| 位置 | 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 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. 禁用 / 激活 / 终止
|
## 8. 禁用 / 激活 / 终止
|
||||||
|
|
||||||
### `POST /api/gov/activities/:activityId/disable` —— access: `ops`
|
### 8.1 禁用 —— `POST /api/gov/activities/:activityId/disable`(`disableOpsActivityRoute`)
|
||||||
|
|
||||||
→ 200 活动视图(§2 同形)。**任何时刻都可禁用,没有前置条件**(2026-08-27 用户重定):
|
**请求**:路径参数 `activityId`;**body 空**。
|
||||||
禁用后这个活动不再允许启动(§5 返回 412);**已完成的实例是历史留痕,进行中的实例继续跑完,都不受影响**。
|
**响应 200**:`ActivityDetail`(`status='disabled'`)。**404**:活动不存在。已禁用再禁用 = 幂等 200。
|
||||||
已禁用再禁用 = 幂等 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` 由服务端生成)。
|
**请求**:路径参数 `activityId`、`instanceId`;**body 空**(终止理由本期不填,服务端生成「由 <操作人> 手动终止」)。
|
||||||
→ 200 实例详情(§6 同形,`state='failed'` + `reason.kind='terminated'`)。412:实例不在 `running`(已终态的不能再终止)。
|
**响应 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
|
```ts
|
||||||
type ActivityStatus = 'active' | 'disabled';
|
type ActivityStatus = 'active' | 'disabled';
|
||||||
@@ -298,40 +546,76 @@ type InstanceState = 'pending' | 'running' | 'completed' | 'rejected' | 'failed'
|
|||||||
type StaffRef = { id: string; name: string };
|
type StaffRef = { id: string; name: string };
|
||||||
|
|
||||||
type ActivitySummary = { id: string; name: string; biz: string; status: ActivityStatus; version: 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 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 InstanceStats = { total: number; byState: Record<InstanceState, number> };
|
||||||
type InstanceList = { stats: InstanceStats; total: number; page: number; size: number; items: InstanceSummary[] };
|
type InstanceList = { stats: InstanceStats; total: number; page: number; size: number; items: InstanceSummary[] };
|
||||||
|
|
||||||
type LaunchParamType = 'text' | 'textarea' | 'select' | 'combo' | 'list';
|
type LaunchParamType = 'text' | 'textarea' | 'select' | 'combo' | 'list';
|
||||||
type LaunchParam = { key: string; label: string; type: LaunchParamType; required?: boolean; desc?: string;
|
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 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 InstanceFile = { index: number; name: string; contentType: string; size: number };
|
||||||
type InstanceReason = { kind: 'rejected' | 'failed' | 'terminated'; text: string };
|
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;
|
type InstanceStep = { seq: number; name: string; by: 'agent' | 'human'; byName: string;
|
||||||
at: string | null; durationMs: number | null; res: 'done' | 'fail' | 'current' | 'pending'; note: 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;
|
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[] };
|
output: { files: InstanceFile[] };
|
||||||
steps: InstanceStep[] };
|
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. 前端对接要点(现状 → 接口落地)
|
## 10. 前端对接要点(现状 → 接口落地)
|
||||||
|
|
||||||
|
接口已落地,前端仍是占位数据(对接另起一轮,2026-08-27 用户定)。届时照这张表换:
|
||||||
|
|
||||||
| 现状(占位) | 接口落地后 |
|
| 现状(占位) | 接口落地后 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `DEMO_ACTIVITIES` 静态数组 | §1;L1 页签切换即换 `status` 重取 |
|
| `DEMO_ACTIVITIES` 静态数组 | §2.1;L1 页签切换即换 `status` 重取;`owner` 为 null 摆「—」,`ico`/`hue` 缺省兜底 |
|
||||||
| `demoInstances()` 每活动共用一份 | §3;检索四条件与分页全部上移到查询参数,前端删掉本地过滤/分页 |
|
| `demoInstances()` 每活动共用一份 | §3;检索四条件与分页全部上移到查询参数,前端删掉本地过滤/分页;表格摆 `no`,寻址用 `id` |
|
||||||
| 统计带由前端从全量数组数 | §3 `stats`(服务端给,分页下才数得准) |
|
| 统计带由前端从全量数组数 | §3 `stats`(服务端给,分页下才数得准) |
|
||||||
| `LAUNCH_PARAMS` 占位参数表 | §4;`options` 从纯字符串升级为 `{value,label}` 两段(渲染器小改);控件种类已含 `combo`(选择+新增,前端已实现) |
|
| 中文状态字面量当键 | 五态英文码 + `INSTANCE_STATE_LABEL`,前端一张 `Record<InstanceState, {tone, ico}>` 映射 |
|
||||||
| `onLaunch` 在清单头落假行 | §5;抽屉每开一次生成 `idempotencyKey`,提交走 mutation,成功失效 `['gov']` |
|
| `LAUNCH_PARAMS` 占位参数表 | §4;`options` 从纯字符串升级为 `{value,label}` 两段(渲染器小改);控件种类已含 `combo` |
|
||||||
| 禁用/激活/终止就地改 state | §8;mutation 成功后由服务端重投影(广播 → 失效 → 重取) |
|
| `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'` 为准,前端不再落「失败」占位 |
|
| `demoSteps` / `terminatedSteps` 五步占位 | §6 `steps`;终止归态以 `reason.kind='terminated'` 为准,前端不再落「失败」占位 |
|
||||||
| 沙箱 `src="/sandbox-demo/activity-view.html"` 固定 480 高 | §2 `view.url` + 票据;`view` 为 null 藏起沙箱区;高度 postMessage 随第一版视图定 |
|
| 沙箱 `src="/sandbox-demo/activity-view.html"` 固定 480 高 | §2.3 `view.url` + 票据(装载那一刻取);`view` 为 null 藏起沙箱区;高度听 `jcai:view-height` |
|
||||||
| 输出下载钮置灰 | §7(本期可继续 404,钮保持置灰) |
|
| 输出下载钮置灰 | §7 已落地:`downloadWithTicket(url + '?raw=1')`(从 `desk-task/internal/FileViewer.tsx` 抽到 shared) |
|
||||||
|
| 设计中心活动库 / 测试台的 `useGovActivities` | 路径改名对它们透明(import 的是路由对象),无需改动 |
|
||||||
|
|||||||
+13
-2
@@ -245,8 +245,8 @@ body `{ "ends": ["design"] }`(端名数组,顺序 / 重复不敏感)→ `{ "role
|
|||||||
| `design` | PUT | `/api/gov/approval-templates` |
|
| `design` | PUT | `/api/gov/approval-templates` |
|
||||||
| `design` | DELETE | `/api/gov/approval-templates/:id` |
|
| `design` | DELETE | `/api/gov/approval-templates/:id` |
|
||||||
| `design` | PUT | `/api/gov/triggers`(定义归设计) |
|
| `design` | PUT | `/api/gov/triggers`(定义归设计) |
|
||||||
| `design` | GET | `/api/gov/activities` |
|
| `design` | GET | `/api/gov/activity-packages`(活动库:包版本 / 资产版本;2026-08-27 从 `/api/gov/activities` 改名让位) |
|
||||||
| `design` | POST | `/api/gov/activities` |
|
| `design` | POST | `/api/gov/activity-packages`(装入活动包;同上改名) |
|
||||||
| `design` | GET | `/api/gov/tools` |
|
| `design` | GET | `/api/gov/tools` |
|
||||||
| `design` | POST | `/api/gov/tools` |
|
| `design` | POST | `/api/gov/tools` |
|
||||||
| `design` | GET | `/api/gov/memory/:activityId` |
|
| `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/attachments/:index`(可用票据) |
|
||||||
| `ops` | GET | `/api/gov/mail/inbound/:mailId/raw`(可用票据) |
|
| `ops` | GET | `/api/gov/mail/inbound/:mailId/raw`(可用票据) |
|
||||||
| `ops` | GET | `/api/gov/mail/outbound` |
|
| `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/staff`(测试台选负责人) |
|
||||||
| `design\|ops` | GET | `/api/gov/process` |
|
| `design\|ops` | GET | `/api/gov/process` |
|
||||||
| `design\|ops` | GET | `/api/gov/triggers` |
|
| `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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>;
|
||||||
@@ -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,
|
||||||
|
];
|
||||||
@@ -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 = {
|
export const installActivityRoute = {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/api/gov/activities',
|
path: '/api/gov/activity-packages',
|
||||||
access: 'design',
|
access: 'design',
|
||||||
body: z.object({
|
body: z.object({
|
||||||
/** 清单六区:身份 / 七件契约 / 声明 / 资产初始版 / 样例集 / 代码入口 */
|
/** 清单六区:身份 / 七件契约 / 声明 / 资产初始版 / 样例集 / 代码入口 */
|
||||||
@@ -606,9 +608,10 @@ export const registerToolRoute = {
|
|||||||
|
|
||||||
/* ── 活动库管理:两维分类 × 包版本 × 资产版本 ─────────────────────────── */
|
/* ── 活动库管理:两维分类 × 包版本 × 资产版本 ─────────────────────────── */
|
||||||
|
|
||||||
|
/** 路径同 `installActivityRoute` 的说明:`/api/gov/activity-packages`(2026-08-27 让位给运营端活动模块) */
|
||||||
export const listActivitiesRoute = {
|
export const listActivitiesRoute = {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: '/api/gov/activities',
|
path: '/api/gov/activity-packages',
|
||||||
access: 'design',
|
access: 'design',
|
||||||
output: z.object({
|
output: z.object({
|
||||||
activities: z.array(
|
activities: z.array(
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ export * from './governance.ts';
|
|||||||
export * from './org-directory.ts';
|
export * from './org-directory.ts';
|
||||||
export * from './ingest.ts';
|
export * from './ingest.ts';
|
||||||
export * from './mail.ts';
|
export * from './mail.ts';
|
||||||
|
export * from './activity-ops.ts';
|
||||||
|
|||||||
@@ -15,4 +15,5 @@ export * from './process-package.ts';
|
|||||||
export * from './manifest.ts';
|
export * from './manifest.ts';
|
||||||
export * from './org.ts';
|
export * from './org.ts';
|
||||||
export * from './mail.ts';
|
export * from './mail.ts';
|
||||||
|
export * from './activity-ops.ts';
|
||||||
export * from './api/index.ts';
|
export * from './api/index.ts';
|
||||||
|
|||||||
Reference in New Issue
Block a user