fix(ops): 实例时间线第 9 步的结果跟第 8 步走 —— 已放行的单不再永远停在 pending

放行那一步(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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0167qWjeyBwvfdGovd7FWbCu
This commit is contained in:
xcs
2026-08-28 10:50:16 +08:00
co-authored by Claude Opus 5
parent f41bd76b0e
commit c1137fb09a
4 changed files with 45 additions and 11 deletions
@@ -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 = `<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL" id="d">
@@ -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);
});
});
@@ -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));
}
+15 -10
View File
@@ -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;