From c1137fb09a327b202295bf15a481e50264b2176c Mon Sep 17 00:00:00 2001 From: xcs Date: Fri, 28 Aug 2026 10:50:16 +0800 Subject: [PATCH] =?UTF-8?q?fix(ops):=20=E5=AE=9E=E4=BE=8B=E6=97=B6?= =?UTF-8?q?=E9=97=B4=E7=BA=BF=E7=AC=AC=209=20=E6=AD=A5=E7=9A=84=E7=BB=93?= =?UTF-8?q?=E6=9E=9C=E8=B7=9F=E7=AC=AC=208=20=E6=AD=A5=E8=B5=B0=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E5=B7=B2=E6=94=BE=E8=A1=8C=E7=9A=84?= =?UTF-8?q?=E5=8D=95=E4=B8=8D=E5=86=8D=E6=B0=B8=E8=BF=9C=E5=81=9C=E5=9C=A8?= =?UTF-8?q?=20pending?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 放行那一步(T_release,人工激活)**没有自己的收步事件** —— 人签字后 `submit` 直接把整单落终态, 计划项本身从没被 finish 过。于是一单已 `released` 的详情里第 9 步永远是 `pending`, 而它前面拟落账那一步早就 `done`:同一张单上两句话互相打脸。 `followRes` 纯函数在服务端交出详情之前把第 9 步的 `res` 与 `at` 跟上第 8 步(2026-08-28 用户定,`at` 也跟); **用时不编** —— 那一步没有自己的起止痕;两步有一步不在(别的活动步数不同)就什么都不动。 《API文档-活动模块》§ 时间线补一行口径。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0167qWjeyBwvfdGovd7FWbCu --- .../app/__tests__/activity-ops-steps.test.ts | 17 ++++++++++++- .../src/app/activity-ops/instance-steps.ts | 13 ++++++++++ apps/api/src/app/activity-ops/service.ts | 25 +++++++++++-------- docs/API文档-活动模块.md | 1 + 4 files changed, 45 insertions(+), 11 deletions(-) diff --git a/apps/api/src/app/__tests__/activity-ops-steps.test.ts b/apps/api/src/app/__tests__/activity-ops-steps.test.ts index a4eb2e4..9614f53 100644 --- a/apps/api/src/app/__tests__/activity-ops-steps.test.ts +++ b/apps/api/src/app/__tests__/activity-ops-steps.test.ts @@ -2,7 +2,7 @@ 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'; +import { followRes, stepsOf } from '../activity-ops/instance-steps.ts'; const XML = ` @@ -84,3 +84,18 @@ describe('stepsOf', () => { expect(steps[1]?.res).toBe('current'); }); }); + +describe('followRes:第 9 步跟第 8 步(放行那一步没有自己的收步事件)', () => { + const step = (seq: number, res: 'done' | 'current' | 'pending' | 'fail') => ({ seq, name: `S${seq}`, by: 'agent' as const, byName: 'llm', at: null, durationMs: null, res, note: '' }); + it('已放行的单:第 8 步 done → 第 9 步也 done、时刻跟第 8 步;别的字段不动', () => { + const steps = [...Array.from({ length: 7 }, (_, i) => step(i + 1, 'done')), { ...step(8, 'done'), at: '2026-08-27T14:30:26.080Z' }, { ...step(9, 'pending'), by: 'human' as const, byName: '柴浩' }]; + const out = followRes(steps, { seq: 9, from: 8 }); + expect(out[8]).toEqual({ ...step(9, 'done'), at: '2026-08-27T14:30:26.080Z', by: 'human', byName: '柴浩' }); + expect(out.slice(0, 8)).toEqual(steps.slice(0, 8)); + }); + it('第 8 步 fail / pending 时第 9 步照样跟;步数不够 9 的活动什么都不动', () => { + expect(followRes([step(1, 'done'), ...Array.from({ length: 7 }, (_, i) => step(i + 2, 'pending')), step(9, 'pending')].map((s) => (s.seq === 8 ? { ...s, res: 'fail' as const } : s)), { seq: 9, from: 8 })[8]?.res).toBe('fail'); + const seven = Array.from({ length: 7 }, (_, i) => step(i + 1, 'done')); + expect(followRes(seven, { seq: 9, from: 8 })).toEqual(seven); + }); +}); diff --git a/apps/api/src/app/activity-ops/instance-steps.ts b/apps/api/src/app/activity-ops/instance-steps.ts index bd4b8a5..90e1391 100644 --- a/apps/api/src/app/activity-ops/instance-steps.ts +++ b/apps/api/src/app/activity-ops/instance-steps.ts @@ -99,3 +99,16 @@ export function stepsOf(f: StepFacts): InstanceStep[] { }; }); } + +/** + * 让某一步的结果**跟随**前面那一步(2026-08-28 用户定:详情接口交给前端之前,第 9 步的 res 跟第 8 步走)。 + * + * 为什么会需要:放行那一步(T_release,人工激活)没有自己的收步事件 —— 人签字后 `submit` 直接把整单落终态, + * 计划项本身从没被 finish 过,于是一单已 `released` 的详情里第 9 步永远是 `pending`,而它前面拟落账那一步早就 `done`。 + * 改 `res` 与 `at`(2026-08-28 用户定,`at` 也跟);用时不编(那一步没有自己的起止痕)。两步有一步不在(别的活动步数不同)就什么都不动。 + */ +export function followRes(steps: readonly InstanceStep[], rule: { readonly seq: number; readonly from: number }): InstanceStep[] { + const source = steps.find((s) => s.seq === rule.from); + if (source === undefined) return [...steps]; + return steps.map((s) => (s.seq === rule.seq ? { ...s, res: source.res, at: source.at } : s)); +} diff --git a/apps/api/src/app/activity-ops/service.ts b/apps/api/src/app/activity-ops/service.ts index 9ba3544..600e31d 100644 --- a/apps/api/src/app/activity-ops/service.ts +++ b/apps/api/src/app/activity-ops/service.ts @@ -41,7 +41,7 @@ import { fieldSpecsOf, launchParamsOf, validateLaunchParams } from './launch-par 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 { followRes, 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'; @@ -54,6 +54,8 @@ import type { ActivityOpsStore, TaskRow } from './store.ts'; export const LAUNCH_CLAIM_STALE_MS = 60_000; /** combo 候选内联上限(文档 §4:本期一律内联;更大的表以后走检索接口) */ export const OPTIONS_CAP = 500; +/** 详情时间线交给前端之前:第 9 步(放行)的结果跟第 8 步(拟落账)走 —— 见 `followRes` 的注释(2026-08-28 用户定) */ +export const STEP_FOLLOW_RULE = { seq: 9, from: 8 } as const; /** 输入 / 产出文件的存取 —— app 里现成的两段(`upload-store.ts` / `asset-archive.ts`),按接口注入好替身 */ export type InstanceFileStore = { @@ -387,15 +389,18 @@ export function createActivityOps(deps: ActivityOpsDeps): ActivityOps { 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 steps = followRes( + 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, + }), + STEP_FOLLOW_RULE, + ); 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; diff --git a/docs/API文档-活动模块.md b/docs/API文档-活动模块.md index d6efe4c..58ce617 100644 --- a/docs/API文档-活动模块.md +++ b/docs/API文档-活动模块.md @@ -196,6 +196,7 @@ | `input.files` | `InstanceFile[]` | 是 | 输入文件元数据(可为空数组) | | `output.files` | `InstanceFile[]` | 是 | 产出文件元数据;没产出是 `[]`,摆「暂无产出」 | | `steps` | `InstanceStep[]` | 是 | 时间线 | +| | | | **第 9 步的 `res` 与 `at` 跟第 8 步走**(服务端交出前改好,2026-08-28 定):放行那一步没有自己的收步事件,不这么做已放行的单第 9 步永远 `pending`;用时不编 | 注意:详情里**没有** `input`/`output` 那两句摘要字符串(它们是清单行的字段),`input`/`output` 在详情里是上面的结构体。