From 0923fbfe4db56e49fa037a9094f558bf82148722 Mon Sep 17 00:00:00 2001 From: xcs Date: Thu, 27 Aug 2026 23:20:15 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(ops):=20=E8=BF=90=E8=90=A5=E4=B8=AD?= =?UTF-8?q?=E5=BF=83=20=C2=B7=20=E6=B4=BB=E5=8A=A8=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E8=90=BD=E5=9C=B0=20=E2=80=94=E2=80=94=20L1/?= =?UTF-8?q?L2/L3=20=E5=8D=81=E4=B8=80=E6=9D=A1(=E5=85=A8=20ops)+=20?= =?UTF-8?q?=E8=AE=BE=E8=AE=A1=E7=AB=AF=E6=B4=BB=E5=8A=A8=E5=BA=93=E4=B8=A4?= =?UTF-8?q?=E6=9D=A1=E6=94=B9=E5=90=8D=E8=AE=A9=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 Claude-Session: https://claude.ai/code/session_0167qWjeyBwvfdGovd7FWbCu --- .../src/__tests__/activity-ops.test.d.ts | 1 + .../packages/contracts/src/activity-ops.d.ts | 353 +++++++++++ .../contracts/src/api/activity-ops.d.ts | 522 +++++++++++++++ .../activity-ops-instance-state.test.ts | 52 ++ .../activity-ops-launch-params.test.ts | 210 ++++++ .../__tests__/activity-ops-service.test.ts | 528 +++++++++++++++ .../app/__tests__/activity-ops-steps.test.ts | 86 +++ .../__tests__/activity-ops-store.it.test.ts | 93 +++ .../app/__tests__/activity-ops-store.test.ts | 124 ++++ .../__tests__/activity-ops-summaries.test.ts | 76 +++ .../__tests__/activity-ops-view-html.test.ts | 18 + .../__tests__/activity-ops-view-scope.test.ts | 181 ++++++ .../activity-routes-registry.test.ts | 26 + .../src/app/__tests__/activity-routes.test.ts | 150 +++++ .../src/app/activity-ops/instance-state.ts | 99 +++ .../src/app/activity-ops/instance-steps.ts | 101 +++ .../api/src/app/activity-ops/launch-params.ts | 258 ++++++++ apps/api/src/app/activity-ops/service.ts | 560 ++++++++++++++++ apps/api/src/app/activity-ops/store.ts | 237 +++++++ apps/api/src/app/activity-ops/summaries.ts | 114 ++++ apps/api/src/app/activity-ops/view-html.ts | 29 + apps/api/src/app/activity-ops/view-scope.ts | 170 +++++ apps/api/src/app/routes/activities.ts | 167 +++++ .../src/modules/activity-registry/CONTRACT.md | 6 +- .../internal/usecase/registry-service.ts | 3 + db/migrations/0035_activity_ops.sql | 22 + docs/API文档-活动模块.md | 600 +++++++++++++----- docs/API文档-鉴权与人员.md | 15 +- .../src/__tests__/activity-ops.test.ts | 115 ++++ packages/contracts/src/activity-ops.ts | 209 ++++++ packages/contracts/src/api/activity-ops.ts | 166 +++++ packages/contracts/src/api/governance.ts | 7 +- packages/contracts/src/api/index.ts | 1 + packages/contracts/src/index.ts | 1 + 34 files changed, 5137 insertions(+), 163 deletions(-) create mode 100644 api-report/packages/contracts/src/__tests__/activity-ops.test.d.ts create mode 100644 api-report/packages/contracts/src/activity-ops.d.ts create mode 100644 api-report/packages/contracts/src/api/activity-ops.d.ts create mode 100644 apps/api/src/app/__tests__/activity-ops-instance-state.test.ts create mode 100644 apps/api/src/app/__tests__/activity-ops-launch-params.test.ts create mode 100644 apps/api/src/app/__tests__/activity-ops-service.test.ts create mode 100644 apps/api/src/app/__tests__/activity-ops-steps.test.ts create mode 100644 apps/api/src/app/__tests__/activity-ops-store.it.test.ts create mode 100644 apps/api/src/app/__tests__/activity-ops-store.test.ts create mode 100644 apps/api/src/app/__tests__/activity-ops-summaries.test.ts create mode 100644 apps/api/src/app/__tests__/activity-ops-view-html.test.ts create mode 100644 apps/api/src/app/__tests__/activity-ops-view-scope.test.ts create mode 100644 apps/api/src/app/__tests__/activity-routes-registry.test.ts create mode 100644 apps/api/src/app/__tests__/activity-routes.test.ts create mode 100644 apps/api/src/app/activity-ops/instance-state.ts create mode 100644 apps/api/src/app/activity-ops/instance-steps.ts create mode 100644 apps/api/src/app/activity-ops/launch-params.ts create mode 100644 apps/api/src/app/activity-ops/service.ts create mode 100644 apps/api/src/app/activity-ops/store.ts create mode 100644 apps/api/src/app/activity-ops/summaries.ts create mode 100644 apps/api/src/app/activity-ops/view-html.ts create mode 100644 apps/api/src/app/activity-ops/view-scope.ts create mode 100644 apps/api/src/app/routes/activities.ts create mode 100644 db/migrations/0035_activity_ops.sql create mode 100644 packages/contracts/src/__tests__/activity-ops.test.ts create mode 100644 packages/contracts/src/activity-ops.ts create mode 100644 packages/contracts/src/api/activity-ops.ts diff --git a/api-report/packages/contracts/src/__tests__/activity-ops.test.d.ts b/api-report/packages/contracts/src/__tests__/activity-ops.test.d.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/api-report/packages/contracts/src/__tests__/activity-ops.test.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/api-report/packages/contracts/src/activity-ops.d.ts b/api-report/packages/contracts/src/activity-ops.d.ts new file mode 100644 index 0000000..0a00c6c --- /dev/null +++ b/api-report/packages/contracts/src/activity-ops.d.ts @@ -0,0 +1,353 @@ +/** + * 运营中心 · 活动模块的契约(L1 活动清单 / L2 活动详情 / L3 实例详情;`docs/API文档-活动模块.md`)。 + * + * 只有 zod schema 与推导类型。两个概念(2026-08-25 用户定): + * · **活动** = 装进来的活动包在运营面上的投影 —— 名称 / 分类 / 版本 / 模型来自清单, + * 负责人 / 说明 / 图标 / 色相来自运营侧档案(`sys.activity_profile`,活动包本身不载这些); + * · **实例** = 这个活动的一次运行(一张 task)—— 五态是对 task 运行态 / 终态的**如实映射**, + * 不是第六套状态机(见 `INSTANCE_STATE_LABEL` 旁的口径)。 + */ +import { z } from 'zod'; +/** 运营态:激活中 / 已禁用 —— 禁用只拦「新启动」,已完成与在途实例都不受影响(2026-08-27 用户定) */ +export declare const ACTIVITY_STATUSES: readonly ["active", "disabled"]; +export declare const activityStatusSchema: z.ZodEnum<{ + active: "active"; + disabled: "disabled"; +}>; +export type ActivityStatus = z.infer; +export declare const ACTIVITY_STATUS_LABEL: Readonly>; +/** 业务色相 —— 一卡一色;缺省由前端兜底(`blue`) */ +export declare const ACTIVITY_HUES: readonly ["blue", "teal", "amber", "violet"]; +export declare const activityHueSchema: z.ZodEnum<{ + blue: "blue"; + teal: "teal"; + amber: "amber"; + violet: "violet"; +}>; +export type ActivityHue = z.infer; +/** 凡出人都是两段:展示用 name,筛选 / 提交用 id */ +export declare const staffRefSchema: z.ZodObject<{ + id: z.core.$ZodBranded; + name: z.ZodString; +}, z.core.$strip>; +export type StaffRef = z.infer; +export declare const activitySummarySchema: z.ZodObject<{ + id: z.ZodString; + name: z.ZodString; + biz: z.ZodString; + status: z.ZodEnum<{ + active: "active"; + disabled: "disabled"; + }>; + version: z.ZodString; + owner: z.ZodNullable; + name: z.ZodString; + }, z.core.$strip>>; + llm: z.ZodString; + instanceCount: z.ZodNumber; + createdAt: z.ZodString; + ico: z.ZodOptional; + hue: z.ZodOptional>; +}, z.core.$strip>; +export type ActivitySummary = z.infer; +export declare const activityDetailSchema: z.ZodObject<{ + name: z.ZodString; + id: z.ZodString; + version: z.ZodString; + status: z.ZodEnum<{ + active: "active"; + disabled: "disabled"; + }>; + createdAt: z.ZodString; + biz: z.ZodString; + owner: z.ZodNullable; + name: z.ZodString; + }, z.core.$strip>>; + llm: z.ZodString; + ico: z.ZodOptional; + hue: z.ZodOptional>; + desc: z.ZodString; + view: z.ZodNullable>; +}, z.core.$strip>; +export type ActivityDetail = z.infer; +/** + * 沙箱视图向父页报高的消息类型(postMessage 载荷 `{ type, height }`)。 + * 视图模版本身**无脚本**(安装静态审拦着);报高脚本由服务端在出口那一刻注入。 + */ +export declare const ACTIVITY_VIEW_HEIGHT_MESSAGE = "jcai:view-height"; +/** + * 实例五态(2026-08-25 用户定),对 task 的如实映射: + * pending 已建单、推进器还没领到(`used.steps = 0`) + * running 在途(含等人回传 / 监听中 / 挂起 / 落地受阻) + * completed 终态 released + * rejected 验收被负责人打回、模型还没重新提交的那一段(重新提交后回 running) + * failed 终态 blocked / stalled / exhausted,以及手动终止(靠 `reason.kind='terminated'` 区分) + */ +export declare const INSTANCE_STATES: readonly ["pending", "running", "completed", "rejected", "failed"]; +export declare const instanceStateSchema: z.ZodEnum<{ + running: "running"; + completed: "completed"; + pending: "pending"; + failed: "failed"; + rejected: "rejected"; +}>; +export type InstanceState = z.infer; +export declare const INSTANCE_STATE_LABEL: Readonly>; +export declare const instanceSummarySchema: z.ZodObject<{ + id: z.ZodString; + no: z.ZodString; + state: z.ZodEnum<{ + running: "running"; + completed: "completed"; + pending: "pending"; + failed: "failed"; + rejected: "rejected"; + }>; + owner: z.ZodObject<{ + id: z.core.$ZodBranded; + name: z.ZodString; + }, z.core.$strip>; + input: z.ZodString; + output: z.ZodNullable; + startedAt: z.ZodString; +}, z.core.$strip>; +export type InstanceSummary = z.infer; +export declare const instanceStatsSchema: z.ZodObject<{ + total: z.ZodNumber; + byState: z.ZodRecord, z.ZodNumber>; +}, z.core.$strip>; +export type InstanceStats = z.infer; +export declare const instanceListSchema: z.ZodObject<{ + stats: z.ZodObject<{ + total: z.ZodNumber; + byState: z.ZodRecord, z.ZodNumber>; + }, z.core.$strip>; + total: z.ZodNumber; + page: z.ZodNumber; + size: z.ZodNumber; + items: z.ZodArray; + owner: z.ZodObject<{ + id: z.core.$ZodBranded; + name: z.ZodString; + }, z.core.$strip>; + input: z.ZodString; + output: z.ZodNullable; + startedAt: z.ZodString; + }, z.core.$strip>>; +}, z.core.$strip>; +export type InstanceList = z.infer; +export declare const instanceFileSchema: z.ZodObject<{ + index: z.ZodNumber; + name: z.ZodString; + contentType: z.ZodString; + size: z.ZodNumber; +}, z.core.$strip>; +export type InstanceFile = z.infer; +export declare const INSTANCE_REASON_KINDS: readonly ["rejected", "failed", "terminated"]; +export declare const instanceReasonSchema: z.ZodObject<{ + kind: z.ZodEnum<{ + terminated: "terminated"; + failed: "failed"; + rejected: "rejected"; + }>; + text: z.ZodString; +}, z.core.$strip>; +export type InstanceReason = z.infer; +export declare const STEP_RESULTS: readonly ["done", "fail", "current", "pending"]; +export declare const instanceStepSchema: z.ZodObject<{ + seq: z.ZodNumber; + name: z.ZodString; + by: z.ZodEnum<{ + human: "human"; + agent: "agent"; + }>; + byName: z.ZodString; + at: z.ZodNullable; + durationMs: z.ZodNullable; + res: z.ZodEnum<{ + pending: "pending"; + done: "done"; + fail: "fail"; + current: "current"; + }>; + note: z.ZodString; +}, z.core.$strip>; +export type InstanceStep = z.infer; +/** 起单来源:manual(运营端手动启动)/ upstream(工作流节点)/ mail 等通道名 / testbed(测试台试跑)/ system */ +export declare const instanceSourceSchema: z.ZodObject<{ + kind: z.ZodString; + label: z.ZodString; +}, z.core.$strip>; +export type InstanceSource = z.infer; +export declare const instanceDetailSchema: z.ZodObject<{ + id: z.ZodString; + state: z.ZodEnum<{ + running: "running"; + completed: "completed"; + pending: "pending"; + failed: "failed"; + rejected: "rejected"; + }>; + startedAt: z.ZodString; + owner: z.ZodObject<{ + id: z.core.$ZodBranded; + name: z.ZodString; + }, z.core.$strip>; + no: z.ZodString; + activityId: z.ZodString; + llm: z.ZodString; + endedAt: z.ZodNullable; + durationMs: z.ZodNullable; + reason: z.ZodNullable; + text: z.ZodString; + }, z.core.$strip>>; + input: z.ZodObject<{ + content: z.ZodString; + source: z.ZodObject<{ + kind: z.ZodString; + label: z.ZodString; + }, z.core.$strip>; + submittedAt: z.ZodString; + files: z.ZodArray>; + }, z.core.$strip>; + output: z.ZodObject<{ + files: z.ZodArray>; + }, z.core.$strip>; + steps: z.ZodArray; + byName: z.ZodString; + at: z.ZodNullable; + durationMs: z.ZodNullable; + res: z.ZodEnum<{ + pending: "pending"; + done: "done"; + fail: "fail"; + current: "current"; + }>; + note: z.ZodString; + }, z.core.$strip>>; +}, z.core.$strip>; +export type InstanceDetail = z.infer; +/** + * 控件种类:text 单行 / textarea 多行 / select 单选(只能选候选)/ combo 选择 + 新增 / list 字符串数组。 + * 新增种类前后端一起升级;前端遇到不认识的 type 摆「暂不支持的控件」。 + */ +export declare const LAUNCH_PARAM_TYPES: readonly ["text", "textarea", "select", "combo", "list"]; +export declare const launchParamTypeSchema: z.ZodEnum<{ + text: "text"; + textarea: "textarea"; + select: "select"; + combo: "combo"; + list: "list"; +}>; +export type LaunchParamType = z.infer; +export declare const launchOptionSchema: z.ZodObject<{ + value: z.ZodString; + label: z.ZodString; +}, z.core.$strip>; +export type LaunchOption = z.infer; +export declare const launchParamSchema: z.ZodObject<{ + key: z.ZodString; + label: z.ZodString; + type: z.ZodEnum<{ + text: "text"; + textarea: "textarea"; + select: "select"; + combo: "combo"; + list: "list"; + }>; + required: z.ZodOptional; + desc: z.ZodOptional; + placeholder: z.ZodOptional; + options: z.ZodOptional>>; + default: z.ZodOptional]>>; +}, z.core.$strip>; +export type LaunchParam = z.infer; +export declare const launchParamsSchema: z.ZodObject<{ + activityId: z.ZodString; + version: z.ZodString; + params: z.ZodArray; + required: z.ZodOptional; + desc: z.ZodOptional; + placeholder: z.ZodOptional; + options: z.ZodOptional>>; + default: z.ZodOptional]>>; + }, z.core.$strip>>; +}, z.core.$strip>; +export type LaunchParams = z.infer; +export declare const launchInputSchema: z.ZodObject<{ + idempotencyKey: z.ZodString; + ownerId: z.core.$ZodBranded; + params: z.ZodRecord]>>; +}, z.core.$strip>; +export type LaunchInput = z.infer; diff --git a/api-report/packages/contracts/src/api/activity-ops.d.ts b/api-report/packages/contracts/src/api/activity-ops.d.ts new file mode 100644 index 0000000..739653c --- /dev/null +++ b/api-report/packages/contracts/src/api/activity-ops.d.ts @@ -0,0 +1,522 @@ +/** + * 运营中心 · 活动模块的路由契约(`docs/API文档-活动模块.md`)。 + * + * 全部 `access: 'ops'` —— 活动区落在运营中心,与工作流 / 任务看板同端。 + * `/api/gov/*` 的写路由由 app 中间件自动广播 `{kind:'system', id:'gov'}`。 + * 设计端的活动库(包版本 / 资产版本 / 装入)在 `/api/gov/activity-packages`(governance.ts), + * `/api/gov/activities/*` 整个归运营端(2026-08-27 用户定)。 + */ +import { z } from 'zod'; +import type { RouteContract } from './http.ts'; +export declare const listOpsActivitiesRoute: { + readonly method: "GET"; + readonly path: "/api/gov/activities"; + readonly access: "ops"; + /** 不带 status = 全量;量小不分页 */ + readonly query: z.ZodObject<{ + status: z.ZodOptional>; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + activities: z.ZodArray; + version: z.ZodString; + owner: z.ZodNullable; + name: z.ZodString; + }, z.core.$strip>>; + llm: z.ZodString; + instanceCount: z.ZodNumber; + createdAt: z.ZodString; + ico: z.ZodOptional; + hue: z.ZodOptional>; + }, z.core.$strip>>; + }, z.core.$strip>; +}; +export declare const getOpsActivityRoute: { + readonly method: "GET"; + readonly path: "/api/gov/activities/:activityId"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + activityId: z.ZodString; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + name: z.ZodString; + id: z.ZodString; + version: z.ZodString; + status: z.ZodEnum<{ + active: "active"; + disabled: "disabled"; + }>; + createdAt: z.ZodString; + biz: z.ZodString; + owner: z.ZodNullable; + name: z.ZodString; + }, z.core.$strip>>; + llm: z.ZodString; + ico: z.ZodOptional; + hue: z.ZodOptional>; + desc: z.ZodString; + view: z.ZodNullable>; + }, z.core.$strip>; +}; +/** + * 沙箱要渲染的活动视图 —— 处理器回 `text/html`(不是 JSON),`output` 只是形式上的字符串。 + * iframe 带不了 Authorization 头,走票据 `?ticket=`(GET,中间件已认)。404 = 该活动没配视图。 + */ +export declare const opsActivityViewRoute: { + readonly method: "GET"; + readonly path: "/api/gov/activities/:activityId/view"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + activityId: z.ZodString; + }, z.core.$strip>; + readonly output: z.ZodString; +}; +export declare const listOpsInstancesRoute: { + readonly method: "GET"; + readonly path: "/api/gov/activities/:activityId/instances"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + activityId: z.ZodString; + }, z.core.$strip>; + readonly query: z.ZodObject<{ + id: z.ZodOptional; + ownerId: z.ZodOptional; + state: z.ZodOptional>; + startedFrom: z.ZodOptional; + startedTo: z.ZodOptional; + page: z.ZodDefault>; + size: z.ZodDefault>; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + stats: z.ZodObject<{ + total: z.ZodNumber; + byState: z.ZodRecord, z.ZodNumber>; + }, z.core.$strip>; + total: z.ZodNumber; + page: z.ZodNumber; + size: z.ZodNumber; + items: z.ZodArray; + owner: z.ZodObject<{ + id: z.core.$ZodBranded; + name: z.ZodString; + }, z.core.$strip>; + input: z.ZodString; + output: z.ZodNullable; + startedAt: z.ZodString; + }, z.core.$strip>>; + }, z.core.$strip>; +}; +export declare const opsLaunchParamsRoute: { + readonly method: "GET"; + readonly path: "/api/gov/activities/:activityId/launch-params"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + activityId: z.ZodString; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + activityId: z.ZodString; + version: z.ZodString; + params: z.ZodArray; + required: z.ZodOptional; + desc: z.ZodOptional; + placeholder: z.ZodOptional; + options: z.ZodOptional>>; + default: z.ZodOptional]>>; + }, z.core.$strip>>; + }, z.core.$strip>; +}; +/** + * 启动活动 = 创建实例。同一 `idempotencyKey` 重放返回第一次创建的实例; + * 412 活动已禁用;400 缺必填 / 类型不符 / select 值不在候选 / 责任人不在职;409 同键正在创建中。 + */ +export declare const launchOpsInstanceRoute: { + readonly method: "POST"; + readonly path: "/api/gov/activities/:activityId/instances"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + activityId: z.ZodString; + }, z.core.$strip>; + readonly body: z.ZodObject<{ + idempotencyKey: z.ZodString; + ownerId: z.core.$ZodBranded; + params: z.ZodRecord]>>; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + id: z.ZodString; + state: z.ZodEnum<{ + running: "running"; + completed: "completed"; + pending: "pending"; + failed: "failed"; + rejected: "rejected"; + }>; + startedAt: z.ZodString; + owner: z.ZodObject<{ + id: z.core.$ZodBranded; + name: z.ZodString; + }, z.core.$strip>; + no: z.ZodString; + activityId: z.ZodString; + llm: z.ZodString; + endedAt: z.ZodNullable; + durationMs: z.ZodNullable; + reason: z.ZodNullable; + text: z.ZodString; + }, z.core.$strip>>; + input: z.ZodObject<{ + content: z.ZodString; + source: z.ZodObject<{ + kind: z.ZodString; + label: z.ZodString; + }, z.core.$strip>; + submittedAt: z.ZodString; + files: z.ZodArray>; + }, z.core.$strip>; + output: z.ZodObject<{ + files: z.ZodArray>; + }, z.core.$strip>; + steps: z.ZodArray; + byName: z.ZodString; + at: z.ZodNullable; + durationMs: z.ZodNullable; + res: z.ZodEnum<{ + pending: "pending"; + done: "done"; + fail: "fail"; + current: "current"; + }>; + note: z.ZodString; + }, z.core.$strip>>; + }, z.core.$strip>; +}; +export declare const getOpsInstanceRoute: { + readonly method: "GET"; + readonly path: "/api/gov/activities/:activityId/instances/:instanceId"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + activityId: z.ZodString; + instanceId: z.ZodString; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + id: z.ZodString; + state: z.ZodEnum<{ + running: "running"; + completed: "completed"; + pending: "pending"; + failed: "failed"; + rejected: "rejected"; + }>; + startedAt: z.ZodString; + owner: z.ZodObject<{ + id: z.core.$ZodBranded; + name: z.ZodString; + }, z.core.$strip>; + no: z.ZodString; + activityId: z.ZodString; + llm: z.ZodString; + endedAt: z.ZodNullable; + durationMs: z.ZodNullable; + reason: z.ZodNullable; + text: z.ZodString; + }, z.core.$strip>>; + input: z.ZodObject<{ + content: z.ZodString; + source: z.ZodObject<{ + kind: z.ZodString; + label: z.ZodString; + }, z.core.$strip>; + submittedAt: z.ZodString; + files: z.ZodArray>; + }, z.core.$strip>; + output: z.ZodObject<{ + files: z.ZodArray>; + }, z.core.$strip>; + steps: z.ZodArray; + byName: z.ZodString; + at: z.ZodNullable; + durationMs: z.ZodNullable; + res: z.ZodEnum<{ + pending: "pending"; + done: "done"; + fail: "fail"; + current: "current"; + }>; + note: z.ZodString; + }, z.core.$strip>>; + }, z.core.$strip>; +}; +/** + * 实例文件:默认 JSON + base64(与 `GET /api/gov/upload/:id` 同形);`?raw=1` 直出字节 + * (RFC 5987 文件名),配 `?ticket=` 给 `` 用。 + */ +export declare const opsInstanceFileRoute: { + readonly method: "GET"; + readonly path: "/api/gov/activities/:activityId/instances/:instanceId/files/:io/:index"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + activityId: z.ZodString; + instanceId: z.ZodString; + io: z.ZodEnum<{ + output: "output"; + input: "input"; + }>; + index: z.ZodCoercedNumber; + }, z.core.$strip>; + readonly query: z.ZodObject<{ + raw: z.ZodOptional; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + name: z.ZodString; + contentType: z.ZodString; + dataBase64: z.ZodString; + }, z.core.$strip>; +}; +/** 任何时刻都可禁用:只拦新启动,在途实例继续跑完;已禁用再禁用 = 幂等 200 */ +export declare const disableOpsActivityRoute: { + readonly method: "POST"; + readonly path: "/api/gov/activities/:activityId/disable"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + activityId: z.ZodString; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + name: z.ZodString; + id: z.ZodString; + version: z.ZodString; + status: z.ZodEnum<{ + active: "active"; + disabled: "disabled"; + }>; + createdAt: z.ZodString; + biz: z.ZodString; + owner: z.ZodNullable; + name: z.ZodString; + }, z.core.$strip>>; + llm: z.ZodString; + ico: z.ZodOptional; + hue: z.ZodOptional>; + desc: z.ZodString; + view: z.ZodNullable>; + }, z.core.$strip>; +}; +export declare const activateOpsActivityRoute: { + readonly method: "POST"; + readonly path: "/api/gov/activities/:activityId/activate"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + activityId: z.ZodString; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + name: z.ZodString; + id: z.ZodString; + version: z.ZodString; + status: z.ZodEnum<{ + active: "active"; + disabled: "disabled"; + }>; + createdAt: z.ZodString; + biz: z.ZodString; + owner: z.ZodNullable; + name: z.ZodString; + }, z.core.$strip>>; + llm: z.ZodString; + ico: z.ZodOptional; + hue: z.ZodOptional>; + desc: z.ZodString; + view: z.ZodNullable>; + }, z.core.$strip>; +}; +/** body 空;→ `state='failed'` + `reason.kind='terminated'`;412 实例已终态 */ +export declare const terminateOpsInstanceRoute: { + readonly method: "POST"; + readonly path: "/api/gov/activities/:activityId/instances/:instanceId/terminate"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + activityId: z.ZodString; + instanceId: z.ZodString; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + id: z.ZodString; + state: z.ZodEnum<{ + running: "running"; + completed: "completed"; + pending: "pending"; + failed: "failed"; + rejected: "rejected"; + }>; + startedAt: z.ZodString; + owner: z.ZodObject<{ + id: z.core.$ZodBranded; + name: z.ZodString; + }, z.core.$strip>; + no: z.ZodString; + activityId: z.ZodString; + llm: z.ZodString; + endedAt: z.ZodNullable; + durationMs: z.ZodNullable; + reason: z.ZodNullable; + text: z.ZodString; + }, z.core.$strip>>; + input: z.ZodObject<{ + content: z.ZodString; + source: z.ZodObject<{ + kind: z.ZodString; + label: z.ZodString; + }, z.core.$strip>; + submittedAt: z.ZodString; + files: z.ZodArray>; + }, z.core.$strip>; + output: z.ZodObject<{ + files: z.ZodArray>; + }, z.core.$strip>; + steps: z.ZodArray; + byName: z.ZodString; + at: z.ZodNullable; + durationMs: z.ZodNullable; + res: z.ZodEnum<{ + pending: "pending"; + done: "done"; + fail: "fail"; + current: "current"; + }>; + note: z.ZodString; + }, z.core.$strip>>; + }, z.core.$strip>; +}; +/** 全部 11 条 —— 文档总表与覆盖测试按它数 */ +export declare const ACTIVITY_OPS_ROUTES: readonly RouteContract[]; diff --git a/apps/api/src/app/__tests__/activity-ops-instance-state.test.ts b/apps/api/src/app/__tests__/activity-ops-instance-state.test.ts new file mode 100644 index 0000000..541f519 --- /dev/null +++ b/apps/api/src/app/__tests__/activity-ops-instance-state.test.ts @@ -0,0 +1,52 @@ +/** 实例五态:对 task 运行态 / 终态的如实映射(含「驳回是环内回转」那一段) */ +import { describe, expect, it } from 'vitest'; +import { instanceStateOf, verdictFactOf } from '../activity-ops/instance-state.ts'; + +const base = { terminal: undefined, reason: undefined, runtime: 'running', usedSteps: 3, lastVerdict: undefined, pending: undefined }; + +describe('instanceStateOf', () => { + it('终态:released 完成;terminated 失败且 kind=terminated;其余终态失败带原话', () => { + expect(instanceStateOf({ ...base, terminal: 'released' })).toEqual({ state: 'completed', reason: null }); + expect(instanceStateOf({ ...base, terminal: 'terminated', reason: '由 柴浩 手动终止' })).toEqual({ state: 'failed', reason: { kind: 'terminated', text: '由 柴浩 手动终止' } }); + // exhausted 存的是预算执行器的 JSON —— 摆给人的要说哪一项用了多少;认不出的形状原样交 + expect(instanceStateOf({ ...base, terminal: 'exhausted', reason: '{"kind":"exhausted","breaches":[{"item":"steps","used":90,"cap":90},{"item":"activeMs","used":7200000,"cap":7200000}]}' }).reason) + .toEqual({ kind: 'failed', text: '预算耗尽:步数 90/90、活跃时长 120/120 分钟' }); + expect(instanceStateOf({ ...base, terminal: 'exhausted', reason: '{"breaches":[]}' }).reason?.text).toBe('{"breaches":[]}'); + expect(instanceStateOf({ ...base, terminal: 'exhausted', reason: undefined }).reason?.text).toBe('预算耗尽'); + expect(instanceStateOf({ ...base, terminal: 'stalled' }).reason).toEqual({ kind: 'failed', text: '停滞熔断' }); + }); + + it('待执行 = 建了单还没被推进器领到;等人回传仍是进行中', () => { + expect(instanceStateOf({ ...base, usedSteps: 0 }).state).toBe('pending'); + expect(instanceStateOf({ ...base, runtime: 'awaiting-human', usedSteps: 0 }).state).toBe('running'); + expect(instanceStateOf({ ...base, runtime: 'awaiting-event' }).state).toBe('running'); + }); + + it('驳回:裁决刚到(pending.verdict=reject)或重做已被消费(pending 清空)且最近裁决不是 pass;重新提交后回进行中', () => { + const redo = { pass: false, comment: '毛利低于阈值,退回重算', byName: '柴浩' }; + expect(instanceStateOf({ ...base, lastVerdict: redo, pending: { verdict: 'reject', comment: redo.comment } })).toEqual({ + state: 'rejected', + reason: { kind: 'rejected', text: '验收驳回:毛利低于阈值,退回重算 —— 负责人 柴浩' }, + }); + expect(instanceStateOf({ ...base, lastVerdict: redo, pending: undefined }).state).toBe('rejected'); + // 新验收卡已发(pending 立起、还没裁决)→ running + expect(instanceStateOf({ ...base, lastVerdict: redo, pending: { verdict: undefined, comment: undefined } }).state).toBe('running'); + // 上一次裁决是 pass(落地中)→ running + expect(instanceStateOf({ ...base, lastVerdict: { pass: true, comment: '', byName: '柴浩' }, pending: { verdict: 'pass', comment: undefined } }).state).toBe('running'); + // 没写意见也要说人话 + expect(instanceStateOf({ ...base, lastVerdict: { pass: false, comment: '', byName: undefined } }).reason?.text).toBe('验收驳回:负责人未写明具体意见,已退回重做'); + }); +}); + +describe('verdictFactOf', () => { + it('取最近一条 `裁决:`;落地那一路同 kind 但标题不同,不算', () => { + const entries = [ + { kind: 'release-verdict', title: '裁决:redo', detail: { by: 's-1', comment: '再核一遍' } }, + { kind: 'release-verdict', title: '裁决:pass', detail: { by: 's-1' } }, + { kind: 'release-verdict', title: '落地收敛,发射输出口:assessment-released', detail: { ports: ['assessment-released'] } }, + ]; + expect(verdictFactOf(entries, (id) => (id === 's-1' ? '柴浩' : undefined))).toEqual({ pass: true, comment: '', byName: '柴浩' }); + expect(verdictFactOf(entries.slice(0, 1))).toEqual({ pass: false, comment: '再核一遍', byName: undefined }); + expect(verdictFactOf([])).toBeUndefined(); + }); +}); diff --git a/apps/api/src/app/__tests__/activity-ops-launch-params.test.ts b/apps/api/src/app/__tests__/activity-ops-launch-params.test.ts new file mode 100644 index 0000000..0d40c0a --- /dev/null +++ b/apps/api/src/app/__tests__/activity-ops-launch-params.test.ts @@ -0,0 +1,210 @@ +/** + * 启动参数表:从清单 `input.data` 派生 + 服务端校验。 + * 夹具照 W05 / E04 的清单形状手写(apps 不得 import activities —— depcruise 在守)。 + */ +import { describe, expect, it } from 'vitest'; +import { controlOf, fieldSpecsOf, launchParamOf, launchParamsOf, validateLaunchParams } from '../activity-ops/launch-params.ts'; + +/** W05 的四参 + 选填文件,按 2026-08-27 的清单写法 */ +const W05_INPUT = { + type: 'object', + required: ['customerCode', 'requestDescription', 'originalInternalPn', 'candidateInternalPns'], + properties: { + customerCode: { + type: 'string', + title: '客户', + description: '台账里有就直接选;新客户自己填', + examples: ['CU-0142'], + 'x-control': 'combo', + 'x-options': { ledger: 'customer.profile', value: 'customer_code', label: 'name' }, + }, + customerName: { type: 'string', title: '客户名称', examples: ['深圳恒星电源有限公司'] }, + requestDescription: { type: 'string', title: '客户需求', 'x-control': 'textarea', examples: ['工业电源项目在用 LM2596S-5.0'] }, + originalInternalPn: { type: 'string', title: '原内部料号', 'x-control': 'combo', 'x-options': { ledger: 'parts.profile', value: 'internal_pn' } }, + candidateInternalPns: { type: 'array', title: '候选内部料号', items: { type: 'string' }, examples: [['XL2596S-5.0', 'LM2596S-ADJ']] }, + datasheets: { type: 'array', format: 'file', title: 'datasheet', items: { type: 'object' } }, + }, +}; + +/** E04:两级下拉 + 数组(元素形状不声明)+ 选填说明 */ +const E04_INPUT = { + type: 'object', + required: ['trigger'], + properties: { + trigger: { type: 'string', enum: ['monthly', 'event'], enumNames: ['月度例行', '事件驱动'], title: '触发方式', default: 'monthly' }, + eventType: { type: 'string', enum: ['new_customer', 'major_deal'], title: '事件类型', showWhen: { field: 'trigger', equals: 'event' } }, + customers: { type: 'array', title: '本轮涉及的客户', examples: [['CU-0142']] }, + note: { type: 'string', title: '本轮说明' }, + budget: { type: 'integer', title: '预算' }, + }, +}; + +const noResolve = async () => undefined; + +describe('fieldSpecsOf / controlOf', () => { + it('保属性序;必填从 required[] 认;x-control / x-options / description 都读到', () => { + const specs = fieldSpecsOf(W05_INPUT); + expect(specs.map((s) => s.key)).toEqual(['customerCode', 'customerName', 'requestDescription', 'originalInternalPn', 'candidateInternalPns', 'datasheets']); + expect(specs[0]).toMatchObject({ required: true, control: 'combo', description: '台账里有就直接选;新客户自己填', optionsSource: { ledger: 'customer.profile', value: 'customer_code', label: 'name' } }); + // label 列没写就用 value 列当标签 + expect(specs[3]?.optionsSource).toEqual({ ledger: 'parts.profile', value: 'internal_pn', label: 'internal_pn' }); + expect(specs[1]?.required).toBe(false); + }); + + it('控件推断:文件不上表单、字符串数组默认 list、enum 是 select、其余 text;数组标了 combo/select 就摆成单选', () => { + const specs = fieldSpecsOf(W05_INPUT); + expect(specs.map((s) => controlOf(s))).toEqual(['combo', 'text', 'textarea', 'combo', 'list', undefined]); + const e04 = fieldSpecsOf(E04_INPUT); + expect(e04.map((s) => controlOf(s))).toEqual(['select', 'select', 'list', 'text', 'text']); + // 数组字段标 combo → 摆成单选(提交时后端包成数组);标 list 或不标 → list + expect(controlOf({ ...specs[4]!, control: 'combo' })).toBe('combo'); + expect(controlOf({ ...specs[4]!, control: 'list' })).toBe('list'); + expect(controlOf({ ...specs[4]!, itemType: 'object' })).toBeUndefined(); + expect(controlOf({ ...specs[1]!, type: 'object' })).toBeUndefined(); + }); +}); + +describe('x-order', () => { + it('声明了次序的按数字排,没声明的殿后保持原序(jsonb 不保键序,书写顺序靠不住)', () => { + const specs = fieldSpecsOf({ + properties: { + customerCode: { type: 'string', 'x-order': 1 }, + customerName: { type: 'string', 'x-order': 2 }, + originalInternalPn: { type: 'string', 'x-order': 4 }, + requestDescription: { type: 'string', 'x-order': 3 }, + zz: { type: 'string' }, + aa: { type: 'string' }, + }, + }); + expect(specs.map((s) => s.key)).toEqual(['customerCode', 'customerName', 'requestDescription', 'originalInternalPn', 'zz', 'aa']); + }); +}); + +describe('launchParamsOf', () => { + it('W05:五参(datasheets 省略);combo 候选来自 resolver;placeholder 取 examples[0]', async () => { + const asked: string[] = []; + const params = await launchParamsOf(W05_INPUT, async (src) => { + asked.push(src.ledger); + return src.ledger === 'customer.profile' ? [{ value: 'CU-0142', label: 'Itelma' }] : undefined; + }); + expect(params.map((p) => p.key)).toEqual(['customerCode', 'customerName', 'requestDescription', 'originalInternalPn', 'candidateInternalPns']); + expect(asked).toEqual(['customer.profile', 'parts.profile']); + expect(params[0]).toEqual({ + key: 'customerCode', + label: '客户', + type: 'combo', + required: true, + desc: '台账里有就直接选;新客户自己填', + placeholder: 'CU-0142', + options: [{ value: 'CU-0142', label: 'Itelma' }], + }); + // 读不到候选的 combo 仍是 combo(自由填),只是没有 options + expect(params[3]).toEqual({ key: 'originalInternalPn', label: '原内部料号', type: 'combo', required: true }); + expect(params[4]).toMatchObject({ type: 'list', placeholder: 'XL2596S-5.0', required: true }); + expect(params[2]).toMatchObject({ type: 'textarea', placeholder: '工业电源项目在用 LM2596S-5.0' }); + }); + + it('E04:enum → select 带人读名与 default;没 enumNames 拿值当标签;数组元素形状不声明也当 list', async () => { + const params = await launchParamsOf(E04_INPUT, noResolve); + expect(params[0]).toEqual({ + key: 'trigger', + label: '触发方式', + type: 'select', + required: true, + options: [{ value: 'monthly', label: '月度例行' }, { value: 'event', label: '事件驱动' }], + default: 'monthly', + }); + expect(params[1]?.options).toEqual([{ value: 'new_customer', label: 'new_customer' }, { value: 'major_deal', label: 'major_deal' }]); + expect(params[2]).toMatchObject({ type: 'list', placeholder: 'CU-0142' }); + }); + + it('x-control:select 却一个候选都没有 → 降成 text(只能选却无可选等于锁死)', () => { + const spec = fieldSpecsOf({ properties: { k: { type: 'string', 'x-control': 'select' } } })[0]!; + expect(launchParamOf(spec, undefined)).toEqual({ key: 'k', label: 'k', type: 'text' }); + expect(launchParamOf(spec, [])?.type).toBe('text'); + }); +}); + +describe('数组字段摆成单选(2026-08-27 用户定:候选料号与原料号同款下拉,只选一颗)', () => { + const INPUT = { + required: ['candidateInternalPns'], + properties: { + candidateInternalPns: { + type: 'array', + title: '候选内部料号', + items: { type: 'string' }, + examples: [['XL2596S-5.0', 'LM2596S-ADJ']], + default: ['LM2596S-ADJ'], + 'x-control': 'combo', + 'x-options': { ledger: 'parts.profile', value: 'internal_pn' }, + }, + tags: { type: 'array', items: { type: 'string' }, enum: ['a', 'b'], 'x-control': 'select' }, + }, + }; + + it('数组 + x-control:combo → combo 控件,占位与预填取第一颗,候选照读', async () => { + const params = await launchParamsOf(INPUT, async () => [{ value: 'LM2596S-ADJ', label: 'LM2596S-ADJ' }]); + expect(params[0]).toEqual({ + key: 'candidateInternalPns', + label: '候选内部料号', + type: 'combo', + required: true, + placeholder: 'XL2596S-5.0', + options: [{ value: 'LM2596S-ADJ', label: 'LM2596S-ADJ' }], + default: 'LM2596S-ADJ', + }); + expect(params[1]).toMatchObject({ type: 'select', options: [{ value: 'a', label: 'a' }, { value: 'b', label: 'b' }] }); + }); + + it('提交单个字符串包成一元数组;传数组也照收;空的按必填拦;select 逐项对候选', async () => { + const specs = fieldSpecsOf(INPUT); + const params = await launchParamsOf(INPUT, noResolve); + expect(validateLaunchParams(params, { candidateInternalPns: 'XL2596S-5.0' }, specs)).toEqual({ ok: true, data: { candidateInternalPns: ['XL2596S-5.0'] } }); + expect(validateLaunchParams(params, { candidateInternalPns: ['XL2596S-5.0', ' LM2596S-ADJ '] }, specs)).toEqual({ ok: true, data: { candidateInternalPns: ['XL2596S-5.0', 'LM2596S-ADJ'] } }); + const empty = validateLaunchParams(params, { candidateInternalPns: ' ' }, specs); + expect(empty).toEqual({ ok: false, issues: [{ key: 'candidateInternalPns', message: '必填' }] }); + const off = validateLaunchParams(params, { candidateInternalPns: 'x', tags: ['a', 'z'] }, specs); + expect(off).toEqual({ ok: false, issues: [{ key: 'tags', message: '不在候选里(单选只能选候选)' }] }); + expect(validateLaunchParams(params, { candidateInternalPns: 'x', tags: 'b' }, specs)).toEqual({ ok: true, data: { candidateInternalPns: ['x'], tags: ['b'] } }); + }); +}); + +describe('validateLaunchParams', () => { + it('必填空 / 未知键 / 类型不符 / select 不在候选 各点名一格;combo 候选之外合法', async () => { + const specs = fieldSpecsOf(W05_INPUT); + const params = await launchParamsOf(W05_INPUT, async () => [{ value: 'CU-0142', label: 'Itelma' }]); + const bad = validateLaunchParams(params, { customerCode: 'CU-NEW', requestDescription: '', candidateInternalPns: [' '], oops: 'x' }, specs); + expect(bad.ok).toBe(false); + if (!bad.ok) { + expect(bad.issues.map((i) => i.key).sort()).toEqual(['candidateInternalPns', 'oops', 'originalInternalPn', 'requestDescription']); + } + const typed = validateLaunchParams(params, { customerCode: ['CU-1'], requestDescription: 'x', originalInternalPn: 'p', candidateInternalPns: 'PN-1' }, specs); + expect(typed.ok).toBe(false); + // 标量格传数组是错;list 格传单个字符串**照收**(包成一元数组) + if (!typed.ok) expect(typed.issues).toEqual([{ key: 'customerCode', message: '要传字符串' }]); + const wrapped = validateLaunchParams(params, { customerCode: 'CU-1', requestDescription: 'x', originalInternalPn: 'p', candidateInternalPns: ' PN-1 ' }, specs); + expect(wrapped).toMatchObject({ ok: true, data: { candidateInternalPns: ['PN-1'] } }); + + const good = validateLaunchParams( + params, + { customerCode: 'CU-NEW', customerName: '', requestDescription: ' 客户要求验证替代料 ', originalInternalPn: 'LM2596S-5.0', candidateInternalPns: ['XL2596S-5.0', '', ' LM2596S-ADJ '] }, + specs, + ); + expect(good).toEqual({ + ok: true, + // 可选空串不落入参;list 逐项去空白、丢空项;combo 候选之外原样收 + data: { customerCode: 'CU-NEW', requestDescription: '客户要求验证替代料', originalInternalPn: 'LM2596S-5.0', candidateInternalPns: ['XL2596S-5.0', 'LM2596S-ADJ'] }, + }); + }); + + it('select 只认候选;整数按清单类型转回数字', async () => { + const specs = fieldSpecsOf(E04_INPUT); + const params = await launchParamsOf(E04_INPUT, noResolve); + const bad = validateLaunchParams(params, { trigger: 'weekly' }, specs); + expect(bad.ok).toBe(false); + if (!bad.ok) expect(bad.issues[0]).toMatchObject({ key: 'trigger', message: expect.stringContaining('候选') }); + expect(validateLaunchParams(params, { trigger: 'event', budget: '12' }, specs)).toEqual({ ok: true, data: { trigger: 'event', budget: 12 } }); + const nan = validateLaunchParams(params, { trigger: 'event', budget: '1.5' }, specs); + expect(nan.ok).toBe(false); + }); +}); diff --git a/apps/api/src/app/__tests__/activity-ops-service.test.ts b/apps/api/src/app/__tests__/activity-ops-service.test.ts new file mode 100644 index 0000000..024e37d --- /dev/null +++ b/apps/api/src/app/__tests__/activity-ops-service.test.ts @@ -0,0 +1,528 @@ +/** + * 活动模块用例:投影 / 启动(幂等三档)/ 实例清单与详情 / 终止 / 禁用。 + * 依赖全是内存字面量 —— 服务只声明用到的那几面,不用造整本清单。 + */ +import { describe, expect, it } from 'vitest'; +import type { LaunchInput, TaskId } from '@contracts'; +import { staffIdSchema, taskIdSchema } from '@contracts'; +import type { ActivityCard } from '@modules/activity-registry'; +import { parseCmmn } from '@modules/loop-engine'; +import type { ItemRuntime } from '@modules/loop-engine'; +import { StaffNotFoundError } from '@modules/org'; +import type { StartTaskInput } from '@modules/task'; +import { createActivityOps, LAUNCH_CLAIM_STALE_MS } from '../activity-ops/service.ts'; +import type { ActivityOpsDeps, HistoryEntry, LoopFace, ManifestFace } from '../activity-ops/service.ts'; +import type { ActivityOpsStore, ActivityProfile, LaunchClaim, TaskRow } from '../activity-ops/store.ts'; + +const W05 = 'part-substitution-assessment'; +const OWNER = staffIdSchema.parse('s-owner'); +const ME = { id: staffIdSchema.parse('s-me'), name: '冯兵' }; + +const XML = ` + + + + + + +`; + +const card = (over: Partial = {}): ActivityCard => ({ + activityId: W05, + name: 'ACT-WORK-05 · 差异评估', + category: '需求分析与方案匹配', + packageVersions: [ + { activityId: W05, version: '1.4.9', installedAt: '2026-08-26T01:00:00.000Z', state: 'active' }, + { activityId: W05, version: '1.5.0', installedAt: '2026-08-27T01:00:00.000Z', state: 'active' }, + ], + assetVersions: [], + enabled: true, + ...over, +}); + +const manifest = (over: Partial = {}): ManifestFace => ({ + identity: { name: 'ACT-WORK-05 · 差异评估' }, + contract: { + input: { + data: { + type: 'object', + required: ['customerCode', 'requestDescription', 'candidateInternalPns'], + properties: { + customerCode: { type: 'string', title: '客户', 'x-control': 'combo', 'x-options': { ledger: 'customer.profile', value: 'customer_code', label: 'name' } }, + requestDescription: { type: 'string', title: '客户需求', 'x-control': 'textarea' }, + candidateInternalPns: { type: 'array', title: '候选料号', items: { type: 'string' } }, + datasheets: { type: 'array', format: 'file', items: { type: 'object' } }, + }, + }, + }, + }, + declarations: { + models: ['deepseek-v4-flash'], + ledger: [ + { domain: 'sub_eval', object: 'verdict', fields: [{ name: 'eval_ref' }, { name: 'request_id' }] }, + { domain: 'parts', object: 'profile', fields: [{ name: 'internal_pn' }, { name: 'lifecycle_status', tally: ['active', 'nrnd', 'eol'] }] }, + ], + }, + assets: { viewTemplates: [{ name: 'activity-ledger', html: '

一览

' }] }, + ...over, +}); + +/** 一张带取数声明的看板模版:案号 → 核对表 → 料号圈主数据,页头摆实例与案号 */ +const BOARD_HTML = [ + '
', + '

· ·

', + '

需求 · 料号 条 · NRND

', + '
', + '
', +].join(''); +const BOARD_DATA = { + case: { ledger: 'sub_eval.verdict', key: 'eval_ref', prefix: 'W05-' }, + rows: [ + { as: 'sub_eval.verdict', ledger: 'sub_eval.verdict', by: { column: 'eval_ref', from: 'case' } }, + { as: 'sub_eval.pn_check', ledger: 'sub_eval.pn_check', by: { column: 'eval_ref', from: 'case' } }, + { as: 'parts.profile', ledger: 'parts.profile', by: { column: 'internal_pn', from: 'sub_eval.pn_check.internal_pn' } }, + ], +}; +const boardManifest = (): ManifestFace => manifest({ assets: { viewTemplates: [{ name: 'activity-ledger', html: BOARD_HTML, data: BOARD_DATA }] } }); +/** 正式表的一角:两单的行并排躺着,看板只许圈出案号对上的那一单 */ +const FORMAL: Record>> = { + 'sub_eval.verdict': [ + { eval_ref: 'W05-20260827100000', request_id: 'REQ-20260827100000' }, + { eval_ref: 'W05-20260827000000', request_id: 'REQ-20260827000000' }, + ], + 'sub_eval.pn_check': [ + { eval_ref: 'W05-20260827100000', internal_pn: 'LM2596S-5.0' }, + { eval_ref: 'W05-20260827100000', internal_pn: 'XL2596S-5.0' }, + { eval_ref: 'W05-20260827000000', internal_pn: 'OTHER' }, + ], + 'parts.profile': [ + { internal_pn: 'LM2596S-5.0', lifecycle_status: 'nrnd' }, + { internal_pn: 'XL2596S-5.0', lifecycle_status: 'active' }, + { internal_pn: 'OTHER', lifecycle_status: 'active' }, + ], +}; +const formalLedger: ActivityOpsDeps['ledger'] = { + describe: async (address) => (FORMAL[`${address.domain}.${address.object}`] === undefined ? undefined : { columns: [] }), + query: async (q) => ({ + rows: (FORMAL[`${q.address.domain}.${q.address.object}`] ?? []).filter((r) => Object.entries(q.where).every(([k, v]) => String(r[k]) === String(v))), + }), +}; +/** 本地时区里 2026-08-27 10:00:00 起的单 —— 案号 W05-20260827100000 */ +const RELEASED_AT = new Date(2026, 7, 27, 10, 0, 0).toISOString(); + +const row = (over: Partial = {}): TaskRow => ({ + id: 't-1', + no: 'INS-7', + activityId: W05, + workflowId: null, + origin: 'ops', + ownerId: OWNER, + title: 'ACT-WORK-05 · 差异评估', + runtime: 'running', + terminal: null, + reason: null, + envelope: { packageVersion: '1.5.0' }, + data: { customerCode: 'CU-0142', requestDescription: '工业电源项目在用 LM2596S-5.0', candidateInternalPns: ['XL2596S-5.0'], datasheets: [{ name: 'a.pdf', contentType: 'application/pdf', size: 3, ref: 'u-1' }] }, + usedSteps: 2, + startedAt: '2026-08-27T02:00:00.000Z', + finishedAt: null, + ...over, +}); + +function memoryStore(rows: TaskRow[] = [], profiles: ActivityProfile[] = []) { + const claims = new Map(); + const profileMap = new Map(profiles.map((p) => [p.activityId, p])); + const store: ActivityOpsStore = { + async countByActivity() { + const m = new Map(); + for (const r of rows) m.set(r.activityId, (m.get(r.activityId) ?? 0) + 1); + return m; + }, + async listTasks(activityId) { + return rows.filter((r) => r.activityId === activityId); + }, + async getTask(activityId, taskId) { + return rows.find((r) => r.activityId === activityId && r.id === taskId); + }, + async latestReleased(activityId) { + return rows + .filter((r) => r.activityId === activityId && r.terminal === 'released') + .sort((a, b) => Date.parse(b.finishedAt ?? b.startedAt) - Date.parse(a.finishedAt ?? a.startedAt))[0]; + }, + async claimLaunch(input) { + const hit = claims.get(input.key); + if (hit !== undefined) return hit; + claims.set(input.key, { ...input, taskId: null, claimedAt: clock.now().toISOString() }); + return 'claimed'; + }, + async reclaimLaunch(key, staleMs, ownerId, launchedBy) { + const hit = claims.get(key); + if (hit === undefined || hit.taskId !== null || clock.now().getTime() - Date.parse(hit.claimedAt) < staleMs) return false; + claims.set(key, { ...hit, ownerId, launchedBy, claimedAt: clock.now().toISOString() }); + return true; + }, + async bindLaunch(key, taskId) { + const hit = claims.get(key); + if (hit !== undefined) claims.set(key, { ...hit, taskId }); + }, + async launchOf(taskId) { + return [...claims.values()].find((c) => c.taskId === taskId); + }, + async profilesOf() { + return profileMap; + }, + async upsertProfile(p) { + profileMap.set(p.activityId, p); + }, + async workflowOf() { + return { subject: '统一报价 · Itelma', source: { kind: 'event' } }; + }, + async inboundCommOf(taskId) { + return taskId === 't-mail' ? { channel: 'mail', party: 'quotes@itelma.ru', subject: 'RFQ' } : undefined; + }, + }; + const clock = { at: Date.parse('2026-08-27T03:00:00.000Z'), now: () => new Date(clock.at) }; + return { store, claims, clock, rows }; +} + +function harness( + opts: { + rows?: TaskRow[]; + profiles?: ActivityProfile[]; + enabled?: boolean; + history?: Record; + loops?: Record; + manifest?: ManifestFace; + ledger?: ActivityOpsDeps['ledger']; + } = {}, +) { + const mem = memoryStore(opts.rows ?? [], opts.profiles ?? []); + const started: StartTaskInput[] = []; + const controlled: Array<{ taskId: TaskId; reason: string | undefined }> = []; + let enabled = opts.enabled ?? true; + let n = 0; + const staff = [ + { id: OWNER, name: '柴浩', state: 'active' }, + { id: ME.id, name: ME.name, state: 'active' }, + { id: 's-gone', name: '离职者', state: 'disabled' }, + ]; + const deps: ActivityOpsDeps = { + registry: { + list: async () => [card({ enabled })], + get: async (id) => (id === W05 ? card({ enabled }) : undefined), + manifestOf: async () => opts.manifest ?? manifest(), + setEnabled: async (_id, on) => { + enabled = on; + }, + }, + task: { + start: async (input) => { + started.push(input); + n += 1; + const id = taskIdSchema.parse(`t-new-${n}`); + mem.rows.push(row({ id, no: `INS-${100 + n}`, ownerId: input.ownerId, data: input.data, usedSteps: 0, origin: input.origin ?? null })); + return { id }; + }, + control: async (taskId, _semantic, _by, reason) => { + controlled.push({ taskId, reason }); + const at = mem.rows.findIndex((r) => r.id === taskId); + const hit = mem.rows[at]; + if (hit !== undefined) mem.rows.splice(at, 1, { ...hit, terminal: 'terminated', reason: reason ?? null, finishedAt: mem.clock.now().toISOString() }); + return {}; + }, + }, + org: { + listStaff: async () => ({ staff }), + getStaff: async (id) => { + const hit = staff.find((s) => s.id === id); + if (hit === undefined) throw new StaffNotFoundError(id); + return hit; + }, + }, + memory: { history: async (taskId) => opts.history?.[taskId] ?? [] }, + ledger: opts.ledger ?? { + describe: async (address) => (address.object === 'profile' ? { columns: [{ name: 'customer_code' }, { name: 'name' }] } : undefined), + query: async () => ({ rows: [{ customer_code: 'CU-0142', name: 'Itelma' }, { customer_code: 'CU-0001', name: '' }] }), + }, + loopState: { load: async (taskId) => opts.loops?.[taskId] }, + store: mem.store, + files: { + readUpload: async (ref) => (ref === 'u-1' ? { name: 'a.pdf', contentType: 'application/pdf', bytes: new Uint8Array([1, 2, 3]) } : undefined), + listArchived: async (taskId) => (taskId === 't-done' ? [{ address: 'content.asset', key: { id: '1' }, format: 'docx', name: '报告.docx', size: 9 }] : []), + readArchived: async () => ({ bytes: new Uint8Array([9]), name: '报告.docx', format: 'docx' }), + }, + clock: mem.clock, + warn: () => undefined, + }; + return { ops: createActivityOps(deps), mem, started, controlled }; +} + +const launchInput = (over: Partial = {}): LaunchInput => ({ + idempotencyKey: 'k-1', + ownerId: OWNER, + params: { customerCode: 'CU-0142', requestDescription: '验证替代料', candidateInternalPns: ['XL2596S-5.0'] }, + ...over, +}); + +describe('活动投影', () => { + it('清单:版本取最新一版、创建时刻取最早一版、负责人与图标来自档案、实例按活动数;status 过滤', async () => { + const { ops } = harness({ + rows: [row(), row({ id: 't-2', no: 'INS-8' })], + profiles: [{ activityId: W05, ownerId: OWNER, description: '逐项差异评估', ico: 'git-compare', hue: 'teal' }], + }); + const [a] = await ops.listActivities(); + expect(a).toEqual({ + id: W05, + name: 'ACT-WORK-05 · 差异评估', + biz: '需求分析与方案匹配', + status: 'active', + version: '1.5.0', + owner: { id: OWNER, name: '柴浩' }, + llm: 'deepseek-v4-flash', + instanceCount: 2, + createdAt: '2026-08-26T01:00:00.000Z', + ico: 'git-compare', + hue: 'teal', + }); + expect(await ops.listActivities('disabled')).toEqual([]); + const detail = await ops.getActivity(W05); + expect(detail).toMatchObject({ desc: '逐项差异评估', view: { url: `/api/gov/activities/${W05}/view` } }); + expect(detail).not.toHaveProperty('instanceCount'); + expect(await ops.getActivity('nope')).toBeUndefined(); + }); + + it('没建档:负责人 null、说明空串、图标色相缺席;色相不在四色里也不摆', async () => { + const { ops } = harness({ profiles: [{ activityId: W05, ownerId: null, description: '', ico: null, hue: 'pink' }] }); + const [a] = await ops.listActivities(); + expect(a).toMatchObject({ owner: null, instanceCount: 0 }); + expect(a).not.toHaveProperty('ico'); + expect(a).not.toHaveProperty('hue'); + }); + + it('禁用 / 激活幂等,回活动详情;视图注入报高脚本、没配视图 404', async () => { + const { ops } = harness(); + expect((await ops.setEnabled(W05, false))?.status).toBe('disabled'); + expect((await ops.setEnabled(W05, false))?.status).toBe('disabled'); + expect((await ops.setEnabled(W05, true))?.status).toBe('active'); + expect(await ops.setEnabled('nope', true)).toBeUndefined(); + const view = await ops.viewHtml(W05); + expect(view.kind).toBe('html'); + if (view.kind === 'html') expect(view.html).toContain('jcai:view-height'); + expect((await ops.viewHtml('nope')).kind).toBe('no-activity'); + }); +}); + +describe('沙箱看板:最近一次已放行实例的落账快照', () => { + const released = (over: Partial = {}) => + row({ id: 't-rel', no: 'INS-20', runtime: 'terminal', terminal: 'released', startedAt: RELEASED_AT, finishedAt: new Date(2026, 7, 27, 10, 6, 0).toISOString(), ...over }); + + it('按案号只圈这一单的行:页头实例号 / 案号 / 负责人,料号两颗、别单的 OTHER 不进来,徽标按值投影,读数摆出来', async () => { + const older = released({ id: 't-old', no: 'INS-3', startedAt: new Date(2026, 7, 27, 0, 0, 0).toISOString(), finishedAt: new Date(2026, 7, 27, 0, 5, 0).toISOString() }); + const { ops } = harness({ rows: [row({ id: 't-live' }), older, released()], manifest: boardManifest(), ledger: formalLedger }); + const view = await ops.viewHtml(W05); + expect(view.kind).toBe('html'); + if (view.kind !== 'html') return; + expect(view.html).toContain('class="page v-ready"'); + expect(view.html).toContain('INS-20 · W05-20260827100000 · 柴浩'); + expect(view.html).toContain('需求 REQ-20260827100000 · 料号 2 条 · NRND 1'); + expect(view.html).toContain('LM2596S-5.0'); + expect(view.html).toContain('XL2596S-5.0'); + expect(view.html).not.toContain('OTHER'); + expect(view.html).not.toContain('data-rows='); + expect(view.html).toContain('jcai:view-height'); + }); + + it('没放行过 → none 态、实例事实空着;案号在正式表里查无 → missing 态、表全空(不摆别单的行)', async () => { + const none = harness({ rows: [row({ id: 't-live' })], manifest: boardManifest(), ledger: formalLedger }); + const v1 = await none.ops.viewHtml(W05); + if (v1.kind === 'html') { + expect(v1.html).toContain('class="page v-none"'); + expect(v1.html).toContain('—'); + expect(v1.html).toContain('料号 0'); + } else expect.unreachable(); + + const missing = harness({ rows: [released({ startedAt: new Date(2026, 7, 27, 11, 0, 0).toISOString() })], manifest: boardManifest(), ledger: formalLedger }); + const v2 = await missing.ops.viewHtml(W05); + if (v2.kind === 'html') { + expect(v2.html).toContain('class="page v-missing"'); + expect(v2.html).toContain('W05-20260827110000'); + expect(v2.html).not.toContain('LM2596S-5.0'); + } else expect.unreachable(); + }); + + it('一张表读不了不拖垮整屏:那一块空着,别的照摆;模版没带 data 就原样出', async () => { + const half: ActivityOpsDeps['ledger'] = { ...formalLedger, describe: async (a) => (a.object === 'profile' ? undefined : formalLedger.describe(a)) }; + const { ops } = harness({ rows: [released()], manifest: boardManifest(), ledger: half }); + const v = await ops.viewHtml(W05); + if (v.kind === 'html') { + expect(v.html).toContain('class="page v-ready"'); + expect(v.html).toContain('需求 REQ-20260827100000 · 料号 0'); + } else expect.unreachable(); + const plain = harness({ rows: [released()] }); + const p = await plain.ops.viewHtml(W05); + if (p.kind === 'html') expect(p.html).toContain('

一览

'); + else expect.unreachable(); + }); +}); + +describe('启动', () => { + it('参数表:combo 候选从台账读(空标签退回值);文件字段不上表', async () => { + const { ops } = harness(); + const table = await ops.launchParams(W05); + expect(table?.version).toBe('1.5.0'); + expect(table?.params.map((p) => p.key)).toEqual(['customerCode', 'requestDescription', 'candidateInternalPns']); + expect(table?.params[0]?.options).toEqual([{ value: 'CU-0142', label: 'Itelma' }, { value: 'CU-0001', label: 'CU-0001' }]); + }); + + it('禁用 412 / 责任人不在职 400 / 参数不合 400 —— 都不起单', async () => { + const { ops, started } = harness({ enabled: false }); + expect((await ops.launch(W05, launchInput(), ME)).kind).toBe('disabled'); + const live = harness(); + expect(await live.ops.launch(W05, launchInput({ ownerId: staffIdSchema.parse('s-gone') }), ME)).toMatchObject({ kind: 'owner-invalid' }); + expect(await live.ops.launch(W05, launchInput({ ownerId: staffIdSchema.parse('s-nobody') }), ME)).toMatchObject({ kind: 'owner-invalid', message: '责任人不在名册里' }); + const bad = await live.ops.launch(W05, launchInput({ params: { customerCode: 'CU-0142' } }), ME); + expect(bad.kind).toBe('invalid'); + if (bad.kind === 'invalid') expect(bad.issues.map((i) => i.key)).toEqual(['requestDescription', 'candidateInternalPns']); + expect((await live.ops.launch('nope', launchInput(), ME)).kind).toBe('not-found'); + expect(started).toEqual([]); + expect(live.started).toEqual([]); + }); + + it('起单:origin=ops、标题取清单名、入参归一后交 task.start;同键重放回同一单、不起第二个', async () => { + const { ops, started } = harness(); + const first = await ops.launch(W05, launchInput(), ME); + expect(first.kind).toBe('ok'); + if (first.kind !== 'ok') return; + expect(first.created).toBe(true); + expect(started).toEqual([ + { activityId: W05, ownerId: OWNER, title: 'ACT-WORK-05 · 差异评估', origin: 'ops', data: { customerCode: 'CU-0142', requestDescription: '验证替代料', candidateInternalPns: ['XL2596S-5.0'] } }, + ]); + expect(first.detail).toMatchObject({ id: 't-new-1', state: 'pending', owner: { id: OWNER, name: '柴浩' }, input: { source: { kind: 'manual', label: '手动启动 · 冯兵' } } }); + + const again = await ops.launch(W05, launchInput({ params: { customerCode: 'X', requestDescription: 'y', candidateInternalPns: ['z'] } }), ME); + expect(again).toMatchObject({ kind: 'ok', created: false, detail: { id: 't-new-1' } }); + expect(started).toHaveLength(1); + }); + + it('键占了单还没建成:60 秒内 409;过期后重占再起', async () => { + const h = harness(); + await h.mem.store.claimLaunch({ key: 'k-1', activityId: W05, ownerId: OWNER, launchedBy: 's-x' }); + expect(await h.ops.launch(W05, launchInput(), ME)).toMatchObject({ kind: 'busy' }); + h.mem.clock.at += LAUNCH_CLAIM_STALE_MS + 1; + const late = await h.ops.launch(W05, launchInput(), ME); + expect(late.kind).toBe('ok'); + expect(h.started).toHaveLength(1); + expect(h.mem.claims.get('k-1')?.taskId).toBe('t-new-1'); + // 别的活动拿同一枚键 → 409 + await h.mem.store.claimLaunch({ key: 'k-2', activityId: 'other', ownerId: OWNER, launchedBy: 's-x' }); + expect(await h.ops.launch(W05, launchInput({ idempotencyKey: 'k-2' }), ME)).toMatchObject({ kind: 'busy' }); + }); +}); + +describe('实例清单与详情', () => { + const items = parseCmmn(XML).items; + const rt = (states: Record): ItemRuntime[] => Object.entries(states).map(([id, state]) => ({ id, state, instance: 1 })); + const loop = (states: Record, pending?: LoopFace['pending']): LoopFace => ({ + model: { steps: [{ item: 'S1', intent: '把四项绑定' }] }, + parsed: { items }, + runtime: { items: rt(states) }, + ...(pending === undefined ? {} : { pending }), + }); + const line = (kind: string, title: string, at: string, detail?: Record): HistoryEntry => ({ kind, title, at, ...(detail === undefined ? {} : { detail }) }); + + const rows = [ + row({ id: 't-1', no: 'INS-7' }), + row({ id: 't-redo', no: 'INS-6', startedAt: '2026-08-26T02:00:00.000Z' }), + row({ id: 't-done', no: 'INS-5', terminal: 'released', runtime: 'terminal', finishedAt: '2026-08-25T03:00:00.000Z', startedAt: '2026-08-25T02:00:00.000Z' }), + row({ id: 't-dead', no: 'INS-4', terminal: 'terminated', reason: '由 冯兵 手动终止', finishedAt: '2026-08-24T02:30:00.000Z', startedAt: '2026-08-24T02:00:00.000Z', ownerId: ME.id }), + row({ id: 't-mail', no: 'INS-3', origin: null, usedSteps: 0, data: {}, startedAt: '2026-08-23T02:00:00.000Z' }), + ]; + const history = { + 't-redo': [line('release-verdict', '裁决:redo', '2026-08-26T03:00:00.000Z', { by: OWNER, comment: '证据不足' })], + 't-done': [line('release-verdict', '落地收敛,发射输出口:assessment-released', '2026-08-25T03:00:00.000Z', { ports: ['assessment-released'] })], + 't-1': [line('engine-transition', '开始办:归一', '2026-08-27T02:00:10.000Z'), line('engine-transition', '完成:归一', '2026-08-27T02:01:10.000Z'), line('engine-transition', '开始办:交人确认', '2026-08-27T02:01:20.000Z')], + }; + const loops = { 't-1': loop({ S1: 'Completed', S2: 'Active' }), 't-redo': loop({ S1: 'Completed', S2: 'Completed', T_release: 'Completed' }) }; + + it('五态各归其位;统计不随筛选动;产出只给已完成的;分页按筛后走', async () => { + const { ops } = harness({ rows, history, loops }); + const all = await ops.listInstances(W05, { page: 1, size: 8 }); + expect(all?.stats).toEqual({ total: 5, byState: { pending: 1, running: 1, completed: 1, rejected: 1, failed: 1 } }); + expect(all?.items.map((i) => [i.no, i.state, i.output])).toEqual([ + ['INS-7', 'running', null], + ['INS-6', 'rejected', null], + ['INS-5', 'completed', '报告.docx'], + ['INS-4', 'failed', null], + ['INS-3', 'pending', null], + ]); + expect(all?.items[0]?.input).toBe('工业电源项目在用 LM2596S-5.0'); + // 入参空的那单退回来件主题 + expect(all?.items[4]?.input).toBe('RFQ'); + const page2 = await ops.listInstances(W05, { page: 2, size: 2, ownerId: OWNER }); + expect(page2).toMatchObject({ total: 4, page: 2, size: 2 }); + expect(page2?.items.map((i) => i.no)).toEqual(['INS-5', 'INS-3']); + expect(page2?.stats.total).toBe(5); + expect((await ops.listInstances(W05, { page: 1, size: 8, id: 'ins-4' }))?.items.map((i) => i.no)).toEqual(['INS-4']); + expect(await ops.listInstances('nope', { page: 1, size: 8 })).toBeUndefined(); + }); + + it('详情:时间线 / 来源 / 文件 / 原因 / 用时', async () => { + const { ops } = harness({ rows, history, loops }); + const live = await ops.instanceDetail(W05, 't-1'); + expect(live).toMatchObject({ + no: 'INS-7', + state: 'running', + reason: null, + endedAt: null, + durationMs: null, + llm: 'deepseek-v4-flash', + input: { content: '工业电源项目在用 LM2596S-5.0', source: { kind: 'manual', label: '手动启动 · 运营端' }, files: [{ index: 0, name: 'a.pdf', contentType: 'application/pdf', size: 3 }] }, + output: { files: [] }, + }); + expect(live?.steps.map((s) => [s.name, s.by, s.byName, s.res, s.durationMs, s.note])).toEqual([ + ['归一', 'agent', 'deepseek-v4-flash', 'done', 60_000, '把四项绑定'], + ['交人确认', 'human', '柴浩', 'current', null, ''], + ['放行', 'agent', 'deepseek-v4-flash', 'pending', null, ''], + ]); + expect(live).not.toHaveProperty('input.output'); + + const redo = await ops.instanceDetail(W05, 't-redo'); + expect(redo?.reason).toEqual({ kind: 'rejected', text: '验收驳回:证据不足 —— 负责人 柴浩' }); + expect(redo?.steps.map((s) => s.res)).toEqual(['done', 'done', 'fail']); + + const dead = await ops.instanceDetail(W05, 't-dead'); + expect(dead).toMatchObject({ state: 'failed', reason: { kind: 'terminated', text: '由 冯兵 手动终止' }, endedAt: '2026-08-24T02:30:00.000Z', durationMs: 30 * 60_000, owner: { name: '冯兵' } }); + + const done = await ops.instanceDetail(W05, 't-done'); + expect(done?.output.files).toEqual([{ index: 0, name: '报告.docx', contentType: expect.stringContaining('wordprocessingml'), size: 9 }]); + + const mail = await ops.instanceDetail(W05, 't-mail'); + expect(mail?.input.source).toEqual({ kind: 'mail', label: '邮件 · quotes@itelma.ru' }); + expect(await ops.instanceDetail(W05, 'nope')).toBeUndefined(); + }); + + it('上游工作流起的单标 upstream;测试台起的标 testbed', async () => { + const { ops } = harness({ rows: [row({ id: 't-wf', workflowId: 'wf-1', origin: null }), row({ id: 't-tb', origin: 'testbed', data: {} })] }); + expect((await ops.instanceDetail(W05, 't-wf'))?.input.source).toEqual({ kind: 'upstream', label: '上游 · 统一报价 · Itelma' }); + expect((await ops.instanceDetail(W05, 't-tb'))?.input.source).toEqual({ kind: 'testbed', label: '测试台试跑' }); + }); + + it('文件:input 走上传件、output 走存档;越界 / 无单各回明确结果', async () => { + const { ops } = harness({ rows }); + expect(await ops.instanceFile(W05, 't-1', 'input', 0)).toMatchObject({ kind: 'file', name: 'a.pdf', contentType: 'application/pdf' }); + expect((await ops.instanceFile(W05, 't-1', 'input', 1)).kind).toBe('no-file'); + expect(await ops.instanceFile(W05, 't-done', 'output', 0)).toMatchObject({ kind: 'file', name: '报告.docx' }); + expect((await ops.instanceFile(W05, 't-1', 'output', 0)).kind).toBe('no-file'); + expect((await ops.instanceFile(W05, 'nope', 'output', 0)).kind).toBe('no-instance'); + }); + + it('终止:走 task.control,理由带人名;已终态 412;之后详情是 failed + terminated', async () => { + const { ops, controlled } = harness({ rows: [row({ id: 't-1' }), row({ id: 't-done', terminal: 'released', runtime: 'terminal' })] }); + const done = await ops.terminate(W05, 't-1', ME); + expect(done).toMatchObject({ kind: 'ok', detail: { state: 'failed', reason: { kind: 'terminated', text: '由 冯兵 手动终止' } } }); + expect(controlled).toEqual([{ taskId: 't-1', reason: '由 冯兵 手动终止' }]); + expect((await ops.terminate(W05, 't-done', ME)).kind).toBe('terminal'); + expect((await ops.terminate(W05, 't-1', ME)).kind).toBe('terminal'); + expect((await ops.terminate(W05, 'nope', ME)).kind).toBe('not-found'); + }); +}); diff --git a/apps/api/src/app/__tests__/activity-ops-steps.test.ts b/apps/api/src/app/__tests__/activity-ops-steps.test.ts new file mode 100644 index 0000000..a4eb2e4 --- /dev/null +++ b/apps/api/src/app/__tests__/activity-ops-steps.test.ts @@ -0,0 +1,86 @@ +/** L3 动作过程记录:计划项 × 运行态 × 引擎痕 */ +import { describe, expect, it } from 'vitest'; +import { parseCmmn } from '@modules/loop-engine'; +import type { ItemRuntime } from '@modules/loop-engine'; +import { stepsOf } from '../activity-ops/instance-steps.ts'; + +const XML = ` + + + + + + + + + + + +`; + +const rt = (states: Record): ItemRuntime[] => Object.entries(states).map(([id, state]) => ({ id, state, instance: 1 })); +const line = (title: string, at: string) => ({ kind: 'engine-transition', title, at }); +const intents = new Map([['S1', '把来件归一成台账行'], ['S2', '派一张执行单交人']]); + +describe('stepsOf', () => { + const items = parseCmmn(XML).items; + + it('只摆任务项(里程碑不算),按文档序;人工激活的项是人办的;时刻与用时来自引擎痕', () => { + const steps = stepsOf({ + items, + runtimeItems: rt({ S1: 'Completed', S2: 'Active', S3: 'Available' }), + intents, + history: [ + line('开始办:归一', '2026-08-27T02:00:00.000Z'), + line('完成:归一', '2026-08-27T02:03:00.000Z'), + line('开始办:交人确认', '2026-08-27T02:03:30.000Z'), + ], + state: 'running', + ownerName: '柴浩', + llm: 'deepseek-v4-flash', + }); + expect(steps.map((s) => [s.seq, s.name, s.by, s.byName, s.res])).toEqual([ + [1, '归一', 'agent', 'deepseek-v4-flash', 'done'], + [2, '交人确认', 'human', '柴浩', 'current'], + [3, '放行', 'agent', 'deepseek-v4-flash', 'pending'], + ]); + expect(steps[0]).toMatchObject({ at: '2026-08-27T02:00:00.000Z', durationMs: 180_000, note: '把来件归一成台账行' }); + expect(steps[1]).toMatchObject({ at: '2026-08-27T02:03:30.000Z', durationMs: null, note: '派一张执行单交人' }); + expect(steps[2]).toMatchObject({ at: null, durationMs: null, note: '' }); + }); + + it('自动收步的项拿收步那一刻当时刻;没有 runtime 的项是 pending', () => { + const steps = stepsOf({ + items, + runtimeItems: rt({ S1: 'Completed' }), + intents, + history: [line('自动收步:S1 的成果已在台账里(rows > 0)—— 不必再问模型', '2026-08-27T02:01:00.000Z')], + state: 'running', + ownerName: '柴浩', + llm: 'm', + }); + expect(steps[0]).toMatchObject({ res: 'done', at: '2026-08-27T02:01:00.000Z', durationMs: null }); + expect(steps[1]?.res).toBe('pending'); + }); + + it('终止 / 失败:正在办的那一步记 fail;都没开就记第一个没到的;驳回记最后一个已完成的', () => { + const facts = { items, intents, history: [], ownerName: '柴浩', llm: 'm' }; + expect(stepsOf({ ...facts, runtimeItems: rt({ S1: 'Completed', S2: 'Active' }), state: 'failed' }).map((s) => s.res)).toEqual(['done', 'fail', 'pending']); + expect(stepsOf({ ...facts, runtimeItems: rt({ S1: 'Terminated', S2: 'Terminated' }), state: 'failed' }).map((s) => s.res)).toEqual(['fail', 'pending', 'pending']); + expect(stepsOf({ ...facts, runtimeItems: rt({ S1: 'Completed', S2: 'Completed', S3: 'Completed' }), state: 'rejected' }).map((s) => s.res)).toEqual(['done', 'done', 'fail']); + expect(stepsOf({ ...facts, runtimeItems: rt({ S1: 'Failed' }), state: 'running' }).map((s) => s.res)).toEqual(['fail', 'pending', 'pending']); + }); + + it('重复实例化的项看最新那一实例', () => { + const steps = stepsOf({ + items, + runtimeItems: [{ id: 'S2', state: 'Completed', instance: 1 }, { id: 'S2', state: 'Active', instance: 2 }], + intents, + history: [], + state: 'running', + ownerName: '柴浩', + llm: 'm', + }); + expect(steps[1]?.res).toBe('current'); + }); +}); diff --git a/apps/api/src/app/__tests__/activity-ops-store.it.test.ts b/apps/api/src/app/__tests__/activity-ops-store.it.test.ts new file mode 100644 index 0000000..99f2a28 --- /dev/null +++ b/apps/api/src/app/__tests__/activity-ops-store.it.test.ts @@ -0,0 +1,93 @@ +/** + * 启动幂等键在真库上的并发:两条连接同时占同一枚键,**恰一个赢**;单没建成的过期键可被重占。 + * 独占一个库(照 mail-store.it 的先例):建表 / 清表都在 `jcai_it_activity_ops` 里,谁的库都不碰。 + */ +import { readFileSync } from 'node:fs'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { Pool } from 'pg'; +import { drizzle } from 'drizzle-orm/node-postgres'; +import { sql } from 'drizzle-orm'; +import type { Db } from '@shared/db.ts'; +import { createActivityOpsStore } from '../activity-ops/store.ts'; + +const IT_DB = 'jcai_it_activity_ops'; + +async function provisionDatabase(): Promise<{ url: string; stop(): Promise } | undefined> { + const base = process.env['JCAI_TEST_DATABASE_URL'] ?? process.env['DATABASE_URL'] ?? 'postgres://jcai:jcai@localhost:54329/jcai'; + try { + const admin = new Pool({ connectionString: base, max: 1, connectionTimeoutMillis: 1500 }); + const exists = await admin.query(`select 1 from pg_database where datname = $1`, [IT_DB]); + if (exists.rowCount === 0) await admin.query(`create database ${IT_DB}`); + await admin.end(); + return { url: base.replace(/\/[^/]*$/, `/${IT_DB}`), stop: async () => undefined }; + } catch { + /* 开发实例连不上,试 Testcontainers */ + } + try { + const { PostgreSqlContainer } = await import('@testcontainers/postgresql'); + const started = await new PostgreSqlContainer('postgres:16-alpine').start(); + return { url: started.getConnectionUri(), stop: () => started.stop().then(() => undefined) }; + } catch (error) { + process.stderr.write(`⚠️ activity-ops store it 跳过:既连不上开发实例也起不了 Testcontainers(${error instanceof Error ? error.message : String(error)})\n`); + return undefined; + } +} + +const database = await provisionDatabase(); +let poolA: Pool | undefined; +let poolB: Pool | undefined; +let dbA: Db; +let dbB: Db; + +const MIGRATION = new URL('../../../../../db/migrations/0035_activity_ops.sql', import.meta.url); + +beforeAll(async () => { + if (database === undefined) return; + poolA = new Pool({ connectionString: database.url, max: 2 }); + poolB = new Pool({ connectionString: database.url, max: 2 }); + dbA = drizzle(poolA); + dbB = drizzle(poolB); + await poolA.query('create schema if not exists sys'); + await poolA.query('drop table if exists sys.activity_launch; drop table if exists sys.activity_profile'); + await poolA.query(readFileSync(MIGRATION, 'utf8')); +}); + +afterAll(async () => { + await poolA?.end(); + await poolB?.end(); + await database?.stop(); +}); + +describe.skipIf(database === undefined)('activity_launch 幂等键(真库)', () => { + it('两条连接同时占同一枚键,恰一个 claimed,另一个读回那一行;绑定后重放拿到 task_id', async () => { + const a = createActivityOpsStore(dbA); + const b = createActivityOpsStore(dbB); + const input = { key: 'race-1', activityId: 'w05', ownerId: 'owner', launchedBy: 'me' }; + const results = await Promise.all(Array.from({ length: 20 }, (_, i) => (i % 2 === 0 ? a : b).claimLaunch(input))); + expect(results.filter((r) => r === 'claimed')).toHaveLength(1); + for (const r of results) if (r !== 'claimed') expect(r).toMatchObject({ key: 'race-1', activityId: 'w05', taskId: null }); + await a.bindLaunch('race-1', 't-1'); + expect(await b.claimLaunch(input)).toMatchObject({ taskId: 't-1' }); + expect(await b.launchOf('t-1')).toMatchObject({ key: 'race-1', launchedBy: 'me' }); + }); + + it('单没建成的键:没过期不许重占;过期后恰一个重占成功;已绑 task 的永远不许', async () => { + const a = createActivityOpsStore(dbA); + const b = createActivityOpsStore(dbB); + expect(await a.claimLaunch({ key: 'stale-1', activityId: 'w05', ownerId: 'o', launchedBy: 'x' })).toBe('claimed'); + expect(await b.reclaimLaunch('stale-1', 60_000, 'o', 'y')).toBe(false); + await dbA.execute(sql`update sys.activity_launch set claimed_at = now() - interval '2 minutes' where key = 'stale-1'`); + const won = await Promise.all([a.reclaimLaunch('stale-1', 60_000, 'o', 'y'), b.reclaimLaunch('stale-1', 60_000, 'o', 'z')]); + expect(won.filter(Boolean)).toHaveLength(1); + await a.bindLaunch('stale-1', 't-2'); + await dbA.execute(sql`update sys.activity_launch set claimed_at = now() - interval '2 minutes' where key = 'stale-1'`); + expect(await b.reclaimLaunch('stale-1', 60_000, 'o', 'q')).toBe(false); + }); + + it('活动档案:upsert 冲突即更新,负责人可空', async () => { + const a = createActivityOpsStore(dbA); + await a.upsertProfile({ activityId: 'w05', ownerId: null, description: '一', ico: 'git-compare', hue: 'teal' }); + await a.upsertProfile({ activityId: 'w05', ownerId: 'owner', description: '二', ico: 'git-compare', hue: 'teal' }); + expect((await a.profilesOf()).get('w05')).toEqual({ activityId: 'w05', ownerId: 'owner', description: '二', ico: 'git-compare', hue: 'teal' }); + }); +}); diff --git a/apps/api/src/app/__tests__/activity-ops-store.test.ts b/apps/api/src/app/__tests__/activity-ops-store.test.ts new file mode 100644 index 0000000..b58fe52 --- /dev/null +++ b/apps/api/src/app/__tests__/activity-ops-store.test.ts @@ -0,0 +1,124 @@ +/** + * 活动模块的 app 层存取:SQL 不求值,只看「几笔、带了什么」与行 → 视图的映射(时间戳归一、seq → 实例号)。 + * 真库上的并发抢键在 `activity-ops-store.it.test.ts`。 + */ +import { describe, expect, it } from 'vitest'; +import { createActivityOpsStore } from '../activity-ops/store.ts'; + +type Fake = { calls: string[]; execute: (query: unknown) => Promise<{ rows: unknown[] }> }; + +/** 每次 execute 按顺序吐一份 rows;SQL 片段拼成字符串留着断言 */ +function fakeDb(...results: unknown[][]): Fake { + const calls: string[] = []; + const queue = [...results]; + return { + calls, + execute: async (query: unknown) => { + const q = query as { queryChunks?: unknown[] }; + calls.push(JSON.stringify(q.queryChunks ?? query)); + return { rows: queue.shift() ?? [] }; + }, + }; +} + +const RAW_TASK = { + id: 't-1', + seq: '17', + activity_id: 'part-substitution-assessment', + workflow_id: null, + origin: 'ops', + owner_id: 's-1', + title: 'W05', + runtime: 'running', + terminal: null, + reason: null, + envelope: { packageVersion: '1.5.0' }, + input_data: { customerCode: 'CU-0142' }, + used_steps: '3', + // pg 的 timestamptz 在 raw execute 这一路回文本(实测),也可能是 Date —— 两种都要归一成 ISO + started_at: '2026-08-27 16:55:05.123+08', + finished_at: new Date('2026-08-27T08:55:14.383Z'), +}; + +describe('createActivityOpsStore', () => { + it('listTasks:seq → INS-n、时间戳归一成 ISO、jsonb 取成对象、数字列转数', async () => { + const db = fakeDb([RAW_TASK]); + const rows = await createActivityOpsStore(db).listTasks('part-substitution-assessment'); + expect(rows).toEqual([ + { + id: 't-1', + no: 'INS-17', + activityId: 'part-substitution-assessment', + workflowId: null, + origin: 'ops', + ownerId: 's-1', + title: 'W05', + runtime: 'running', + terminal: null, + reason: null, + envelope: { packageVersion: '1.5.0' }, + data: { customerCode: 'CU-0142' }, + usedSteps: 3, + startedAt: '2026-08-27T08:55:05.123Z', + finishedAt: '2026-08-27T08:55:14.383Z', + }, + ]); + expect(db.calls[0]).toContain('order by started_at desc, seq desc'); + expect(db.calls[0]).toContain('activity_id = '); + }); + + it('latestReleased 只看已放行的、按放行时刻倒序取一条;没有回 undefined', async () => { + const db = fakeDb([RAW_TASK]); + const one = await createActivityOpsStore(db).latestReleased('part-substitution-assessment'); + expect(one?.no).toBe('INS-17'); + expect(db.calls[0]).toContain("terminal = 'released'"); + expect(db.calls[0]).toContain('order by finished_at desc nulls last, started_at desc limit 1'); + expect(await createActivityOpsStore(fakeDb([])).latestReleased('x')).toBeUndefined(); + }); + + it('getTask 认活动 + 任务两把钥匙;没命中回 undefined', async () => { + const db = fakeDb([]); + expect(await createActivityOpsStore(db).getTask('a', 't')).toBeUndefined(); + expect(db.calls[0]).toContain('activity_id = '); + expect(db.calls[0]).toContain('and id = '); + }); + + it('countByActivity 按活动数;profilesOf 按活动键映射', async () => { + const db = fakeDb([{ activity_id: 'a', n: '2' }, { activity_id: 'b', n: 1 }], [{ activity_id: 'a', owner_id: null, description: null, ico: 'target', hue: 'blue' }]); + const store = createActivityOpsStore(db); + expect([...(await store.countByActivity()).entries()]).toEqual([['a', 2], ['b', 1]]); + const profiles = await store.profilesOf(); + expect(profiles.get('a')).toEqual({ activityId: 'a', ownerId: null, description: '', ico: 'target', hue: 'blue' }); + }); + + it('claimLaunch:一句 SQL 判 + 占(on conflict do nothing returning);插不进去就读回那一行', async () => { + const claimed = fakeDb([{ key: 'k' }]); + expect(await createActivityOpsStore(claimed).claimLaunch({ key: 'k', activityId: 'a', ownerId: 'o', launchedBy: 'me' })).toBe('claimed'); + expect(claimed.calls[0]).toContain('on conflict (key) do nothing'); + expect(claimed.calls[0]).toContain('returning key'); + expect(claimed.calls).toHaveLength(1); + + const taken = fakeDb([], [{ key: 'k', activity_id: 'a', owner_id: 'o', launched_by: 'x', task_id: 't-9', claimed_at: new Date('2026-08-27T08:00:00.000Z') }]); + expect(await createActivityOpsStore(taken).claimLaunch({ key: 'k', activityId: 'a', ownerId: 'o', launchedBy: 'me' })).toEqual({ + key: 'k', + activityId: 'a', + ownerId: 'o', + launchedBy: 'x', + taskId: 't-9', + claimedAt: '2026-08-27T08:00:00.000Z', + }); + expect(taken.calls).toHaveLength(2); + }); + + it('reclaimLaunch 只占「单没建成且已过期」的键;bindLaunch 落 task_id;upsertProfile 冲突即更新', async () => { + const db = fakeDb([{ key: 'k' }], [], []); + const store = createActivityOpsStore(db); + expect(await store.reclaimLaunch('k', 60_000, 'o', 'me')).toBe(true); + expect(db.calls[0]).toContain('task_id is null'); + expect(db.calls[0]).toContain("milliseconds')::interval"); + await store.bindLaunch('k', 't-1'); + expect(db.calls[1]).toContain('set task_id = '); + await store.upsertProfile({ activityId: 'a', ownerId: null, description: 'd', ico: null, hue: null }); + expect(db.calls[2]).toContain('on conflict (activity_id) do update'); + }); +}); diff --git a/apps/api/src/app/__tests__/activity-ops-summaries.test.ts b/apps/api/src/app/__tests__/activity-ops-summaries.test.ts new file mode 100644 index 0000000..aa91013 --- /dev/null +++ b/apps/api/src/app/__tests__/activity-ops-summaries.test.ts @@ -0,0 +1,76 @@ +/** 清单一句话与检索 */ +import { describe, expect, it } from 'vitest'; +import type { InstanceSummary } from '@contracts'; +import { staffIdSchema } from '@contracts'; +import { fieldSpecsOf } from '../activity-ops/launch-params.ts'; +import { applyInstanceFilters, clip, inputFilesOf, inputSummaryOf, outputSummaryOf, pageOf, statsOf } from '../activity-ops/summaries.ts'; + +const specs = fieldSpecsOf({ + properties: { + customerCode: { type: 'string' }, + requestDescription: { type: 'string', 'x-control': 'textarea' }, + datasheets: { type: 'array', format: 'file' }, + }, +}); + +describe('inputSummaryOf / inputFilesOf / outputSummaryOf', () => { + it('textarea 那格优先;没有就最长的字符串;再没有退回来件主题;文件字段不算', () => { + expect(inputSummaryOf({ customerCode: 'CU-0142', requestDescription: ' 工业电源项目\n在用 LM2596S-5.0 ' }, specs)).toBe('工业电源项目 在用 LM2596S-5.0'); + expect(inputSummaryOf({ customerCode: 'CU-0142', datasheets: [{ ref: 'r', name: 'a-very-long-file-name.pdf' }] }, specs)).toBe('CU-0142'); + expect(inputSummaryOf({}, specs, 'RE: 询价')).toBe('RE: 询价'); + expect(inputSummaryOf({}, specs)).toBe(''); + expect(clip('x'.repeat(100))).toHaveLength(80); + expect(clip('x'.repeat(100)).endsWith('…')).toBe(true); + }); + + it('文件只认带 ref 的;index 跨字段连续', () => { + const files = inputFilesOf({ datasheets: [{ name: 'a.pdf', contentType: 'application/pdf', size: 10, ref: 'u-1' }, { name: 'no-ref' }, 'junk'] }, specs); + expect(files).toEqual([{ index: 0, name: 'a.pdf', contentType: 'application/pdf', size: 10, ref: 'u-1' }]); + }); + + it('产出:有文件摆文件名;没有摆发口;都没有 null', () => { + expect(outputSummaryOf(['报告.docx', '对照表.xlsx'], ['x'])).toBe('报告.docx、对照表.xlsx'); + expect(outputSummaryOf([], ['assessment-released'])).toBe('已发口:assessment-released'); + expect(outputSummaryOf([], [])).toBeNull(); + }); +}); + +const owner = { id: 's-1', name: '柴浩' }; +const mk = (id: string, no: string, state: InstanceSummary['state'], startedAt: string, ownerId = 's-1'): InstanceSummary => ({ + id, + no, + state, + owner: { id: staffIdSchema.parse(ownerId), name: owner.name }, + input: 'x', + output: null, + startedAt, +}); +const items = [ + mk('t-aaa', 'INS-24', 'running', '2026-08-25T02:12:00.000Z'), + mk('t-bbb', 'INS-17', 'completed', '2026-08-24T09:03:00.000Z', 's-2'), + mk('t-ccc', 'INS-9', 'failed', '2026-08-20T01:00:00.000Z'), +]; + +describe('applyInstanceFilters / statsOf / pageOf', () => { + it('实例号模糊、负责人精确、状态精确、日期段含两端整日(本地时区)', () => { + expect(applyInstanceFilters(items, { id: 'ins-2' }).map((i) => i.no)).toEqual(['INS-24']); + expect(applyInstanceFilters(items, { id: 'bbb' }).map((i) => i.no)).toEqual(['INS-17']); + expect(applyInstanceFilters(items, { ownerId: 's-2' }).map((i) => i.no)).toEqual(['INS-17']); + expect(applyInstanceFilters(items, { state: 'failed' }).map((i) => i.no)).toEqual(['INS-9']); + const day = (iso: string) => { + const d = new Date(iso); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + }; + expect(applyInstanceFilters(items, { startedFrom: day('2026-08-24T09:03:00.000Z'), startedTo: day('2026-08-24T09:03:00.000Z') }).map((i) => i.no)).toEqual(['INS-17']); + expect(applyInstanceFilters(items, { startedFrom: day('2026-08-24T09:03:00.000Z') }).map((i) => i.no)).toEqual(['INS-24', 'INS-17']); + expect(applyInstanceFilters(items, { startedTo: day('2026-08-20T01:00:00.000Z') }).map((i) => i.no)).toEqual(['INS-9']); + expect(applyInstanceFilters(items, {})).toHaveLength(3); + }); + + it('统计五键齐;分页越界给空', () => { + expect(statsOf(items)).toEqual({ total: 3, byState: { pending: 0, running: 1, completed: 1, rejected: 0, failed: 1 } }); + expect(pageOf(items, 1, 2).map((i) => i.no)).toEqual(['INS-24', 'INS-17']); + expect(pageOf(items, 2, 2).map((i) => i.no)).toEqual(['INS-9']); + expect(pageOf(items, 3, 2)).toEqual([]); + }); +}); diff --git a/apps/api/src/app/__tests__/activity-ops-view-html.test.ts b/apps/api/src/app/__tests__/activity-ops-view-html.test.ts new file mode 100644 index 0000000..294d63f --- /dev/null +++ b/apps/api/src/app/__tests__/activity-ops-view-html.test.ts @@ -0,0 +1,18 @@ +/** 沙箱视图出口:模版原文不改,只在 前注入报高脚本 */ +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 就追加;原文一字不改', () => { + const html = '

一览

'; + const out = injectHeightScript(html); + expect(out.startsWith('

一览

')).toBe(true); + expect(out.indexOf('' } : { 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(); + 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); + }); +}); diff --git a/apps/api/src/app/activity-ops/instance-state.ts b/apps/api/src/app/activity-ops/instance-state.ts new file mode 100644 index 0000000..8f3fd27 --- /dev/null +++ b/apps/api/src/app/activity-ops/instance-state.ts @@ -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> = { 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 | 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; +} diff --git a/apps/api/src/app/activity-ops/instance-steps.ts b/apps/api/src/app/activity-ops/instance-steps.ts new file mode 100644 index 0000000..bd4b8a5 --- /dev/null +++ b/apps/api/src/app/activity-ops/instance-steps.ts @@ -0,0 +1,101 @@ +/** + * L3 的「动作过程记录」—— 一个计划项一步,按案例模型的文档序排。 + * + * 三处拼起来(没有哪一处单独够用): + * · `loop_state.parsed.items`(CMMN 计划项:名字、谁办 —— `manualActivation` 的项是触达人的那几步); + * · `loop_state.runtime.items[].state`(办到哪了:Completed / Active / Failed / …,**不带时间**); + * · 任务记忆里引擎写的三种痕(`开始办:<名>` / `完成:<名>` / `自动收步: …`)给出时刻与用时。 + * 阶段(stage)/ 里程碑 / 监听器不是「一步」,不摆。 + */ +import type { InstanceState, InstanceStep } from '@contracts'; +import type { ItemRuntime, PlanItem } from '@modules/loop-engine'; + +export const STEP_ITEM_TYPES: ReadonlySet = 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; + readonly history: readonly HistoryLine[]; + readonly state: InstanceState; + readonly ownerName: string; + readonly llm: string; +}; + +type Res = InstanceStep['res']; + +const IN_PROGRESS: ReadonlySet = 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) ?? '', + }; + }); +} diff --git a/apps/api/src/app/activity-ops/launch-params.ts b/apps/api/src/app/activity-ops/launch-params.ts new file mode 100644 index 0000000..b3a5a52 --- /dev/null +++ b/apps/api/src/app/activity-ops/launch-params.ts @@ -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` → 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 | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record) : 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>): 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>, + resolve: (source: OptionsSource) => Promise, +): Promise { + 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>; +export type LaunchValidation = + | { readonly ok: true; readonly data: Record } + | { 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 = {}; + 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 }; +} diff --git a/apps/api/src/app/activity-ops/service.ts b/apps/api/src/app/activity-ops/service.ts new file mode 100644 index 0000000..9ba3544 --- /dev/null +++ b/apps/api/src/app/activity-ops/service.ts @@ -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; format: FileFormat; name: string; size: number }>>; + readArchived(taskId: TaskId, address: string, key: Readonly>): 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> } }; + 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; + get(activityId: string): Promise; + manifestOf(activityId: string, packageVersion?: string): Promise; + setEnabled(activityId: string, enabled: boolean): Promise; +}; +export type TaskFace = { + start(input: StartTaskInput): Promise<{ readonly id: TaskId }>; + control(taskId: TaskId, semantic: 'terminate', by: StaffId, reason?: string): Promise; +}; +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 | undefined }; +export type MemoryFace = { history(taskId: TaskId): Promise> }; +export type LedgerFace = { + describe(address: { domain: string; object: string }): Promise<{ readonly columns: ReadonlyArray<{ readonly name: string }> } | undefined>; + query(query: LedgerQuery): Promise<{ readonly rows: ReadonlyArray>> }>; +}; +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 }; + +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; + getActivity(activityId: string): Promise; + viewHtml(activityId: string): Promise; + listInstances(activityId: string, query: InstanceQuery & { page: number; size: number }): Promise; + launchParams(activityId: string): Promise; + launch(activityId: string, input: LaunchInput, by: Actor): Promise; + instanceDetail(activityId: string, instanceId: string): Promise; + instanceFile(activityId: string, instanceId: string, io: 'input' | 'output', index: number): Promise; + setEnabled(activityId: string, enabled: boolean): Promise; + terminate(activityId: string, instanceId: string, by: Actor): Promise; +}; + +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> { + const { staff } = await deps.org.listStaff(); + return new Map(staff.map((s) => [s.id, s.name])); + } + + function staffRef(id: string, names: ReadonlyMap): 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, + ): 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 { + 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 { + 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; readonly specs: readonly FieldSpec[] }; + + /** 一行 task → 状态裁定;非终态才需要翻记忆与 loop 运行态(判驳回) */ + async function verdictOf(row: TaskRow, names: ReadonlyMap): Promise<{ verdict: StateVerdict; history: ReadonlyArray | 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 { + 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 | undefined): Promise { + 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 | 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): Promise { + 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 { + 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): Promise { + 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 { + 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 }; + }, + }; +} diff --git a/apps/api/src/app/activity-ops/store.ts b/apps/api/src/app/activity-ops/store.ts new file mode 100644 index 0000000..490d905 --- /dev/null +++ b/apps/api/src/app/activity-ops/store.ts @@ -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 }; + +function rowsOf(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 { + return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record) : {}; +} + +/** `sys.task` 的一行,运营面要的那几列 */ +export type TaskRow = { + readonly id: string; + /** 人读实例号 `INS-` */ + 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; + readonly data: Record; + 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>; + listTasks(activityId: string): Promise; + getTask(activityId: string, taskId: string): Promise; + /** 最近一次已放行的实例(沙箱看板摆它):按放行时刻倒序,没放行过回 undefined */ + latestReleased(activityId: string): Promise; + /** 占键:插得进去回 `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; + bindLaunch(key: string, taskId: string): Promise; + launchOf(taskId: string): Promise; + profilesOf(): Promise>; + upsertProfile(profile: ActivityProfile): Promise; + workflowOf(workflowId: string): Promise<{ subject: string; source: Record } | 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( + 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( + 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( + 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( + 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( + 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 ?? '' }; + }, + }; +} diff --git a/apps/api/src/app/activity-ops/summaries.ts b/apps/api/src/app/activity-ops/summaries.ts new file mode 100644 index 0000000..9355574 --- /dev/null +++ b/apps/api/src/app/activity-ops/summaries.ts @@ -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>, 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>, 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; + 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; + for (const it of items) byState[it.state] += 1; + return { total: items.length, byState }; +} + +export function pageOf(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)); +} diff --git a/apps/api/src/app/activity-ops/view-html.ts b/apps/api/src/app/activity-ops/view-html.ts new file mode 100644 index 0000000..7dc00fb --- /dev/null +++ b/apps/api/src/app/activity-ops/view-html.ts @@ -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 ( + `' + ); +} + +/** 插在 `` 之前(没有就追加在末尾);模版原文一个字不改 */ +export function injectHeightScript(html: string): string { + const script = heightScript(); + const at = html.toLowerCase().lastIndexOf(''); + return at < 0 ? `${html}\n${script}` : `${html.slice(0, at)}${script}${html.slice(at)}`; +} diff --git a/apps/api/src/app/activity-ops/view-scope.ts b/apps/api/src/app/activity-ops/view-scope.ts new file mode 100644 index 0000000..4a17d31 --- /dev/null +++ b/apps/api/src/app/activity-ops/view-scope.ts @@ -0,0 +1,170 @@ +/** + * 沙箱看板的取数与作用域(零 IO;读表经注入)—— 执行清单 `viewTemplates[].data` 那份声明。 + * + * 分工:模版归活动、「哪几行是这一单的」归活动声明(案号前缀、哪张表按哪一列圈),这里只做两件机械的事: + * ① `resolveViewData`:按声明逐条求集合 —— `by.column ∈ values(by.from)`,`from` 只能指 `case` + * (案号本身)或前面已求出的 `.<列>`;每个值单独查一次(正式表里历次运行的行并排躺着, + * `where` 只有等值),结果按 `from` 值的顺序拼接,再按 `not` 排除; + * ② `viewScopeOf`:把行集摊成渲染器要的扁平作用域 —— `` 行数组、`.<列>` 首行标量、 + * `_rows` 行数、`_<列>_<值>` 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>; +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>, orderBy: ViewOrder) => Promise; + +export type ViewSets = Readonly>; +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` 本身可以带点) */ +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(); + 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[] | 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 { + const sets: Record = {}; + 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, read: ViewRead, skipped: string[]): Promise { + 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; + readonly instance?: ViewInstance | undefined; + readonly caseRef?: string | undefined; + readonly state: BoardState; +}): Record { + const scope: Record = { + [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; +} diff --git a/apps/api/src/app/routes/activities.ts b/apps/api/src/app/routes/activities.ts new file mode 100644 index 0000000..f87401f --- /dev/null +++ b/apps/api/src/app/routes/activities.ts @@ -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, message: string, detail: unknown): Response { + const body: ApiError = { code: 'validation-failed', message, detail }; + return c.json(body, 400); +} + +function notFound(c: Context, message: string): Response { + const body: ApiError = { code: 'not-found', message }; + return c.json(body, 404); +} + +function precondition(c: Context, message: string): Response { + const body: ApiError = { code: 'precondition-failed', message }; + return c.json(body, 412); +} + +function unauthenticated(c: Context): Response { + const body: ApiError = { code: 'unauthenticated', message: '这一步要知道是谁按的 —— 请求没带身份' }; + return c.json(body, 401); +} + +async function jsonBody(c: Context): Promise { + 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, deps: ActivityRouteDeps): void { + const { activityOps } = deps; + // 通用 Context 上 param 是 string | undefined;路径模版里有这一段,空串只会落成 404 + const activityId = (c: Context): string => c.req.param('activityId') ?? ''; + const instanceId = (c: Context): 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) => { + 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); + }); +} diff --git a/apps/api/src/modules/activity-registry/CONTRACT.md b/apps/api/src/modules/activity-registry/CONTRACT.md index 6274c34..1ecb1f7 100644 --- a/apps/api/src/modules/activity-registry/CONTRACT.md +++ b/apps/api/src/modules/activity-registry/CONTRACT.md @@ -41,7 +41,11 @@ - **进化必过治理**:候选 → 回放 → 人放行,状态机不许跳过回放 - **安装恒为新增、版本并存**:卸载只置 state,在途任务按其启动时版本跑完 -近变(2026-07-28):模块面未变。app 层的 `GET /api/gov/activities` 出参加 `contract` 面 +近变(2026-08-27):模块面未变。app 层那两条路由改名 `GET/POST /api/gov/activity-packages`(从前叫 `/api/gov/activities`), + `/api/gov/activities/*` 整个让给运营中心的活动模块(L1/L2/L3,`packages/contracts/src/api/activity-ops.ts`); + 运营端的禁用 / 激活走本模块既有的 `setEnabled`(从前只有 seed 调它,没有 HTTP 口)。 + +近变(2026-07-28):模块面未变。app 层的 `GET /api/gov/activities`(现 `/api/gov/activity-packages`)出参加 `contract` 面 (必填入参 / 入参字段 / 输出口与载荷字段 / 声明的工具·模型·域对象·岗位 / 调人预算), 由 `manifestOf` 逐个读清单组装。此前治理台看不到活动的契约: 编排的人引用一个活动却不知道它吃什么,边映射只能靠猜; diff --git a/apps/api/src/modules/activity-registry/internal/usecase/registry-service.ts b/apps/api/src/modules/activity-registry/internal/usecase/registry-service.ts index abdd470..4a8f677 100644 --- a/apps/api/src/modules/activity-registry/internal/usecase/registry-service.ts +++ b/apps/api/src/modules/activity-registry/internal/usecase/registry-service.ts @@ -76,6 +76,7 @@ export function createRegistryService(deps: RegistryServiceDeps): ActivityRegist const verdict: InstallVerdict = { accepted: mayTakeEffect(staticOk, replay), + stored: false, staticFindings: findings, replay: { oldPassStillPass: replay.oldPassStillPass, @@ -90,6 +91,8 @@ export function createRegistryService(deps: RegistryServiceDeps): ActivityRegist // 安装恒为新增、版本并存;在途任务按其启动时版本跑完 const isNewPackageVersion = await deps.store.savePackage(manifest, artifact); + // 同版重装 = 什么都没写:如实回给调用方,别让安装脚本再打印「已装」 + verdict.stored = isNewPackageVersion; /* * 资产初始版随包播种。 diff --git a/db/migrations/0035_activity_ops.sql b/db/migrations/0035_activity_ops.sql new file mode 100644 index 0000000..4479e73 --- /dev/null +++ b/db/migrations/0035_activity_ops.sql @@ -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() +); diff --git a/docs/API文档-活动模块.md b/docs/API文档-活动模块.md index 9ae0d51..d6efe4c 100644 --- a/docs/API文档-活动模块.md +++ b/docs/API文档-活动模块.md @@ -1,102 +1,310 @@ # API 文档 —— 活动模块(L1 清单 / L2 活动详情 / L3 实例详情) -> 本文是活动模块前端(运营中心 · 活动区,2026-08-25 重构模版)与服务端之间的接口约定, -> 给后端编写接口、前端取数用。鉴权 / 票据 / 错误体 / 端归属通则:`docs/API文档-鉴权与人员.md` §0,本文不重复。 +> 本文是活动模块前端(运营中心 · 活动区,2026-08-25 重构模版)与服务端之间的接口约定,给前端取数、后端维护用。 +> 鉴权 / 票据 / 错误体 / 端归属的**通则**在 `docs/API文档-鉴权与人员.md` §0,本文 §0.1 只摘调用时必须知道的几条。 > 需求出处:`docs/活动模块重构需求-2026-08-25.md`;启动参数口径:`ACT-WORK-05-一键启动入参说明.md`(极简四参)。 -> 契约落地后唯一真相在 `packages/contracts/src/api/*.ts`(zod schema,前后端共用);两者不一致时以契约为准并回来改本文。 +> **契约已落地(2026-08-27)**:`packages/contracts/src/activity-ops.ts`(数据结构 zod)+ `packages/contracts/src/api/activity-ops.ts` +> (11 条路由,花名册 `ACTIVITY_OPS_ROUTES`);前端一律 `import { xxxRoute } from '@contracts'` 经 `shared/typed-fetch` 的 `call(route, { params, query, body })` 调用, +> 类型直接用 `@contracts` 导出的(§1 每张表都标了导出名)。两者不一致时以契约为准并回来改本文。 +> 设计端的活动库两条路由已改名让位:`GET/POST /api/gov/activities` → `/api/gov/activity-packages`(前端 import 路由对象,不用改)。 -## 0. 通则(本文特有) +## 0. 通则 + +### 0.1 调用约定 | 项 | 值 | |---|---| -| 前缀 | `/api/gov/activities/*`;**全部 `access: 'ops'`** —— 活动区落在运营中心,与工作流/任务看板同端 | -| 时间 | 一律 ISO 8601 UTC 字符串(`2026-08-27T02:25:11.000Z`);**展示格式化是前端的事**(`YYYY-MM-DD HH:mm:ss` 本地时区) | -| 人员 | 凡出人,都是 `{ "id": "", "name": "柴浩" }` 两段 —— 展示用 name,筛选/提交用 id;人员候选名单用既有 `GET /api/gov/staff`,本文不另做 | -| 广播 | 写路由(启动 / 禁用 / 激活 / 终止)成功后广播 `{kind:'system', id:'gov'}` —— 前端按既有套路失效 `['gov',*]` 重取 | -| 实例状态 | 五态,英文码:`pending`(待执行)/ `running`(进行中)/ `completed`(已完成)/ `rejected`(驳回)/ `failed`(失败);**没有第六态** —— 手动终止归入 `failed`,用 `reason.kind='terminated'` 区分「自己跑挂」(见 §6) | -| 禁用语义 | **任何时刻都可禁用**(2026-08-27 用户重定,废除「有实例不能禁用」):禁用只拦「新启动」—— 已完成的实例是历史留痕,进行中的实例**继续跑完不受影响**;激活即恢复启动能力 | +| 前缀 | `/api/gov/activities/*`;**11 条全部 `access: 'ops'`** —— 登录人的 `ends` 里要有 `ops`,否则 403 | +| 请求头 | `Authorization: Bearer `;写请求 `content-type: application/json` | +| 票据 | 只有两条路带不了请求头(`