@fyeeme/pi-dynamic-workflows 0.1.1
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.
- package/LICENSE +21 -0
- package/README.md +373 -0
- package/README.zh-CN.md +359 -0
- package/index.ts +650 -0
- package/package.json +58 -0
- package/sessions/spawn.ts +15 -0
- package/src/agent/dispatch.ts +76 -0
- package/src/budget/caps.ts +42 -0
- package/src/budget/index.ts +8 -0
- package/src/budget/pool.ts +118 -0
- package/src/cache/index.ts +7 -0
- package/src/cache/journal.ts +184 -0
- package/src/cache/key.ts +97 -0
- package/src/determinism/ast-guard.ts +196 -0
- package/src/errors.ts +55 -0
- package/src/format.ts +27 -0
- package/src/index.ts +28 -0
- package/src/inspect.ts +237 -0
- package/src/lifecycle.ts +75 -0
- package/src/loader.ts +50 -0
- package/src/outcomes.ts +113 -0
- package/src/planner.ts +66 -0
- package/src/runner/index.ts +188 -0
- package/src/runner/stage-executor.ts +1078 -0
- package/src/state/index.ts +1 -0
- package/src/state/names.ts +33 -0
- package/src/types.ts +332 -0
- package/src/ui-groups.ts +43 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { generateRunId, type RunIdInput } from "./names.ts";
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run identity — deterministic by construction.
|
|
3
|
+
*
|
|
4
|
+
* Claude Code's workflow engine bans Date.now()/Math.random()/new Date()
|
|
5
|
+
* inside workflow scripts because non-deterministic id generation breaks
|
|
6
|
+
* resume cache hits: same script → different runId → different cache keys
|
|
7
|
+
* → the journal never replays. The removed pi prototype used
|
|
8
|
+
* `run-${Date.now()}-${Math.random()}` exactly this anti-pattern.
|
|
9
|
+
*
|
|
10
|
+
* This module is the fix: `generateRunId` is a PURE function of
|
|
11
|
+
* (timestamp, sequence), both supplied by the caller. The runtime obtains the
|
|
12
|
+
* timestamp once at run inception and passes it down; workflow bodies never
|
|
13
|
+
* read "now" themselves (the ast-guard in `determinism/` enforces that).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export interface RunIdInput {
|
|
17
|
+
/** Inception epoch ms. Supplied by the caller; never read from Date.now() here. */
|
|
18
|
+
readonly timestamp: number;
|
|
19
|
+
/** Monotonic per-caller counter. Disambiguates runs that share a timestamp. */
|
|
20
|
+
readonly sequence: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Build a run id deterministically. Same (timestamp, sequence) → identical id.
|
|
25
|
+
*
|
|
26
|
+
* Format mirrors the removed prototype's `run-<base36>-<base36>` shape, but the
|
|
27
|
+
* second slot is a zero-padded sequence (was `Math.random().slice(2,8)`).
|
|
28
|
+
*/
|
|
29
|
+
export function generateRunId(input: RunIdInput): string {
|
|
30
|
+
const ts = input.timestamp.toString(36);
|
|
31
|
+
const seq = input.sequence.toString(36).padStart(4, "0");
|
|
32
|
+
return `run-${ts}-${seq}`;
|
|
33
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core runtime types for pi-dynamic-workflows.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the OpenSpec workflow-definition / workflow-runtime specs, with
|
|
5
|
+
* extension points reserved for the Claude Code coordination mechanisms that
|
|
6
|
+
* land in later tasks (deterministic sandbox, cache-key resume, per-agent
|
|
7
|
+
* abort). Pure TypeScript here — no external schema dependency yet; the
|
|
8
|
+
* TypeBox / Standard Schema adapter is introduced when step factories need
|
|
9
|
+
* runtime validation.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Stage primitives (10 per implementation)
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
import type { ErrorCategory } from "./errors.ts";
|
|
17
|
+
|
|
18
|
+
export type StageType =
|
|
19
|
+
| "fan_out"
|
|
20
|
+
| "agent"
|
|
21
|
+
| "code"
|
|
22
|
+
| "log"
|
|
23
|
+
| "loop_until"
|
|
24
|
+
| "adversarial"
|
|
25
|
+
| "tournament"
|
|
26
|
+
| "classify_route"
|
|
27
|
+
| "sub_workflow"
|
|
28
|
+
| "loop_until_dry";
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Budget
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Per-run resource limits. These are SPAWN-GATE SOFT LIMITS, not hard kills:
|
|
36
|
+
* the runtime checks them before committing a new agent spawn (guardSpawn /
|
|
37
|
+
* guardBatch) and stops spawning new agents when exhausted, but an agent
|
|
38
|
+
* already in flight is allowed to complete — its spend may push cumulative
|
|
39
|
+
* usage past the cap. `maxDurationMs` is wall-clock from run inception.
|
|
40
|
+
*
|
|
41
|
+
* Task 6 note: originally a static bag; the live tracker is `BudgetPool`. */
|
|
42
|
+
export interface Budget {
|
|
43
|
+
readonly maxTokens?: number;
|
|
44
|
+
readonly maxDurationMs?: number;
|
|
45
|
+
readonly maxAgents?: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Step statistics + result
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
export interface StepStats {
|
|
53
|
+
readonly tokens: number;
|
|
54
|
+
readonly cost: number;
|
|
55
|
+
readonly durationMs: number;
|
|
56
|
+
readonly agents: number;
|
|
57
|
+
readonly failures: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface StepResult<T = unknown> {
|
|
61
|
+
readonly id: string;
|
|
62
|
+
readonly type: StageType;
|
|
63
|
+
readonly status: "done" | "failed" | "skipped" | "running";
|
|
64
|
+
readonly results: T;
|
|
65
|
+
readonly stats: StepStats;
|
|
66
|
+
/** Present only for loop_until / adversarial / tournament. */
|
|
67
|
+
readonly iterations?: number;
|
|
68
|
+
/** A5: category of the terminal failure (dispatch-error for agent dispatch /
|
|
69
|
+
* subprocess failures; propagated from nested classify_route / sub_workflow
|
|
70
|
+
* children). Absent when the failure must not auto-retry — a code transform
|
|
71
|
+
* error or a terminal category (size-limit, determinism, …). */
|
|
72
|
+
readonly errorCategory?: ErrorCategory;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// Step context — inter-step references (spec: ctx.input + ctx.step(id))
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
export interface StepContext {
|
|
80
|
+
readonly input: unknown;
|
|
81
|
+
step(id: string): { results: unknown; stats: StepStats };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Workflow definition (defineWorkflow is a typed identity function)
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Concrete step payloads — a discriminated union on `type`. All ten step
|
|
90
|
+
* types are implemented in the runner (src/runner/stage-executor.ts): the four
|
|
91
|
+
* core primitives (agent/code/fan_out/loop_until) plus three composites
|
|
92
|
+
* (adversarial/tournament/classify_route) expanded onto them.
|
|
93
|
+
*/
|
|
94
|
+
export interface StepBase {
|
|
95
|
+
readonly id: string;
|
|
96
|
+
readonly retry?: StepRetry;
|
|
97
|
+
/** Budget-exhaustion policy for this step. "throw" (default) aborts the run with
|
|
98
|
+
* BudgetExceededError; "null" returns null for this step so siblings/downstream
|
|
99
|
+
* can continue (the run result records degraded steps). The atomic reserve
|
|
100
|
+
* still enforces the agent-count cap regardless of this setting. */
|
|
101
|
+
readonly onBudgetExhaust?: "throw" | "null";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** agent: one LLM call (dispatched via the injectable AgentDispatch). */
|
|
105
|
+
export interface AgentStep extends StepBase {
|
|
106
|
+
readonly type: "agent";
|
|
107
|
+
readonly prompt: string | ((ctx: StepContext) => string | Promise<string>);
|
|
108
|
+
readonly model?: string;
|
|
109
|
+
readonly tools?: readonly string[];
|
|
110
|
+
readonly systemPrompt?: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** code: a pure deterministic transform — no LLM, no dispatch, not cached. */
|
|
114
|
+
export interface CodeStep extends StepBase {
|
|
115
|
+
readonly type: "code";
|
|
116
|
+
readonly transform: (ctx: StepContext) => unknown | Promise<unknown>;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** log: emit a free-form narrative line into the progress widget. Pure string,
|
|
120
|
+
* zero dispatch, zero tokens, not cached — same execution profile as `code`,
|
|
121
|
+
* but fires the onLog listener and renders as a distinct narrative line. */
|
|
122
|
+
export interface LogStep extends StepBase {
|
|
123
|
+
readonly type: "log";
|
|
124
|
+
readonly message: string | ((ctx: StepContext) => string | Promise<string>);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Per-item agent spec emitted by a fan_out step's `agent` factory. */
|
|
128
|
+
export interface FanOutItemSpec extends AgentOpts {
|
|
129
|
+
readonly prompt: string;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** fan_out: parallel agents over a list, optional merge. */
|
|
133
|
+
export interface FanOutStep extends StepBase {
|
|
134
|
+
readonly type: "fan_out";
|
|
135
|
+
readonly over: (ctx: StepContext) => readonly unknown[];
|
|
136
|
+
readonly agent: (item: unknown, index: number, ctx: StepContext) => FanOutItemSpec;
|
|
137
|
+
/** Defaults to mapWithConcurrencyLimit's cap (MAX_CONCURRENCY). */
|
|
138
|
+
readonly parallelism?: number;
|
|
139
|
+
readonly merge?: (results: readonly unknown[], ctx: StepContext) => unknown | Promise<unknown>;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** loop_until: iterate a body agent until `until` / maxIterations / budget. */
|
|
143
|
+
export interface LoopUntilStep extends StepBase {
|
|
144
|
+
readonly type: "loop_until";
|
|
145
|
+
readonly prompt: (ctx: StepContext, iteration: number) => string | Promise<string>;
|
|
146
|
+
readonly until: (ctx: StepContext, iteration: number) => boolean;
|
|
147
|
+
readonly maxIterations?: number;
|
|
148
|
+
readonly model?: string;
|
|
149
|
+
readonly tools?: readonly string[];
|
|
150
|
+
readonly systemPrompt?: string;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Shared agent options that affect output (and therefore the cache signature). */
|
|
154
|
+
export interface AgentOpts {
|
|
155
|
+
readonly model?: string;
|
|
156
|
+
readonly tools?: readonly string[];
|
|
157
|
+
readonly systemPrompt?: string;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** A single agent call: prompt + opts. Used by the composite steps. */
|
|
161
|
+
export interface AgentCallSpec extends AgentOpts {
|
|
162
|
+
readonly prompt: string | ((ctx: StepContext) => string | Promise<string>);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Composite patterns — expanded onto the core primitives. */
|
|
166
|
+
/** adversarial: produce one candidate, N judges grade it against a rubric, tally. */
|
|
167
|
+
export interface AdversarialStep extends StepBase {
|
|
168
|
+
readonly type: "adversarial";
|
|
169
|
+
readonly produce: AgentCallSpec;
|
|
170
|
+
readonly rubric: readonly string[];
|
|
171
|
+
/** Number of independent judges. Default 3. */
|
|
172
|
+
readonly judges?: number;
|
|
173
|
+
readonly judge?: AgentOpts;
|
|
174
|
+
/** Min passing judges for the candidate to pass. Default = majority (ceil(judges/2)). */
|
|
175
|
+
readonly minPass?: number;
|
|
176
|
+
}
|
|
177
|
+
/** tournament: N distinct candidates, M judges rank them, pick a winner. */
|
|
178
|
+
export interface TournamentStep extends StepBase {
|
|
179
|
+
readonly type: "tournament";
|
|
180
|
+
readonly candidates: number;
|
|
181
|
+
readonly produce: AgentCallSpec;
|
|
182
|
+
readonly judges: number;
|
|
183
|
+
readonly judge?: AgentOpts;
|
|
184
|
+
}
|
|
185
|
+
/** classify_route: classify input → category, then run the matching route's steps. */
|
|
186
|
+
export interface ClassifyRouteStep extends StepBase {
|
|
187
|
+
readonly type: "classify_route";
|
|
188
|
+
readonly classifier: AgentCallSpec;
|
|
189
|
+
readonly routes: Readonly<Record<string, readonly StepDefinition[]>>;
|
|
190
|
+
/** Route used when the category matches no key. */
|
|
191
|
+
readonly fallback?: readonly StepDefinition[];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** loop_until_dry: keep spawning discovery agents until K consecutive rounds
|
|
195
|
+
* return nothing new. CC's "loop-until-dry" pattern for unknown-size discovery.
|
|
196
|
+
* Each round's output is parsed via parseFirstJson and deduplicated via keyOf. */
|
|
197
|
+
export interface LoopUntilDryStep extends StepBase {
|
|
198
|
+
readonly type: "loop_until_dry";
|
|
199
|
+
/** Agent prompt builder — receives the known set so it can ask for new items. */
|
|
200
|
+
readonly prompt: (ctx: StepContext, known: unknown[]) => string | Promise<string>;
|
|
201
|
+
/** Stable key for deduplication. */
|
|
202
|
+
readonly keyOf: (item: unknown) => string;
|
|
203
|
+
/** Merge fresh items into the known set (default = known.concat(fresh)). */
|
|
204
|
+
readonly merge?: (known: unknown[], fresh: unknown[]) => unknown[];
|
|
205
|
+
/** Consecutive dry rounds required to stop. Default 2. */
|
|
206
|
+
readonly dryThreshold?: number;
|
|
207
|
+
/** Hard cap on rounds. Default 10. */
|
|
208
|
+
readonly maxRounds?: number;
|
|
209
|
+
/** Optional completeness critic: after dryThreshold is reached, run one
|
|
210
|
+
* final critic agent asking "what's missing?". If the critic returns new
|
|
211
|
+
* items, the loop restarts; otherwise it stops. Mirrors CC's completeness-
|
|
212
|
+
* critic pattern (final agent asks "what's missing — modality not run,
|
|
213
|
+
* claim unverified, source unread?"). */
|
|
214
|
+
readonly critic?: {
|
|
215
|
+
readonly prompt: (ctx: StepContext, known: unknown[]) => string | Promise<string>;
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** sub_workflow: run a nested child workflow inline. Shares parent journal, budget, and registry.
|
|
220
|
+
* CC's `workflow(nameOrRef, args)` pattern — one level of nesting supported. */
|
|
221
|
+
export interface SubWorkflowStep extends StepBase {
|
|
222
|
+
readonly type: "sub_workflow";
|
|
223
|
+
/** The child workflow definition (inline or referenced). */
|
|
224
|
+
readonly workflow: WorkflowDefinition;
|
|
225
|
+
/** Input passed to the child workflow's ctx.input. If a function, called with parent ctx (may be async). */
|
|
226
|
+
readonly input?: unknown | ((ctx: StepContext) => unknown | Promise<unknown>);
|
|
227
|
+
/** If true (default), child agents count against parent budget. If false, child has its own pool. */
|
|
228
|
+
readonly inheritBudget?: boolean;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export type StepDefinition =
|
|
232
|
+
| AgentStep
|
|
233
|
+
| CodeStep
|
|
234
|
+
| LogStep
|
|
235
|
+
| FanOutStep
|
|
236
|
+
| LoopUntilStep
|
|
237
|
+
| AdversarialStep
|
|
238
|
+
| TournamentStep
|
|
239
|
+
| ClassifyRouteStep
|
|
240
|
+
| SubWorkflowStep
|
|
241
|
+
| LoopUntilDryStep;
|
|
242
|
+
|
|
243
|
+
export interface StepRetry {
|
|
244
|
+
readonly maxRetries: number;
|
|
245
|
+
/** Re-executing an upstream stage as part of a retry cycle is not yet supported
|
|
246
|
+
* (the list-walk runner only re-runs the failing step). Tracked for a future
|
|
247
|
+
* spec revision; add `retryStage` back with implementation when ready. */
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** A phase groups related steps for UI progress-tree rendering.
|
|
251
|
+
* Steps not assigned to any phase render under an implicit default group. */
|
|
252
|
+
export interface PhaseDefinition {
|
|
253
|
+
readonly title: string;
|
|
254
|
+
readonly detail?: string;
|
|
255
|
+
readonly stepIds: readonly string[];
|
|
256
|
+
/** Optional model override for all agents in this phase. */
|
|
257
|
+
readonly model?: string;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export interface WorkflowDefinition {
|
|
261
|
+
readonly name: string;
|
|
262
|
+
readonly description?: string;
|
|
263
|
+
readonly steps: readonly StepDefinition[];
|
|
264
|
+
readonly budget?: Budget;
|
|
265
|
+
/** Optional phase groupings for progress-tree UI rendering. */
|
|
266
|
+
readonly phases?: readonly PhaseDefinition[];
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Typed identity helper: gives a workflow literal full union checking. */
|
|
270
|
+
export function defineWorkflow(wf: WorkflowDefinition): WorkflowDefinition {
|
|
271
|
+
return wf;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
// Run result (Task 7 runner surface)
|
|
276
|
+
// ---------------------------------------------------------------------------
|
|
277
|
+
|
|
278
|
+
export type RunStatus = "completed" | "failed" | "aborted";
|
|
279
|
+
|
|
280
|
+
export interface RunResult {
|
|
281
|
+
readonly runId: string;
|
|
282
|
+
readonly status: RunStatus;
|
|
283
|
+
/** One entry per executed step, in order. Shorter than workflow.steps on abort/failure. */
|
|
284
|
+
readonly steps: readonly StepResult[];
|
|
285
|
+
/** Aggregated across executed steps. */
|
|
286
|
+
readonly stats: StepStats;
|
|
287
|
+
readonly journalFile?: string;
|
|
288
|
+
readonly error?: string;
|
|
289
|
+
/** A5: category of the terminal error that ended the run (when the failure
|
|
290
|
+
* originated from a categorized WorkflowError — budget-exceeded, size-limit,
|
|
291
|
+
* control-chars, policy-gate, determinism, …). Absent when the run failed
|
|
292
|
+
* with an uncategorized step error or aborted. */
|
|
293
|
+
readonly errorCategory?: ErrorCategory;
|
|
294
|
+
/** A3: step ids that returned null under an `onBudgetExhaust: "null"` policy
|
|
295
|
+
* (present only when any step degraded). Lets callers distinguish a clean
|
|
296
|
+
* completion from one with holes. */
|
|
297
|
+
readonly degradedSteps?: readonly string[];
|
|
298
|
+
/** Staged-resume info: how many agents hit the cache from a previous run. */
|
|
299
|
+
readonly resume?: {
|
|
300
|
+
readonly cachedHits: number;
|
|
301
|
+
readonly cachedTotal: number;
|
|
302
|
+
readonly previousRunId?: string;
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
307
|
+
// Claude Code fusion — reserved types (land in Tasks 3–5)
|
|
308
|
+
// ---------------------------------------------------------------------------
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Stable cache key for one agent invocation: `sha256(callKey + NUL + normalizedOpts)`.
|
|
312
|
+
* Normalization keeps only [schema, model, effort, isolation, agentType], drops
|
|
313
|
+
* functions, sorts object keys — so identical (prompt, opts) produces identical
|
|
314
|
+
* keys across runs (the determinism premise for resume).
|
|
315
|
+
*
|
|
316
|
+
* Task 4: the journal gains `{type:"result", key, ...}` rows and resume replays
|
|
317
|
+
* cached agent calls without re-dispatch.
|
|
318
|
+
*/
|
|
319
|
+
export type CacheKey = string;
|
|
320
|
+
|
|
321
|
+
/** Unique id for a single agent call within a run. */
|
|
322
|
+
export type AgentCallId = string;
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Per-agent abort registry: `Map<callId, AbortController>`.
|
|
326
|
+
*
|
|
327
|
+
* Claude Code keeps one AbortController per in-flight agent so retry/skip can
|
|
328
|
+
* target a single call without disturbing its batch siblings. In pi each agent
|
|
329
|
+
* is a subprocess, so Task 5 pairs this map with `Map<callId, ChildProcess>`
|
|
330
|
+
* in src/agent/dispatch.ts and translates abort → SIGTERM on exactly one process.
|
|
331
|
+
*/
|
|
332
|
+
export type AgentAbortMap = Map<AgentCallId, AbortController>;
|
package/src/ui-groups.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared phase-grouping logic (C1+D1) — used by the live progress widget
|
|
3
|
+
* (index.ts) and the `/wf-inspect` view (src/inspect.ts) so both render with
|
|
4
|
+
* identical grouping. Extracted as a pure function so it is unit-testable.
|
|
5
|
+
*
|
|
6
|
+
* Declared phases carry a header; ungrouped items collect into a header-less
|
|
7
|
+
* default group interleaved at declaration order. When no phases are declared
|
|
8
|
+
* (or none match), the whole item list is one default group (flat render).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface PhaseDef {
|
|
12
|
+
readonly title: string;
|
|
13
|
+
readonly detail?: string;
|
|
14
|
+
readonly stepIds: readonly string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface RenderGroup<T> {
|
|
18
|
+
readonly kind: "phase" | "default";
|
|
19
|
+
readonly title?: string;
|
|
20
|
+
readonly detail?: string;
|
|
21
|
+
readonly items: T[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function buildRenderGroups<T>(
|
|
25
|
+
items: readonly T[],
|
|
26
|
+
getId: (item: T) => string,
|
|
27
|
+
phases?: readonly PhaseDef[],
|
|
28
|
+
): RenderGroup<T>[] {
|
|
29
|
+
const stepPhase = new Map<string, { title: string; detail?: string }>();
|
|
30
|
+
if (phases && phases.length > 0) {
|
|
31
|
+
for (const ph of phases) for (const sid of ph.stepIds) stepPhase.set(sid, { title: ph.title, detail: ph.detail });
|
|
32
|
+
}
|
|
33
|
+
const groups: RenderGroup<T>[] = [];
|
|
34
|
+
for (const item of items) {
|
|
35
|
+
const ph = stepPhase.get(getId(item));
|
|
36
|
+
const key = ph ? `phase:${ph.title}` : "default";
|
|
37
|
+
const last = groups[groups.length - 1];
|
|
38
|
+
const lastKey = last ? (last.kind === "phase" ? `phase:${last.title}` : "default") : "";
|
|
39
|
+
if (last && lastKey === key) last.items.push(item);
|
|
40
|
+
else groups.push(ph ? { kind: "phase", title: ph.title, detail: ph.detail, items: [item] } : { kind: "default", items: [item] });
|
|
41
|
+
}
|
|
42
|
+
return groups;
|
|
43
|
+
}
|