@llm4ts/flow 2.32.0 → 2.33.0
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/dist/Diagnostics.d.ts +27 -0
- package/dist/Diagnostics.d.ts.map +1 -0
- package/dist/Diagnostics.js +84 -0
- package/dist/Diagnostics.js.map +1 -0
- package/dist/FlowError.d.ts +1 -1
- package/dist/FlowError.js +1 -1
- package/dist/FlowError.js.map +1 -1
- package/dist/Pack.d.ts +16 -0
- package/dist/Pack.d.ts.map +1 -1
- package/dist/Pack.js +12 -0
- package/dist/Pack.js.map +1 -1
- package/dist/Port.d.ts +57 -0
- package/dist/Port.d.ts.map +1 -0
- package/dist/Port.js +134 -0
- package/dist/Port.js.map +1 -0
- package/dist/Retro.d.ts.map +1 -1
- package/dist/Retro.js +9 -2
- package/dist/Retro.js.map +1 -1
- package/dist/WorkQueue.d.ts +63 -0
- package/dist/WorkQueue.d.ts.map +1 -0
- package/dist/WorkQueue.js +117 -0
- package/dist/WorkQueue.js.map +1 -0
- package/package.json +5 -2
- package/src/Diagnostics.ts +105 -0
- package/src/FlowError.ts +1 -1
- package/src/Pack.ts +29 -0
- package/src/Port.ts +178 -0
- package/src/Retro.ts +9 -2
- package/src/WorkQueue.ts +194 -0
package/src/Port.ts
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// Porting a code base file by file (ADR 0028): the manifest, its batches, the
|
|
2
|
+
// deterministic target path, the machine-readable PORT STATUS trailer every
|
|
3
|
+
// draft ends with, and the pilot report. Pure; the flows own the seats.
|
|
4
|
+
import { basename, dirname, extname } from "node:path"
|
|
5
|
+
import * as Schema from "effect/Schema"
|
|
6
|
+
import { DraftApprovalMarker } from "./Approval.ts"
|
|
7
|
+
import type { QueueOutcome } from "./WorkQueue.ts"
|
|
8
|
+
|
|
9
|
+
export class PortEntry extends Schema.Class<PortEntry>("PortEntry")({
|
|
10
|
+
/** Repo-relative source path; also the queue item id. */
|
|
11
|
+
id: Schema.String,
|
|
12
|
+
source: Schema.String,
|
|
13
|
+
target: Schema.String,
|
|
14
|
+
loc: Schema.Int
|
|
15
|
+
}) {}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The target path of a source, from the pack's `target:` template:
|
|
19
|
+
* `{{dir}}` (the source's directory, "" at the root), `{{base}}` (its name
|
|
20
|
+
* without extension), `{{ext}}` (the extension without the dot), `{{path}}`
|
|
21
|
+
* (the whole source path).
|
|
22
|
+
*/
|
|
23
|
+
export const targetPathOf = (template: string, source: string): string => {
|
|
24
|
+
const dir = dirname(source)
|
|
25
|
+
const ext = extname(source)
|
|
26
|
+
const rendered = template
|
|
27
|
+
.replaceAll("{{dir}}", dir === "." ? "" : dir)
|
|
28
|
+
.replaceAll("{{base}}", basename(source, ext))
|
|
29
|
+
.replaceAll("{{ext}}", ext.replace(/^\./u, ""))
|
|
30
|
+
.replaceAll("{{path}}", source)
|
|
31
|
+
return rendered.replace(/^\/+/u, "").replaceAll("//", "/")
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const linesOf = (text: string): number =>
|
|
35
|
+
text.length === 0 ? 0 : text.split(/\r?\n/u).filter((line) => line.trim().length > 0).length
|
|
36
|
+
|
|
37
|
+
export interface BatchPolicy {
|
|
38
|
+
/** Files per batch. Default 100. */
|
|
39
|
+
readonly files?: number
|
|
40
|
+
/** A batch whose first file is longer than this (lines) shrinks to `small`. Default 2200. */
|
|
41
|
+
readonly bigLoc?: number
|
|
42
|
+
/** Default 6. */
|
|
43
|
+
readonly small?: number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Contiguous batches of the manifest; a batch led by a big file is small (the Bun rule). */
|
|
47
|
+
export const batchesOf = (
|
|
48
|
+
entries: ReadonlyArray<PortEntry>,
|
|
49
|
+
policy: BatchPolicy = {}
|
|
50
|
+
): ReadonlyArray<ReadonlyArray<PortEntry>> => {
|
|
51
|
+
const files = Math.max(1, policy.files ?? 100)
|
|
52
|
+
const bigLoc = policy.bigLoc ?? 2_200
|
|
53
|
+
const small = Math.max(1, policy.small ?? 6)
|
|
54
|
+
const batches: Array<ReadonlyArray<PortEntry>> = []
|
|
55
|
+
let index = 0
|
|
56
|
+
while (index < entries.length) {
|
|
57
|
+
const first = entries[index]
|
|
58
|
+
const size = first !== undefined && first.loc > bigLoc ? small : files
|
|
59
|
+
batches.push(entries.slice(index, index + size))
|
|
60
|
+
index += size
|
|
61
|
+
}
|
|
62
|
+
return batches
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const PortConfidence = Schema.Literals(["high", "medium", "low"])
|
|
66
|
+
export type PortConfidence = typeof PortConfidence.Type
|
|
67
|
+
|
|
68
|
+
export interface PortStatus {
|
|
69
|
+
readonly confidence: PortConfidence | undefined
|
|
70
|
+
readonly todos: number | undefined
|
|
71
|
+
readonly notes: string | undefined
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const statusWindow = 40
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The PORT STATUS trailer of a draft: in any comment syntax, within the last
|
|
78
|
+
* lines of the file, `confidence: high|medium|low`, `todos: <n>`, `notes: …`.
|
|
79
|
+
* All undefined when the draft has no trailer.
|
|
80
|
+
*/
|
|
81
|
+
export const portStatusIn = (text: string): PortStatus => {
|
|
82
|
+
const lines = text.split(/\r?\n/u)
|
|
83
|
+
const tail = lines.slice(Math.max(0, lines.length - statusWindow))
|
|
84
|
+
const start = tail.findIndex((line) => /PORT STATUS/u.test(line))
|
|
85
|
+
if (start < 0) {
|
|
86
|
+
return { confidence: undefined, todos: undefined, notes: undefined }
|
|
87
|
+
}
|
|
88
|
+
const block = tail.slice(start).join("\n")
|
|
89
|
+
const confidence = /confidence:\s*(high|medium|low)\b/iu.exec(block)?.[1]?.toLowerCase()
|
|
90
|
+
const todos = /todos?:\s*(\d+)/iu.exec(block)?.[1]
|
|
91
|
+
const notes = /notes?:\s*(.+)$/imu.exec(block)?.[1]?.trim()
|
|
92
|
+
return {
|
|
93
|
+
confidence:
|
|
94
|
+
confidence === "high" || confidence === "medium" || confidence === "low"
|
|
95
|
+
? confidence
|
|
96
|
+
: undefined,
|
|
97
|
+
todos: todos === undefined ? undefined : Number.parseInt(todos, 10),
|
|
98
|
+
notes: notes === undefined || notes.length === 0 ? undefined : notes.replace(/\s*\*\/\s*$/u, "")
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The instruction every implementer gets: how to end the file. */
|
|
103
|
+
export const portStatusInstruction = (comment: string): string =>
|
|
104
|
+
[
|
|
105
|
+
`End the file with a machine-readable trailer in ${comment} comments, exactly this shape:`,
|
|
106
|
+
`${comment} PORT STATUS`,
|
|
107
|
+
`${comment} source: <the source path>`,
|
|
108
|
+
`${comment} confidence: high | medium | low`,
|
|
109
|
+
`${comment} todos: <number of TODO(port) markers you left>`,
|
|
110
|
+
`${comment} notes: <one line: what you could not translate faithfully, or "none">`,
|
|
111
|
+
"`confidence: low` means the logic is probably wrong and must be re-read against the source."
|
|
112
|
+
].join("\n")
|
|
113
|
+
|
|
114
|
+
export interface PilotFacts {
|
|
115
|
+
readonly outcomes: ReadonlyArray<QueueOutcome>
|
|
116
|
+
readonly piloted: number
|
|
117
|
+
readonly remaining: number
|
|
118
|
+
readonly elapsedMs: number
|
|
119
|
+
readonly estimatedCostUsd: number | undefined
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const minutes = (ms: number): string => `${(ms / 60_000).toFixed(1)} min`
|
|
123
|
+
|
|
124
|
+
/** The pilot report (R7 of the plan): rates measured, totals extrapolated, an approval to continue. */
|
|
125
|
+
export const renderPilotReport = (facts: PilotFacts): string => {
|
|
126
|
+
const done = facts.outcomes.filter((outcome) => outcome.status === "done")
|
|
127
|
+
const failed = facts.outcomes.filter((outcome) => outcome.status === "failed")
|
|
128
|
+
const count = (level: PortConfidence) =>
|
|
129
|
+
done.filter((outcome) => outcome.confidence === level).length
|
|
130
|
+
const todos = done.reduce((sum, outcome) => sum + (outcome.todos ?? 0), 0)
|
|
131
|
+
const perItemMs = facts.piloted === 0 ? 0 : facts.elapsedMs / facts.piloted
|
|
132
|
+
const perItemCost =
|
|
133
|
+
facts.estimatedCostUsd === undefined || facts.piloted === 0
|
|
134
|
+
? undefined
|
|
135
|
+
: facts.estimatedCostUsd / facts.piloted
|
|
136
|
+
return [
|
|
137
|
+
"# Port pilot",
|
|
138
|
+
"",
|
|
139
|
+
`- piloted: ${facts.piloted} file(s), ${done.length} done, ${failed.length} failed, in ${minutes(facts.elapsedMs)}`,
|
|
140
|
+
`- confidence: ${count("high")} high, ${count("medium")} medium, ${count("low")} low; ${todos} TODO(port) marker(s)`,
|
|
141
|
+
...(perItemCost === undefined
|
|
142
|
+
? []
|
|
143
|
+
: [
|
|
144
|
+
`- cost: ~$${(facts.estimatedCostUsd ?? 0).toFixed(2)} (~$${perItemCost.toFixed(3)} per file)`
|
|
145
|
+
]),
|
|
146
|
+
`- remaining: ${facts.remaining} file(s) → about ${minutes(perItemMs * facts.remaining)} at this rate${
|
|
147
|
+
perItemCost === undefined ? "" : ` and ~$${(perItemCost * facts.remaining).toFixed(2)}`
|
|
148
|
+
}`,
|
|
149
|
+
"",
|
|
150
|
+
...(failed.length === 0
|
|
151
|
+
? []
|
|
152
|
+
: [
|
|
153
|
+
"## Failed",
|
|
154
|
+
"",
|
|
155
|
+
...failed.map((outcome) => `- ${outcome.id}: ${outcome.note ?? ""}`),
|
|
156
|
+
""
|
|
157
|
+
]),
|
|
158
|
+
"Read the drafted files beside their sources, then tick the box to let the next run",
|
|
159
|
+
"port the rest; edit `prompts/porting.md` in the pack first if the drafts show a",
|
|
160
|
+
"pattern the rulebook should settle.",
|
|
161
|
+
"",
|
|
162
|
+
DraftApprovalMarker,
|
|
163
|
+
""
|
|
164
|
+
].join("\n")
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** A whole new file rendered as a unified diff of additions, for reviewers that judge diffs. */
|
|
168
|
+
export const asAddedFileDiff = (path: string, content: string): string => {
|
|
169
|
+
const lines = content.split(/\r?\n/u)
|
|
170
|
+
return [
|
|
171
|
+
`diff --git a/${path} b/${path}`,
|
|
172
|
+
"new file mode 100644",
|
|
173
|
+
"--- /dev/null",
|
|
174
|
+
`+++ b/${path}`,
|
|
175
|
+
`@@ -0,0 +1,${lines.length} @@`,
|
|
176
|
+
...lines.map((line) => `+${line}`)
|
|
177
|
+
].join("\n")
|
|
178
|
+
}
|
package/src/Retro.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* tasks appended to a story's plan, or a story entry edited in the epic
|
|
10
10
|
* plan (which restarts that story). Nothing is applied unreviewed.
|
|
11
11
|
*/
|
|
12
|
-
import { join } from "node:path"
|
|
12
|
+
import { join, resolve, sep } from "node:path"
|
|
13
13
|
import * as Effect from "effect/Effect"
|
|
14
14
|
import * as Option from "effect/Option"
|
|
15
15
|
import * as Schema from "effect/Schema"
|
|
@@ -1278,7 +1278,14 @@ export const applyApprovedRetros = Effect.fn("@llm4ts/flow/Retro.apply")(functio
|
|
|
1278
1278
|
yield* say(`${runId}: rule edit to ${edit.target} is not allowed; skipped`)
|
|
1279
1279
|
continue
|
|
1280
1280
|
}
|
|
1281
|
-
const
|
|
1281
|
+
const root = resolve(options.rulesRoot)
|
|
1282
|
+
const path = resolve(root, edit.target)
|
|
1283
|
+
// The allowlist already rejects absolute paths and `..`; this keeps the
|
|
1284
|
+
// resolved file under the repository root whatever the string looked like.
|
|
1285
|
+
if (path !== root && !path.startsWith(`${root}${sep}`)) {
|
|
1286
|
+
yield* say(`${runId}: rule edit to ${edit.target} resolves outside the repository; skipped`)
|
|
1287
|
+
continue
|
|
1288
|
+
}
|
|
1282
1289
|
const existing = yield* files.read(path).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
|
1283
1290
|
const next = applyRuleEdit(existing, edit)
|
|
1284
1291
|
if (next === undefined) {
|
package/src/WorkQueue.ts
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// A mechanical work queue (ADR 0028): many small units with a filesystem
|
|
2
|
+
// "done" predicate, worked in rounds under a concurrency cap, each unit's
|
|
3
|
+
// failure becoming the next round's item, progress persisted as a ledger so
|
|
4
|
+
// a rerun resumes where the run stopped. The Bun port's shape; a story is a
|
|
5
|
+
// feature with a DAG and a judge, a queue item is a file with a predicate.
|
|
6
|
+
import * as Clock from "effect/Clock"
|
|
7
|
+
import * as Effect from "effect/Effect"
|
|
8
|
+
import * as Schema from "effect/Schema"
|
|
9
|
+
import { Stalled, describeFlowError, type FlowError } from "./FlowError.ts"
|
|
10
|
+
import {
|
|
11
|
+
Info,
|
|
12
|
+
StageCompleted,
|
|
13
|
+
StageFailed,
|
|
14
|
+
StageStarted,
|
|
15
|
+
type FlowEventsShape
|
|
16
|
+
} from "./FlowEvents.ts"
|
|
17
|
+
import type { PlainFileStoreShape } from "./Persistence.ts"
|
|
18
|
+
|
|
19
|
+
export interface QueueItem {
|
|
20
|
+
readonly id: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const Confidence = Schema.Literals(["high", "medium", "low"])
|
|
24
|
+
|
|
25
|
+
/** One line of the ledger: what happened to one item in one round. */
|
|
26
|
+
export class QueueOutcome extends Schema.Class<QueueOutcome>("QueueOutcome")({
|
|
27
|
+
id: Schema.String,
|
|
28
|
+
round: Schema.Int,
|
|
29
|
+
status: Schema.Literals(["done", "failed"]),
|
|
30
|
+
ms: Schema.Number,
|
|
31
|
+
note: Schema.optionalKey(Schema.String),
|
|
32
|
+
confidence: Schema.optionalKey(Confidence),
|
|
33
|
+
todos: Schema.optionalKey(Schema.Int),
|
|
34
|
+
at: Schema.Number
|
|
35
|
+
}) {}
|
|
36
|
+
|
|
37
|
+
const encodeOutcome = Schema.encodeSync(Schema.fromJsonString(QueueOutcome))
|
|
38
|
+
const decodeOutcome = Schema.decodeUnknownOption(Schema.fromJsonString(QueueOutcome))
|
|
39
|
+
|
|
40
|
+
/** What a unit of work reports back; the queue records it. */
|
|
41
|
+
export interface WorkResult {
|
|
42
|
+
readonly note?: string
|
|
43
|
+
readonly confidence?: "high" | "medium" | "low"
|
|
44
|
+
readonly todos?: number
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface RunQueueOptions<Item extends QueueItem> {
|
|
48
|
+
readonly label: string
|
|
49
|
+
readonly items: ReadonlyArray<Item>
|
|
50
|
+
/** The filesystem predicate: an item whose output exists is done, whatever the ledger says. */
|
|
51
|
+
readonly done: (item: Item) => Effect.Effect<boolean, FlowError>
|
|
52
|
+
/** One unit of work: implement, review, fix; the queue catches its failure. */
|
|
53
|
+
readonly work: (item: Item, round: number) => Effect.Effect<WorkResult, FlowError>
|
|
54
|
+
readonly events: FlowEventsShape
|
|
55
|
+
readonly concurrency?: number
|
|
56
|
+
/** Rounds over the items that failed; default 3. */
|
|
57
|
+
readonly maxRounds?: number
|
|
58
|
+
/** Where outcomes are appended, one JSON line each; omit to keep none. */
|
|
59
|
+
readonly ledger?: { readonly files: PlainFileStoreShape; readonly path: string }
|
|
60
|
+
/** After every round: the done items of that round, for a commit. */
|
|
61
|
+
readonly afterRound?: (
|
|
62
|
+
round: number,
|
|
63
|
+
done: ReadonlyArray<Item>,
|
|
64
|
+
failed: ReadonlyArray<Item>
|
|
65
|
+
) => Effect.Effect<void, FlowError>
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface QueueReport<Item extends QueueItem> {
|
|
69
|
+
readonly outcomes: ReadonlyArray<QueueOutcome>
|
|
70
|
+
readonly rounds: number
|
|
71
|
+
/** Items still not done when the queue stopped. */
|
|
72
|
+
readonly pending: ReadonlyArray<Item>
|
|
73
|
+
/** Items that were already done before the first round (a resume). */
|
|
74
|
+
readonly skipped: ReadonlyArray<Item>
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const readLedger = (
|
|
78
|
+
files: PlainFileStoreShape,
|
|
79
|
+
path: string
|
|
80
|
+
): Effect.Effect<ReadonlyArray<QueueOutcome>, FlowError> =>
|
|
81
|
+
Effect.map(files.read(path), (text) =>
|
|
82
|
+
(text ?? "")
|
|
83
|
+
.split("\n")
|
|
84
|
+
.filter((line) => line.trim().length > 0)
|
|
85
|
+
.flatMap((line) => {
|
|
86
|
+
const decoded = decodeOutcome(line)
|
|
87
|
+
return decoded._tag === "Some" ? [decoded.value] : []
|
|
88
|
+
})
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Works the items that are not done, round after round, until every item is
|
|
93
|
+
* done, the rounds run out, or a round makes no progress (typed `Stalled`).
|
|
94
|
+
*/
|
|
95
|
+
export const runQueue = Effect.fn("@llm4ts/flow/WorkQueue.run")(function* <Item extends QueueItem>(
|
|
96
|
+
options: RunQueueOptions<Item>
|
|
97
|
+
): Effect.fn.Return<QueueReport<Item>, FlowError> {
|
|
98
|
+
const concurrency = Math.max(1, options.concurrency ?? 4)
|
|
99
|
+
const maxRounds = Math.max(1, options.maxRounds ?? 3)
|
|
100
|
+
const outcomes: Array<QueueOutcome> = []
|
|
101
|
+
const record = (outcome: QueueOutcome): Effect.Effect<void, FlowError> =>
|
|
102
|
+
Effect.gen(function* () {
|
|
103
|
+
outcomes.push(outcome)
|
|
104
|
+
if (options.ledger !== undefined) {
|
|
105
|
+
yield* options.ledger.files.append(options.ledger.path, `${encodeOutcome(outcome)}\n`)
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
const skipped: Array<Item> = []
|
|
110
|
+
let pending: Array<Item> = []
|
|
111
|
+
for (const item of options.items) {
|
|
112
|
+
if (yield* options.done(item)) {
|
|
113
|
+
skipped.push(item)
|
|
114
|
+
} else {
|
|
115
|
+
pending.push(item)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (skipped.length > 0) {
|
|
119
|
+
yield* options.events.publish(
|
|
120
|
+
Info.make({
|
|
121
|
+
message: `${options.label}: ${skipped.length} of ${options.items.length} item(s) already done; ${pending.length} to go`
|
|
122
|
+
})
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let rounds = 0
|
|
127
|
+
while (pending.length > 0 && rounds < maxRounds) {
|
|
128
|
+
rounds += 1
|
|
129
|
+
const round = rounds
|
|
130
|
+
yield* options.events.publish(
|
|
131
|
+
Info.make({ message: `${options.label}: round ${round}, ${pending.length} item(s)` })
|
|
132
|
+
)
|
|
133
|
+
const results = yield* Effect.forEach(
|
|
134
|
+
pending,
|
|
135
|
+
(item) =>
|
|
136
|
+
Effect.gen(function* () {
|
|
137
|
+
const stage = `${options.label} ${item.id}`
|
|
138
|
+
yield* options.events.publish(StageStarted.make({ stage, lane: item.id }))
|
|
139
|
+
const started = yield* Clock.currentTimeMillis
|
|
140
|
+
const result = yield* options.work(item, round).pipe(
|
|
141
|
+
Effect.map((value) => ({ ok: true as const, value })),
|
|
142
|
+
Effect.catch((error: FlowError) =>
|
|
143
|
+
Effect.succeed({ ok: false as const, error: describeFlowError(error) })
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
const ended = yield* Clock.currentTimeMillis
|
|
147
|
+
// The predicate decides, not the work's own account of itself.
|
|
148
|
+
const finished = result.ok && (yield* options.done(item))
|
|
149
|
+
const outcome = QueueOutcome.make({
|
|
150
|
+
id: item.id,
|
|
151
|
+
round,
|
|
152
|
+
status: finished ? "done" : "failed",
|
|
153
|
+
ms: ended - started,
|
|
154
|
+
at: ended,
|
|
155
|
+
...(result.ok
|
|
156
|
+
? {
|
|
157
|
+
...(finished
|
|
158
|
+
? {}
|
|
159
|
+
: { note: result.value.note ?? "the work ended but left no output" }),
|
|
160
|
+
...(finished && result.value.note !== undefined
|
|
161
|
+
? { note: result.value.note }
|
|
162
|
+
: {}),
|
|
163
|
+
...(result.value.confidence === undefined
|
|
164
|
+
? {}
|
|
165
|
+
: { confidence: result.value.confidence }),
|
|
166
|
+
...(result.value.todos === undefined ? {} : { todos: result.value.todos })
|
|
167
|
+
}
|
|
168
|
+
: { note: result.error })
|
|
169
|
+
})
|
|
170
|
+
yield* record(outcome)
|
|
171
|
+
yield* options.events.publish(
|
|
172
|
+
finished
|
|
173
|
+
? StageCompleted.make({ stage, lane: item.id })
|
|
174
|
+
: StageFailed.make({ stage, lane: item.id, message: outcome.note ?? "failed" })
|
|
175
|
+
)
|
|
176
|
+
return { item, finished }
|
|
177
|
+
}),
|
|
178
|
+
{ concurrency }
|
|
179
|
+
)
|
|
180
|
+
const done = results.filter((entry) => entry.finished).map((entry) => entry.item)
|
|
181
|
+
const failed = results.filter((entry) => !entry.finished).map((entry) => entry.item)
|
|
182
|
+
if (options.afterRound !== undefined) {
|
|
183
|
+
yield* options.afterRound(round, done, failed)
|
|
184
|
+
}
|
|
185
|
+
if (done.length === 0 && failed.length > 0) {
|
|
186
|
+
return yield* Stalled.make({
|
|
187
|
+
signal: "no-progress",
|
|
188
|
+
detail: `${options.label}: round ${round} finished none of its ${failed.length} item(s)`
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
pending = failed
|
|
192
|
+
}
|
|
193
|
+
return { outcomes, rounds, pending, skipped }
|
|
194
|
+
})
|