@bpmn-nova/studio 0.3.5-preview → 0.3.7-preview

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -51,9 +51,27 @@ export interface RuntimeApprovalActionPresentation extends Omit<RuntimeApprovalA
51
51
  assetCount: number
52
52
  order: number
53
53
  }
54
+ export interface RuntimeDiagnostic {
55
+ code: string
56
+ level: 'error' | 'warning' | 'info'
57
+ message: string
58
+ fieldPath?: string
59
+ elementId?: string
60
+ activityId?: string
61
+ visitId?: string
62
+ actionId?: string
63
+ transitionId?: string
64
+ edgeId?: string
65
+ }
66
+ export interface RuntimeInstant {
67
+ instant: number | null
68
+ raw: string
69
+ kind: 'offset' | 'z' | 'naive' | 'invalid' | 'missing'
70
+ }
54
71
  export interface RuntimeApprovalActionSummary {
55
72
  actions: RuntimeApprovalActionPresentation[]
56
73
  latestAction: RuntimeApprovalActionPresentation | null
74
+ latestNonEmptyComment: RuntimeApprovalActionPresentation | null
57
75
  actionText: string
58
76
  actionSummary: string
59
77
  imageCount: number
@@ -70,6 +88,7 @@ export interface ActivityInstance {
70
88
  assigneeId?: string
71
89
  participant?: RuntimeParticipant
72
90
  visitId?: string
91
+ supersedesVisitId?: string
73
92
  multiInstanceId?: string
74
93
  approvalMode?: 'single' | 'all' | 'any'
75
94
  multiInstanceMode?: 'parallel' | 'sequential'
@@ -92,6 +111,7 @@ export interface RuntimeTransition {
92
111
  state?: 'active' | 'resolved'
93
112
  resolvedAt?: string
94
113
  resolvedByActivityId?: string
114
+ invalidatedElementIds?: string[]
95
115
  invalidatedActivityIds?: string[]
96
116
  invalidatedEdgeIds?: string[]
97
117
  }
@@ -120,10 +140,18 @@ export interface RuntimeTransitionPresentation extends RuntimeTransition {
120
140
  label: string
121
141
  latest: boolean
122
142
  issues: string[]
143
+ invalidatedElementIds: string[]
123
144
  invalidatedActivityIds: string[]
124
145
  invalidatedEdgeIds: string[]
125
146
  action: RuntimeApprovalActionPresentation | null
126
147
  }
148
+ export interface ActivityVisitState {
149
+ status: ActivityStatus
150
+ records: ActivityInstance[]
151
+ latestRecords: ActivityInstance[]
152
+ visits: Array<{ id: string; round: number; records: ActivityInstance[]; effective?: boolean; superseded?: boolean }>
153
+ effectiveVisits: Array<{ id: string; round: number; records: ActivityInstance[]; effective?: boolean; superseded?: boolean }>
154
+ }
127
155
  export interface NodeRuntimePresentation extends RuntimeApprovalActionSummary {
128
156
  elementId: string
129
157
  status: ActivityStatus | 'rejected'
@@ -141,13 +169,15 @@ export interface NodeRuntimePresentation extends RuntimeApprovalActionSummary {
141
169
  round: number
142
170
  records: ActivityInstance[]
143
171
  latestRecords: ActivityInstance[]
144
- visits: Array<{ id: string; round: number; records: ActivityInstance[] } & RuntimeApprovalActionSummary>
172
+ visits: Array<{ id: string; round: number; records: ActivityInstance[]; effective: boolean; superseded: boolean } & RuntimeApprovalActionSummary>
173
+ effectiveVisits: Array<{ id: string; round: number; records: ActivityInstance[]; effective: boolean; superseded: boolean } & RuntimeApprovalActionSummary>
145
174
  transitions: RuntimeTransitionPresentation[]
146
175
  isReentry: boolean
147
176
  hasDetails: boolean
148
177
  }
149
178
  export interface RuntimePresentation {
150
179
  runtime: ProcessInstanceSnapshot
180
+ diagnostics: readonly RuntimeDiagnostic[]
151
181
  getNode(elementId: string): NodeRuntimePresentation
152
182
  getEdge(edgeId: string): { edgeId: string; status: ActivityStatus; visited: boolean; historicallyVisited: boolean; superseded: boolean }
153
183
  getAction(actionId: string): RuntimeApprovalActionPresentation | null
@@ -161,9 +191,13 @@ export interface RuntimeAppearanceLike {
161
191
  resolveTransition(transition?: { type?: string }): { tone: string }
162
192
  }
163
193
 
164
- export function normalizeRuntime(snapshot?: Partial<ProcessInstanceSnapshot> | null): ProcessInstanceSnapshot
165
- export function activityState(runtime: ProcessInstanceSnapshot | null | undefined, elementId: string): ActivityStatus
166
- export function createRuntimePresentation(context: { model: ProcessModel; runtime?: ProcessInstanceSnapshot | null; appearance?: RuntimeAppearanceLike | null }): RuntimePresentation
194
+ export function parseRuntimeInstant(value?: string | null): RuntimeInstant
195
+ export function formatRuntimeInstant(value?: string | RuntimeInstant | null): string
196
+ export function compareRuntimeOrder(left: { instant?: number | null; index?: number } | string | null | undefined, right: { instant?: number | null; index?: number } | string | null | undefined): number
197
+ export function normalizeRuntime(snapshot?: Partial<ProcessInstanceSnapshot> | null, options?: { diagnostics?: RuntimeDiagnostic[]; model?: ProcessModel | null }): ProcessInstanceSnapshot
198
+ export function activityState(runtime: ProcessInstanceSnapshot | null | undefined, elementId: string, options?: { diagnostics?: RuntimeDiagnostic[] }): ActivityVisitState
199
+ export function inspectRuntime(snapshot?: Partial<ProcessInstanceSnapshot> | null, options?: { model?: ProcessModel | null; appearance?: RuntimeAppearanceLike | null }): { runtime: ProcessInstanceSnapshot; diagnostics: readonly RuntimeDiagnostic[] }
200
+ export function createRuntimePresentation(context: { model: ProcessModel; runtime?: ProcessInstanceSnapshot | null; appearance?: RuntimeAppearanceLike | null; diagnostics?: RuntimeDiagnostic[] }): RuntimePresentation
167
201
  export function demoRuntime(): ProcessInstanceSnapshot
168
202
 
169
203
  export type RuntimeElement = BpmnNode | BpmnEdge
@@ -1,3 +1,10 @@
1
+ import { parseRuntimeInstant, formatRuntimeInstant, compareRuntimeOrder, runtimeInstant } from './time.js';
2
+ import { pushDiagnostic } from './diagnostics.js';
3
+ import { groupVisits, markEffectiveVisits, resolveEffectiveStatus, resolveVisitStatus } from './visits.js';
4
+
5
+ export { parseRuntimeInstant, formatRuntimeInstant, compareRuntimeOrder } from './time.js';
6
+ export { groupVisits, resolveVisitStatus, resolveEffectiveStatus } from './visits.js';
7
+
1
8
  const TERMINAL_PROCESS_STATUSES = new Set(['completed', 'terminated']);
2
9
  const ABNORMAL_TRANSITION_TYPES = new Set(['reject', 'return']);
3
10
  const APPROVAL_ACTION_LABELS = {
@@ -14,16 +21,21 @@ function normalizeParticipant(record = {}) {
14
21
  return null;
15
22
  }
16
23
 
24
+ function stampInstant(value) {
25
+ return runtimeInstant(value);
26
+ }
27
+
17
28
  function recordOrder(record, index) {
18
- const stamp = record.startTime || record.endTime;
19
- const parsed = stamp ? Date.parse(stamp.replace(' ', 'T')) : Number.NaN;
20
- return Number.isFinite(parsed) ? parsed : index;
29
+ return stampInstant(record.startTime || record.endTime);
21
30
  }
22
31
 
23
- function runtimeOrder(value, index) {
24
- const stamp = value?.occurredAt || value?.time || value?.endTime || value?.startTime;
25
- const parsed = stamp ? Date.parse(String(stamp).replace(' ', 'T')) : Number.NaN;
26
- return Number.isFinite(parsed) ? parsed : index;
32
+ function runtimeOrder(value) {
33
+ return stampInstant(value?.occurredAt || value?.time || value?.endTime || value?.startTime);
34
+ }
35
+
36
+ function isAfter(left, right) {
37
+ if (left == null || right == null) return false;
38
+ return left > right;
27
39
  }
28
40
 
29
41
  function normalizeRuntimeAsset(asset) {
@@ -107,12 +119,14 @@ function approvalActionLabel(action) {
107
119
  }
108
120
 
109
121
  function summarizeApprovalActions(actions = []) {
110
- const latestAction = [...actions].reverse().find((action) => action.plainText || action.assetCount) || actions.at(-1) || null;
122
+ const latestAction = actions.at(-1) || null;
123
+ const latestNonEmptyComment = [...actions].reverse().find((action) => action.plainText || action.assetCount) || null;
111
124
  const assetSummary = latestAction
112
125
  ? [latestAction.imageCount ? `${latestAction.imageCount} 张图片` : '', latestAction.fileCount ? `${latestAction.fileCount} 个附件` : ''].filter(Boolean).join(' · ')
113
126
  : '';
114
127
  return {
115
128
  latestAction,
129
+ latestNonEmptyComment,
116
130
  actionText: latestAction?.plainText || '',
117
131
  actionSummary: latestAction ? [latestAction.label, latestAction.plainText, assetSummary].filter(Boolean).join(' · ') : '',
118
132
  imageCount: actions.reduce((sum, action) => sum + action.imageCount, 0),
@@ -121,31 +135,6 @@ function summarizeApprovalActions(actions = []) {
121
135
  };
122
136
  }
123
137
 
124
- function groupVisits(records = []) {
125
- const groups = new Map();
126
- records.forEach((record, index) => {
127
- const key = record.visitId
128
- ? `visit:${record.visitId}`
129
- : record.multiInstanceId
130
- ? `multi:${record.multiInstanceId}`
131
- : `record:${record.id || index}`;
132
- if (!groups.has(key)) groups.set(key, { id: record.visitId || record.multiInstanceId || record.id || String(index), records: [], order: recordOrder(record, index) });
133
- const visit = groups.get(key);
134
- visit.records.push(record);
135
- visit.order = Math.max(visit.order, recordOrder(record, index));
136
- });
137
- return [...groups.values()].sort((a, b) => a.order - b.order).map((visit, index) => ({ ...visit, round: index + 1 }));
138
- }
139
-
140
- function resolveVisitStatus(records = []) {
141
- if (!records.length) return 'idle';
142
- if (records.some((item) => item.status === 'failed')) return 'failed';
143
- if (records.some((item) => item.status === 'active')) return 'active';
144
- if (records.every((item) => item.status === 'skipped')) return 'skipped';
145
- if (records.some((item) => item.status === 'cancelled') && !records.some((item) => item.status === 'completed')) return 'cancelled';
146
- return 'completed';
147
- }
148
-
149
138
  function uniqueParticipants(records = []) {
150
139
  const seen = new Set();
151
140
  const result = [];
@@ -180,16 +169,146 @@ function baseStatusLabel(status) {
180
169
  }[status] || status;
181
170
  }
182
171
 
183
- function transitionOrder(transition, index) {
184
- const stamp = transition.occurredAt || transition.time;
185
- const parsed = stamp ? Date.parse(stamp.replace(' ', 'T')) : Number.NaN;
186
- return Number.isFinite(parsed) ? parsed : index;
172
+ function transitionOrder(transition) {
173
+ return stampInstant(transition.occurredAt || transition.time);
174
+ }
175
+
176
+ function edgeVisitOrder(visit) {
177
+ return stampInstant(visit.occurredAt || visit.time);
178
+ }
179
+
180
+ function collectIdentityDiagnostics(runtime, { model = null, diagnostics }) {
181
+ const activityIds = new Set();
182
+ const actionIds = new Set();
183
+ const transitionIds = new Set();
184
+ const edgeVisitIds = new Set();
185
+ const visitOwners = new Map();
186
+ const nodeIds = new Set((model?.nodes || []).map((node) => node.id));
187
+ const edgeIds = new Set((model?.edges || []).map((edge) => edge.id));
188
+
189
+ runtime.activities.forEach((record, index) => {
190
+ const fieldPath = `activities[${index}]`;
191
+ if (record.id) {
192
+ if (activityIds.has(record.id)) pushDiagnostic(diagnostics, { code: 'duplicate-activity-id', level: 'error', message: '工作项 ID 重复。', fieldPath, activityId: record.id, elementId: record.elementId });
193
+ activityIds.add(record.id);
194
+ }
195
+ if (record.visitId) {
196
+ const owner = visitOwners.get(record.visitId);
197
+ if (owner && owner !== record.elementId) {
198
+ pushDiagnostic(diagnostics, { code: 'visit-element-mismatch', level: 'error', message: '同一 visitId 关联了不同 BPMN 元素。', fieldPath, visitId: record.visitId, elementId: record.elementId });
199
+ }
200
+ visitOwners.set(record.visitId, record.elementId);
201
+ }
202
+ ['startTime', 'endTime'].forEach((field) => {
203
+ const parsed = parseRuntimeInstant(record[field]);
204
+ if (parsed.kind === 'invalid') pushDiagnostic(diagnostics, { code: 'invalid-time', level: 'warning', message: '时间无法解析。', fieldPath: `${fieldPath}.${field}`, activityId: record.id, elementId: record.elementId });
205
+ else if (parsed.kind === 'naive') pushDiagnostic(diagnostics, { code: 'naive-time', level: 'info', message: '时间缺少时区,比较结果可能因环境而异。', fieldPath: `${fieldPath}.${field}`, activityId: record.id, elementId: record.elementId });
206
+ });
207
+ if (model && record.elementId && !nodeIds.has(record.elementId)) {
208
+ pushDiagnostic(diagnostics, { code: 'unknown-element', level: 'error', message: '工作项引用了不存在的 BPMN 元素。', fieldPath: `${fieldPath}.elementId`, activityId: record.id, elementId: record.elementId });
209
+ }
210
+ });
211
+
212
+ runtime.actions.forEach((action, index) => {
213
+ const fieldPath = `actions[${index}]`;
214
+ if (actionIds.has(action.id)) pushDiagnostic(diagnostics, { code: 'duplicate-action-id', level: 'error', message: '操作 ID 重复。', fieldPath, actionId: action.id, elementId: action.elementId });
215
+ actionIds.add(action.id);
216
+ if (action.activityId && !activityIds.has(action.activityId) && !String(action.id).startsWith('legacy-')) {
217
+ pushDiagnostic(diagnostics, { code: 'unknown-activity', level: 'error', message: '操作引用了不存在的工作项。', fieldPath: `${fieldPath}.activityId`, actionId: action.id, activityId: action.activityId, elementId: action.elementId });
218
+ }
219
+ const record = runtime.activities.find((item) => item.id && item.id === action.activityId);
220
+ if (record && ((action.elementId && record.elementId !== action.elementId) || (action.visitId && record.visitId && record.visitId !== action.visitId))) {
221
+ pushDiagnostic(diagnostics, { code: 'action-identity-mismatch', level: 'error', message: '操作与工作项的元素或 Visit 不一致。', fieldPath, actionId: action.id, activityId: action.activityId, elementId: action.elementId, visitId: action.visitId });
222
+ }
223
+ const parsed = parseRuntimeInstant(action.occurredAt);
224
+ if (parsed.kind === 'invalid') pushDiagnostic(diagnostics, { code: 'invalid-time', level: 'warning', message: '操作时间无法解析。', fieldPath: `${fieldPath}.occurredAt`, actionId: action.id });
225
+ else if (parsed.kind === 'naive') pushDiagnostic(diagnostics, { code: 'naive-time', level: 'info', message: '操作时间缺少时区。', fieldPath: `${fieldPath}.occurredAt`, actionId: action.id });
226
+ else if (parsed.kind === 'missing' && !String(action.id).startsWith('legacy-')) {
227
+ pushDiagnostic(diagnostics, { code: 'missing-time', level: 'warning', message: '操作缺少时间,排序仅用于展示。', fieldPath: `${fieldPath}.occurredAt`, actionId: action.id });
228
+ }
229
+ });
230
+
231
+ runtime.transitions.forEach((transition, index) => {
232
+ const fieldPath = `transitions[${index}]`;
233
+ const id = transition.id || `runtime-transition-${index + 1}`;
234
+ if (transitionIds.has(id)) pushDiagnostic(diagnostics, { code: 'duplicate-transition-id', level: 'error', message: '跳转 ID 重复。', fieldPath, transitionId: id });
235
+ transitionIds.add(id);
236
+ if (transition.sourceActivityId && !activityIds.has(transition.sourceActivityId)) {
237
+ pushDiagnostic(diagnostics, { code: 'unknown-activity', level: 'error', message: '跳转引用了不存在的来源工作项。', fieldPath: `${fieldPath}.sourceActivityId`, transitionId: id, activityId: transition.sourceActivityId, elementId: transition.sourceElementId });
238
+ }
239
+ if (transition.actionId && !actionIds.has(transition.actionId) && !String(transition.actionId).startsWith('legacy-')) {
240
+ pushDiagnostic(diagnostics, { code: 'unknown-action', level: 'error', message: '跳转引用了不存在的操作。', fieldPath: `${fieldPath}.actionId`, transitionId: id, actionId: transition.actionId });
241
+ }
242
+ if (model && transition.sourceElementId && !nodeIds.has(transition.sourceElementId)) {
243
+ pushDiagnostic(diagnostics, { code: 'unknown-element', level: 'error', message: '跳转来源元素不存在。', fieldPath: `${fieldPath}.sourceElementId`, transitionId: id, elementId: transition.sourceElementId });
244
+ }
245
+ if (model && transition.targetElementId && !nodeIds.has(transition.targetElementId)) {
246
+ pushDiagnostic(diagnostics, { code: 'unknown-element', level: 'error', message: '跳转目标元素不存在。', fieldPath: `${fieldPath}.targetElementId`, transitionId: id, elementId: transition.targetElementId });
247
+ }
248
+ const parsed = parseRuntimeInstant(transition.occurredAt || transition.time);
249
+ if ((transition.occurredAt || transition.time) && parsed.kind === 'invalid') {
250
+ pushDiagnostic(diagnostics, { code: 'invalid-time', level: 'warning', message: '跳转时间无法解析。', fieldPath: `${fieldPath}.occurredAt`, transitionId: id });
251
+ } else if (parsed.kind === 'naive') {
252
+ pushDiagnostic(diagnostics, { code: 'naive-time', level: 'info', message: '跳转时间缺少时区。', fieldPath: `${fieldPath}.occurredAt`, transitionId: id });
253
+ }
254
+ });
255
+
256
+ runtime.edgeVisits.forEach((visit, index) => {
257
+ if (visit.id) {
258
+ if (edgeVisitIds.has(visit.id)) pushDiagnostic(diagnostics, { code: 'duplicate-edge-visit-id', level: 'error', message: '连线经过 ID 重复。', fieldPath: `edgeVisits[${index}]`, edgeId: visit.edgeId });
259
+ edgeVisitIds.add(visit.id);
260
+ }
261
+ if (model && visit.edgeId && !edgeIds.has(visit.edgeId)) {
262
+ pushDiagnostic(diagnostics, { code: 'unknown-edge', level: 'error', message: '连线经过引用了不存在的 BPMN 连线。', fieldPath: `edgeVisits[${index}].edgeId`, edgeId: visit.edgeId });
263
+ }
264
+ });
187
265
  }
188
266
 
189
- function edgeVisitOrder(visit, index) {
190
- const stamp = visit.occurredAt || visit.time;
191
- const parsed = stamp ? Date.parse(stamp.replace(' ', 'T')) : Number.NaN;
192
- return Number.isFinite(parsed) ? parsed : index;
267
+ function resolveInvalidatedElements(transition, { activities, model, diagnostics }) {
268
+ const legacy = Array.isArray(transition.invalidatedActivityIds) ? transition.invalidatedActivityIds.map(String) : null;
269
+ const next = Array.isArray(transition.invalidatedElementIds) ? transition.invalidatedElementIds.map(String) : null;
270
+ const activityIds = new Set(activities.map((record) => record.id).filter(Boolean));
271
+ const nodeIds = new Set((model?.nodes || []).map((node) => node.id));
272
+ const warnIds = (ids, field) => {
273
+ ids.forEach((id) => {
274
+ if (activityIds.has(id) && !nodeIds.has(id)) {
275
+ pushDiagnostic(diagnostics, {
276
+ code: 'invalidation-work-item-id',
277
+ level: 'warning',
278
+ message: '失效字段收到工作项 ID,该字段只接受 BPMN 元素 ID。',
279
+ fieldPath: `transitions.${transition.id}.${field}`,
280
+ transitionId: transition.id,
281
+ activityId: id,
282
+ });
283
+ } else if (model && !nodeIds.has(id)) {
284
+ pushDiagnostic(diagnostics, {
285
+ code: 'unknown-element',
286
+ level: 'error',
287
+ message: '失效范围引用了不存在的 BPMN 元素。',
288
+ fieldPath: `transitions.${transition.id}.${field}`,
289
+ transitionId: transition.id,
290
+ elementId: id,
291
+ });
292
+ }
293
+ });
294
+ };
295
+ if (legacy && next) {
296
+ const same = legacy.length === next.length && [...legacy].sort().every((id, index) => id === [...next].sort()[index]);
297
+ if (!same) {
298
+ pushDiagnostic(diagnostics, {
299
+ code: 'invalidation-field-conflict',
300
+ level: 'warning',
301
+ message: 'invalidatedElementIds 与 invalidatedActivityIds 不一致,已按元素字段生效。',
302
+ fieldPath: `transitions.${transition.id}.invalidatedElementIds`,
303
+ transitionId: transition.id,
304
+ });
305
+ }
306
+ warnIds(next, 'invalidatedElementIds');
307
+ return [...next];
308
+ }
309
+ const resolved = [...(next || legacy || [])];
310
+ warnIds(resolved, next ? 'invalidatedElementIds' : 'invalidatedActivityIds');
311
+ return resolved;
193
312
  }
194
313
 
195
314
  function inferInvalidatedPath(model, runtime, transition) {
@@ -227,8 +346,9 @@ function inferInvalidatedPath(model, runtime, transition) {
227
346
  return { activityIds: paths[0].nodeIds.slice(1), edgeIds: paths[0].edgeIds, issues: [] };
228
347
  }
229
348
 
230
- export function normalizeRuntime(snapshot = {}) {
349
+ export function normalizeRuntime(snapshot = {}, options = {}) {
231
350
  snapshot = snapshot || {};
351
+ const diagnostics = options.diagnostics || null;
232
352
  const activities = Array.isArray(snapshot.activities)
233
353
  ? snapshot.activities.map((record) => ({ ...record, participant: normalizeParticipant(record) || undefined }))
234
354
  : [];
@@ -274,10 +394,13 @@ export function normalizeRuntime(snapshot = {}) {
274
394
  ? snapshot.edgeVisits.map((visit, index) => ({ id: visit.id || `runtime-edge-visit-${index + 1}`, status: visit.status || 'effective', ...visit }))
275
395
  : [];
276
396
  const orderedActions = actions
277
- .map((action, index) => ({ action, order: runtimeOrder(action, index), index }))
278
- .sort((a, b) => (a.order - b.order) || (a.index - b.index))
397
+ .map((action, index) => ({ action, index }))
398
+ .sort((left, right) => compareRuntimeOrder(
399
+ { instant: runtimeOrder(left.action), index: left.index },
400
+ { instant: runtimeOrder(right.action), index: right.index },
401
+ ))
279
402
  .map(({ action }) => action);
280
- return {
403
+ const normalized = {
281
404
  ...snapshot,
282
405
  processInstanceId: snapshot.processInstanceId || '',
283
406
  status: snapshot.status || 'running',
@@ -287,20 +410,45 @@ export function normalizeRuntime(snapshot = {}) {
287
410
  transitions,
288
411
  edgeVisits,
289
412
  };
413
+ if (diagnostics) collectIdentityDiagnostics(normalized, { model: options.model, diagnostics });
414
+ return normalized;
290
415
  }
291
416
 
292
- export function activityState(runtime, elementId) {
293
- if (!runtime) return { status: 'idle', records: [], latestRecords: [], visits: [] };
417
+ export function activityState(runtime, elementId, options = {}) {
418
+ if (!runtime) return { status: 'idle', records: [], latestRecords: [], visits: [], effectiveVisits: [] };
294
419
  const records = runtime.activities.filter((item) => item.elementId === elementId);
295
- const visits = groupVisits(records);
296
- const latestRecords = visits.at(-1)?.records || [];
297
- return { status: resolveVisitStatus(latestRecords), records, latestRecords, visits };
420
+ const visits = markEffectiveVisits(groupVisits(records), {
421
+ elementId,
422
+ transitions: runtime.transitions || [],
423
+ diagnostics: options.diagnostics || null,
424
+ });
425
+ const effectiveVisits = visits.filter((visit) => visit.effective);
426
+ const latestRecords = (effectiveVisits.at(-1) || visits.at(-1))?.records || [];
427
+ return {
428
+ status: resolveEffectiveStatus(effectiveVisits.length ? effectiveVisits : visits),
429
+ records,
430
+ latestRecords,
431
+ visits,
432
+ effectiveVisits,
433
+ };
298
434
  }
299
435
 
300
- export function createRuntimePresentation({ model, runtime, appearance = null } = {}) {
301
- const normalized = normalizeRuntime(runtime);
436
+ export function inspectRuntime(snapshot, options = {}) {
437
+ const diagnostics = [];
438
+ const presentation = createRuntimePresentation({
439
+ model: options.model || { id: '', nodes: [], edges: [] },
440
+ runtime: snapshot,
441
+ appearance: options.appearance || null,
442
+ diagnostics,
443
+ });
444
+ return { runtime: presentation.runtime, diagnostics: presentation.diagnostics };
445
+ }
446
+
447
+ export function createRuntimePresentation({ model, runtime, appearance = null, diagnostics = null } = {}) {
448
+ const collected = diagnostics || [];
449
+ const normalized = normalizeRuntime(runtime, { diagnostics: collected, model });
302
450
  const nodes = new Map((model?.nodes || []).map((node) => [node.id, node]));
303
- const activityStates = new Map((model?.nodes || []).map((node) => [node.id, activityState(normalized, node.id)]));
451
+ const activityStates = new Map((model?.nodes || []).map((node) => [node.id, activityState(normalized, node.id, { diagnostics: collected })]));
304
452
  const nodePresentations = new Map();
305
453
  const actionPresentations = normalized.actions.map((action, index) => {
306
454
  const assets = action.content.assets || [];
@@ -319,7 +467,7 @@ export function createRuntimePresentation({ model, runtime, appearance = null }
319
467
  imageCount: images.length,
320
468
  fileCount: files.length,
321
469
  assetCount: images.length + files.length,
322
- order: runtimeOrder(action, index),
470
+ order: runtimeOrder(action) ?? index,
323
471
  };
324
472
  const resolvedAppearance = appearance?.resolveAction?.(baseAction);
325
473
  return resolvedAppearance
@@ -328,20 +476,27 @@ export function createRuntimePresentation({ model, runtime, appearance = null }
328
476
  });
329
477
  const actionMap = new Map(actionPresentations.map((action) => [action.id, action]));
330
478
  const rawTransitions = normalized.transitions
331
- .map((transition, index) => ({ ...transition, order: transitionOrder(transition, index) }))
332
- .sort((a, b) => a.order - b.order);
479
+ .map((transition, index) => ({ ...transition, order: transitionOrder(transition) ?? index }))
480
+ .sort((left, right) => compareRuntimeOrder(
481
+ { instant: transitionOrder(left), index: left.order },
482
+ { instant: transitionOrder(right), index: right.order },
483
+ ));
333
484
  const transitionPresentations = rawTransitions.map((transition) => {
334
485
  const targetName = nodes.get(transition.targetElementId)?.name || transition.targetElementId || '';
335
486
  const labelPrefix = transition.type === 'reject' ? '驳回至' : transition.type === 'return' ? '退回至' : transition.type === 'skip' ? '跳过' : '流转至';
336
487
  const targetState = activityStates.get(transition.targetElementId);
337
- const targetRecordsAfter = (targetState?.records || []).filter((record, index) => recordOrder(record, index) > transition.order);
488
+ const targetRecordsAfter = (targetState?.records || []).filter((record) => isAfter(recordOrder(record), transition.order));
338
489
  const targetStatusAfter = resolveVisitStatus(groupVisits(targetRecordsAfter).at(-1)?.records || []);
339
490
  const state = transition.state || (ABNORMAL_TRANSITION_TYPES.has(transition.type)
340
491
  ? (targetRecordsAfter.length && targetStatusAfter !== 'active' ? 'resolved' : 'active')
341
492
  : 'resolved');
342
- const hasExplicitInvalidation = Array.isArray(transition.invalidatedActivityIds) || Array.isArray(transition.invalidatedEdgeIds);
493
+ const hasExplicitInvalidation = Array.isArray(transition.invalidatedElementIds) || Array.isArray(transition.invalidatedActivityIds) || Array.isArray(transition.invalidatedEdgeIds);
343
494
  const invalidation = hasExplicitInvalidation
344
- ? { activityIds: [...(transition.invalidatedActivityIds || [])], edgeIds: [...(transition.invalidatedEdgeIds || [])], issues: [] }
495
+ ? {
496
+ activityIds: resolveInvalidatedElements(transition, { activities: normalized.activities, model, diagnostics: collected }),
497
+ edgeIds: [...(transition.invalidatedEdgeIds || [])],
498
+ issues: [],
499
+ }
345
500
  : ABNORMAL_TRANSITION_TYPES.has(transition.type)
346
501
  ? inferInvalidatedPath(model, normalized, transition)
347
502
  : { activityIds: [], edgeIds: [], issues: [] };
@@ -354,6 +509,7 @@ export function createRuntimePresentation({ model, runtime, appearance = null }
354
509
  state,
355
510
  visible: ABNORMAL_TRANSITION_TYPES.has(transition.type) && state === 'active',
356
511
  resolvedAt: transition.resolvedAt || (state === 'resolved' ? targetRecordsAfter.map((record) => record.endTime).filter(Boolean).at(-1) : undefined),
512
+ invalidatedElementIds: invalidation.activityIds,
357
513
  invalidatedActivityIds: invalidation.activityIds,
358
514
  invalidatedEdgeIds: invalidation.edgeIds,
359
515
  issues: invalidation.issues,
@@ -365,15 +521,38 @@ export function createRuntimePresentation({ model, runtime, appearance = null }
365
521
  const invalidatedNodes = new Map();
366
522
  const invalidatedEdges = new Map();
367
523
  const hasActivityAfter = (elementId, transition) => (activityStates.get(elementId)?.records || [])
368
- .some((record, index) => recordOrder(record, index) > transition.order);
524
+ .some((record) => isAfter(recordOrder(record), transition.order));
369
525
  const hasEdgeVisitAfter = (edgeId, transition) => {
370
526
  const edgeVisits = normalized.edgeVisits.filter((visit) => visit.edgeId === edgeId);
371
- if (edgeVisits.length) return edgeVisits.some((visit, index) => visit.status !== 'superseded' && edgeVisitOrder(visit, index) > transition.order);
527
+ if (edgeVisits.length) return edgeVisits.some((visit) => visit.status !== 'superseded' && isAfter(edgeVisitOrder(visit), transition.order));
372
528
  const edge = model?.edges?.find((item) => item.id === edgeId);
373
529
  return edge ? hasActivityAfter(edge.target, transition) : false;
374
530
  };
375
531
  for (const transition of transitionPresentations.filter((item) => ABNORMAL_TRANSITION_TYPES.has(item.type))) {
376
- for (const elementId of transition.invalidatedActivityIds) if (!hasActivityAfter(elementId, transition)) invalidatedNodes.set(elementId, transition);
532
+ for (const elementId of transition.invalidatedActivityIds) {
533
+ const state = activityStates.get(elementId);
534
+ const concurrent = (state?.effectiveVisits || []).length > 1;
535
+ if (concurrent) {
536
+ pushDiagnostic(collected, {
537
+ code: 'element-invalidation-with-concurrent-visits',
538
+ level: 'warning',
539
+ message: '元素级失效遇到多个有效 Visit,未取消无关的并发执行。',
540
+ fieldPath: `transitions.${transition.id}.invalidatedElementIds`,
541
+ transitionId: transition.id,
542
+ elementId,
543
+ });
544
+ const endedBefore = (state?.visits || []).filter((visit) => {
545
+ const ended = visit.records.map((record) => recordOrder(record)).filter((value) => value != null);
546
+ const last = ended.length ? Math.max(...ended) : null;
547
+ return last != null && last <= transition.order;
548
+ });
549
+ if (endedBefore.length && endedBefore.length < (state?.visits || []).length && !hasActivityAfter(elementId, transition)) {
550
+ invalidatedNodes.set(elementId, transition);
551
+ }
552
+ continue;
553
+ }
554
+ if (!hasActivityAfter(elementId, transition)) invalidatedNodes.set(elementId, transition);
555
+ }
377
556
  for (const edgeId of transition.invalidatedEdgeIds) if (!hasEdgeVisitAfter(edgeId, transition)) invalidatedEdges.set(edgeId, transition);
378
557
  }
379
558
 
@@ -384,32 +563,40 @@ export function createRuntimePresentation({ model, runtime, appearance = null }
384
563
  const visitActions = actions.filter((action) => action.visitId === visit.id);
385
564
  return { ...visit, actions: visitActions, ...summarizeApprovalActions(visitActions) };
386
565
  });
387
- const latestVisit = visits.at(-1) || null;
566
+ const effectiveVisits = visits.filter((visit) => visit.effective);
567
+ const latestVisit = effectiveVisits.at(-1) || visits.at(-1) || null;
388
568
  const latestRecords = latestVisit?.records || [];
389
- const participants = uniqueParticipants(latestRecords);
569
+ const currentRecords = effectiveVisits.flatMap((visit) => visit.records);
570
+ const currentActions = effectiveVisits.flatMap((visit) => visit.actions);
571
+ const participants = uniqueParticipants(currentRecords.length ? currentRecords : latestRecords);
390
572
  const approvalMode = latestRecords.find((item) => item.approvalMode)?.approvalMode || null;
391
573
  const multiInstanceMode = latestRecords.find((item) => item.multiInstanceMode)?.multiInstanceMode || null;
392
574
  const declaredTotal = latestRecords.reduce((max, item) => Math.max(max, Number(item.totalInstances) || 0), 0);
393
- const total = Math.max(declaredTotal, latestRecords.length, participants.length);
575
+ const total = Math.max(declaredTotal, latestRecords.length, uniqueParticipants(latestRecords).length);
394
576
  const completed = latestRecords.filter((item) => item.status === 'completed').length;
395
577
  const required = latestRecords.reduce((max, item) => Math.max(max, Number(item.requiredInstances) || 0), 0) || (approvalMode === 'any' ? 1 : total);
396
578
  const inboundReentry = transitionPresentations.some((item) => ABNORMAL_TRANSITION_TYPES.has(item.type) && item.targetElementId === node.id);
397
579
  const latestSourceRejection = [...transitionPresentations].reverse().find((item) => item.type === 'reject' && item.sourceElementId === node.id);
398
580
  const rejectionSource = latestRecords.some((item) => item.outcome === 'rejected')
399
- || (latestSourceRejection && (!latestVisit || latestSourceRejection.order >= latestVisit.order));
581
+ || (latestSourceRejection && (!latestVisit || (latestSourceRejection.order ?? 0) >= (recordOrder(latestRecords.at(-1) || {}) ?? 0)));
400
582
  const skipped = !state.records.length && transitionPresentations.some((item) => item.type === 'skip' && item.targetElementId === node.id);
401
583
  let status = skipped ? 'skipped' : state.status;
402
- if (rejectionSource && status === 'completed') status = 'rejected';
584
+ if (rejectionSource && status === 'completed' && effectiveVisits.length <= 1) status = 'rejected';
403
585
  const isReentry = inboundReentry && Boolean(latestVisit) && (latestVisit.round > 1 || status === 'active');
404
586
  const superseded = invalidatedNodes.has(node.id);
405
587
  const pathStatus = superseded || status === 'rejected' ? 'idle' : status;
406
588
 
407
589
  let statusLabel = baseStatusLabel(status);
408
- if (isReentry && status === 'active') statusLabel = '重新审批';
590
+ const activeVisitCount = effectiveVisits.filter((visit) => resolveVisitStatus(visit.records) === 'active').length;
591
+ if (effectiveVisits.length > 1 && activeVisitCount) {
592
+ statusLabel = activeVisitCount === effectiveVisits.length
593
+ ? `处理中 ${activeVisitCount} 项执行`
594
+ : `处理中 ${activeVisitCount}/${effectiveVisits.length} 项执行`;
595
+ } else if (isReentry && status === 'active') statusLabel = '重新审批';
409
596
  else if (status === 'active' || status === 'completed') {
410
597
  if (approvalMode === 'all') statusLabel = `${multiInstanceMode === 'sequential' ? '顺序会签' : '会签'} ${completed}/${total || required}`;
411
598
  else if (approvalMode === 'any') statusLabel = `或签 ${completed}/${total || required}`;
412
- else if (participants.length > 1) statusLabel = `多人审批 ${completed}/${total}`;
599
+ else if (participants.length > 1 && effectiveVisits.length <= 1) statusLabel = `多人审批 ${completed}/${total}`;
413
600
  }
414
601
 
415
602
  let summary = participantSummary(participants);
@@ -424,30 +611,35 @@ export function createRuntimePresentation({ model, runtime, appearance = null }
424
611
  }
425
612
 
426
613
  const relatedTransitions = transitionPresentations.filter((item) => item.sourceElementId === node.id || item.targetElementId === node.id);
427
- const actionSummary = summarizeApprovalActions(actions);
614
+ const currentSummary = summarizeApprovalActions(currentActions);
615
+ const historySummary = summarizeApprovalActions(actions);
428
616
  nodePresentations.set(node.id, {
429
617
  elementId: node.id, status, pathStatus, superseded, statusLabel, summary, fullSummary: participants.map((item) => item.name).join('、') || summary,
430
618
  participants, approvalMode, multiInstanceMode, completed, total, required, round: latestVisit?.round || 0,
431
- records: state.records, latestRecords, visits, transitions: relatedTransitions, actions, ...actionSummary,
619
+ records: state.records, latestRecords, visits, effectiveVisits, transitions: relatedTransitions, actions,
620
+ ...currentSummary,
621
+ latestNonEmptyComment: historySummary.latestNonEmptyComment,
432
622
  isReentry,
433
623
  hasDetails: Boolean(state.records.length || actions.length || relatedTransitions.length),
434
624
  });
435
625
  }
436
626
 
627
+ const frozenDiagnostics = Object.freeze(collected.slice());
628
+ const emptyNode = (elementId) => ({
629
+ elementId, status: 'idle', pathStatus: 'idle', superseded: false, statusLabel: '未到达', summary: '', fullSummary: '', participants: [], approvalMode: null,
630
+ multiInstanceMode: null, completed: 0, total: 0, required: 0, round: 0, records: [], latestRecords: [], visits: [], effectiveVisits: [], transitions: [], actions: [], latestAction: null,
631
+ latestNonEmptyComment: null, actionText: '', actionSummary: '', imageCount: 0, fileCount: 0, assetCount: 0, isReentry: false, hasDetails: false,
632
+ });
633
+
437
634
  return {
438
635
  runtime: normalized,
439
- getNode(elementId) {
440
- return nodePresentations.get(elementId) || {
441
- elementId, status: 'idle', pathStatus: 'idle', superseded: false, statusLabel: '未到达', summary: '', fullSummary: '', participants: [], approvalMode: null,
442
- multiInstanceMode: null, completed: 0, total: 0, required: 0, round: 0, records: [], latestRecords: [], visits: [], transitions: [], actions: [], latestAction: null,
443
- actionText: '', actionSummary: '', imageCount: 0, fileCount: 0, assetCount: 0, isReentry: false, hasDetails: false,
444
- };
445
- },
636
+ diagnostics: frozenDiagnostics,
637
+ getNode(elementId) { return nodePresentations.get(elementId) || emptyNode(elementId); },
446
638
  getEdge(edgeId) {
447
639
  const edgeVisits = normalized.edgeVisits.filter((visit) => visit.edgeId === edgeId);
448
640
  const latestVisit = edgeVisits
449
- .map((visit, index) => ({ visit, order: edgeVisitOrder(visit, index) }))
450
- .sort((a, b) => a.order - b.order)
641
+ .map((visit, index) => ({ visit, index, instant: edgeVisitOrder(visit) }))
642
+ .sort((left, right) => compareRuntimeOrder(left, right))
451
643
  .at(-1)?.visit;
452
644
  const visited = normalized.visitedEdges.includes(edgeId) || edgeVisits.length > 0;
453
645
  const superseded = invalidatedEdges.has(edgeId) || latestVisit?.status === 'superseded';
@@ -0,0 +1,41 @@
1
+ const OFFSET_PATTERN = /(?:Z|[+-]\d{2}:?\d{2})$/i;
2
+
3
+ export function parseRuntimeInstant(value) {
4
+ if (value == null || value === '') return { instant: null, raw: '', kind: 'missing' };
5
+ const raw = String(value);
6
+ const trimmed = raw.trim();
7
+ if (!trimmed) return { instant: null, raw, kind: 'missing' };
8
+ const hasOffset = OFFSET_PATTERN.test(trimmed);
9
+ const normalized = !trimmed.includes('T') && trimmed.includes(' ') ? trimmed.replace(' ', 'T') : trimmed;
10
+ const instant = Date.parse(normalized);
11
+ if (!Number.isFinite(instant)) return { instant: null, raw, kind: 'invalid' };
12
+ if (hasOffset) return { instant, raw, kind: /Z$/i.test(trimmed) ? 'z' : 'offset' };
13
+ return { instant, raw, kind: 'naive' };
14
+ }
15
+
16
+ export function formatRuntimeInstant(value) {
17
+ const parsed = value && typeof value === 'object' && 'raw' in value ? value : parseRuntimeInstant(value);
18
+ if (!parsed?.raw) return '';
19
+ return String(parsed.raw).trim().replace('T', ' ');
20
+ }
21
+
22
+ export function compareRuntimeOrder(left, right) {
23
+ const a = normalizeOrderKey(left);
24
+ const b = normalizeOrderKey(right);
25
+ if (a.instant != null && b.instant != null && a.instant !== b.instant) return a.instant - b.instant;
26
+ if (a.instant != null && b.instant == null) return -1;
27
+ if (a.instant == null && b.instant != null) return 1;
28
+ return (a.index || 0) - (b.index || 0);
29
+ }
30
+
31
+ export function runtimeInstant(value) {
32
+ return parseRuntimeInstant(value).instant;
33
+ }
34
+
35
+ function normalizeOrderKey(value) {
36
+ if (value && typeof value === 'object' && ('instant' in value || 'index' in value || 'kind' in value)) {
37
+ return { instant: value.instant ?? null, index: Number.isFinite(value.index) ? value.index : 0, kind: value.kind };
38
+ }
39
+ const parsed = parseRuntimeInstant(value);
40
+ return { instant: parsed.instant, index: 0, kind: parsed.kind };
41
+ }