@llm4ts/flow 2.45.0 → 2.46.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/Approval.d.ts +2 -0
- package/dist/Approval.d.ts.map +1 -1
- package/dist/Approval.js +4 -0
- package/dist/Approval.js.map +1 -1
- package/dist/AutonomyContract.d.ts +2 -2
- package/dist/AutonomyContract.d.ts.map +1 -1
- package/dist/AutonomyContract.js +9 -3
- package/dist/AutonomyContract.js.map +1 -1
- package/dist/CostReport.d.ts +27 -0
- package/dist/CostReport.d.ts.map +1 -1
- package/dist/CostReport.js +103 -2
- package/dist/CostReport.js.map +1 -1
- package/dist/FlowRecorder.d.ts +8 -0
- package/dist/FlowRecorder.d.ts.map +1 -1
- package/dist/FlowRecorder.js +10 -3
- package/dist/FlowRecorder.js.map +1 -1
- package/dist/GateTriage.d.ts +26 -0
- package/dist/GateTriage.d.ts.map +1 -1
- package/dist/GateTriage.js +98 -5
- package/dist/GateTriage.js.map +1 -1
- package/dist/GitTool.d.ts +9 -5
- package/dist/GitTool.d.ts.map +1 -1
- package/dist/GitTool.js +44 -18
- package/dist/GitTool.js.map +1 -1
- package/dist/OperatorSchemas.d.ts +256 -0
- package/dist/OperatorSchemas.d.ts.map +1 -0
- package/dist/OperatorSchemas.js +204 -0
- package/dist/OperatorSchemas.js.map +1 -0
- package/dist/Retro.d.ts +2 -0
- package/dist/Retro.d.ts.map +1 -1
- package/dist/Retro.js +6 -0
- package/dist/Retro.js.map +1 -1
- package/dist/Review.d.ts +2 -0
- package/dist/Review.d.ts.map +1 -1
- package/dist/Review.js +17 -9
- package/dist/Review.js.map +1 -1
- package/dist/RunState.d.ts +26 -0
- package/dist/RunState.d.ts.map +1 -0
- package/dist/RunState.js +59 -0
- package/dist/RunState.js.map +1 -0
- package/package.json +4 -2
- package/src/Approval.ts +6 -0
- package/src/AutonomyContract.ts +10 -3
- package/src/CostReport.ts +152 -2
- package/src/FlowRecorder.ts +19 -3
- package/src/GateTriage.ts +113 -7
- package/src/GitTool.ts +46 -18
- package/src/OperatorSchemas.ts +221 -0
- package/src/Retro.ts +11 -0
- package/src/Review.ts +25 -11
- package/src/RunState.ts +72 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@llm4ts/flow",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.46.0",
|
|
4
4
|
"description": "Effect-native LLM workflow, persistence, review, and repository automation",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
"./Domains": "./dist/Domains.js",
|
|
39
39
|
"./EpicBrief": "./dist/EpicBrief.js",
|
|
40
40
|
"./EpicRuns": "./dist/EpicRuns.js",
|
|
41
|
+
"./OperatorSchemas": "./dist/OperatorSchemas.js",
|
|
41
42
|
"./Equiv": "./dist/Equiv.js",
|
|
42
43
|
"./EquivReport": "./dist/EquivReport.js",
|
|
43
44
|
"./EstimatedUsage": "./dist/EstimatedUsage.js",
|
|
@@ -71,6 +72,7 @@
|
|
|
71
72
|
"./OracleGuard": "./dist/OracleGuard.js",
|
|
72
73
|
"./Orientation": "./dist/Orientation.js",
|
|
73
74
|
"./Persistence": "./dist/Persistence.js",
|
|
75
|
+
"./RunState": "./dist/RunState.js",
|
|
74
76
|
"./Plan": "./dist/Plan.js",
|
|
75
77
|
"./PlanExecution": "./dist/PlanExecution.js",
|
|
76
78
|
"./Port": "./dist/Port.js",
|
|
@@ -127,7 +129,7 @@
|
|
|
127
129
|
"typescript"
|
|
128
130
|
],
|
|
129
131
|
"dependencies": {
|
|
130
|
-
"@llm4ts/core": "2.
|
|
132
|
+
"@llm4ts/core": "2.46.0"
|
|
131
133
|
},
|
|
132
134
|
"peerDependencies": {
|
|
133
135
|
"effect": "4.0.0"
|
package/src/Approval.ts
CHANGED
|
@@ -28,6 +28,12 @@ export class MissingModernizeArtifact extends Schema.TaggedError<MissingModerniz
|
|
|
28
28
|
export const ApprovedMarker = "- [x] Approved"
|
|
29
29
|
export const DraftApprovalMarker = "- [ ] Approved"
|
|
30
30
|
|
|
31
|
+
/** `- [ ] Approved` → `- [x] Approved` in a markdown document; unchanged when already approved. */
|
|
32
|
+
export const approveMarkdown = (markdown: string): string =>
|
|
33
|
+
markdown.includes(ApprovedMarker)
|
|
34
|
+
? markdown
|
|
35
|
+
: markdown.replace(DraftApprovalMarker, ApprovedMarker)
|
|
36
|
+
|
|
31
37
|
export const withDraftApproval = (markdown: string): string =>
|
|
32
38
|
`${markdown.trimEnd()}\n\n${DraftApprovalMarker}\n`
|
|
33
39
|
|
package/src/AutonomyContract.ts
CHANGED
|
@@ -17,6 +17,12 @@ const workspace = [
|
|
|
17
17
|
"files only where your instructions name them. What it expects of you is in your instructions."
|
|
18
18
|
]
|
|
19
19
|
|
|
20
|
+
const runState = [
|
|
21
|
+
"The directory `.llm4ts/` at the root of your working directory is the run's own state — traces,",
|
|
22
|
+
"plans, transcripts and costs that belong to the tool running you. Never read, modify, delete or",
|
|
23
|
+
"commit anything under it; the one exception is a path there that your instructions name as yours."
|
|
24
|
+
]
|
|
25
|
+
|
|
20
26
|
const noGaming = [
|
|
21
27
|
"Never edit, skip, delete or weaken a test to make a gate pass. A test you believe is wrong is a",
|
|
22
28
|
"finding to report, not a line to change; a pre-existing bug outside your task is a finding, not",
|
|
@@ -41,8 +47,8 @@ const evidence = [
|
|
|
41
47
|
|
|
42
48
|
/**
|
|
43
49
|
* The contract for a profile. `full` is the default; `minimal` keeps scope,
|
|
44
|
-
* the workspace rule and the no-gaming rule for a model
|
|
45
|
-
* under the full text;
|
|
50
|
+
* the workspace rule, the run-state rule and the no-gaming rule for a model
|
|
51
|
+
* that over-verifies under the full text;
|
|
46
52
|
* `off` is the empty string, for a flow that writes its own rules.
|
|
47
53
|
*/
|
|
48
54
|
export const autonomyContract = (profile: ContractProfile = "full"): string => {
|
|
@@ -50,12 +56,13 @@ export const autonomyContract = (profile: ContractProfile = "full"): string => {
|
|
|
50
56
|
case "off":
|
|
51
57
|
return ""
|
|
52
58
|
case "minimal":
|
|
53
|
-
return [...scope, ...workspace, ...noGaming].join("\n")
|
|
59
|
+
return [...scope, ...workspace, ...runState, ...noGaming].join("\n")
|
|
54
60
|
case "full":
|
|
55
61
|
return [
|
|
56
62
|
...unattended,
|
|
57
63
|
...scope,
|
|
58
64
|
...workspace,
|
|
65
|
+
...runState,
|
|
59
66
|
...minimalChange,
|
|
60
67
|
...noGaming,
|
|
61
68
|
...evidence
|
package/src/CostReport.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import * as DateTime from "effect/DateTime"
|
|
2
|
+
import * as Effect from "effect/Effect"
|
|
2
3
|
import * as Option from "effect/Option"
|
|
3
4
|
import * as Schema from "effect/Schema"
|
|
4
5
|
import type { TokenUsage } from "@llm4ts/core/Models"
|
|
6
|
+
import type { CostRecord } from "./CostLedger.ts"
|
|
5
7
|
import { isEstimatedModel } from "./EstimatedUsage.ts"
|
|
6
8
|
import { TokensUsed, cloneName } from "./FlowEvents.ts"
|
|
7
9
|
import type { TraceLine } from "./FlowRecorder.ts"
|
|
@@ -16,10 +18,22 @@ import { estimateCostUsd, PricesAsOf } from "./PriceList.ts"
|
|
|
16
18
|
* backend's own token counts) and estimated usage (`EstimatedUsage`'s
|
|
17
19
|
* character-count fallback, labelled `estimated:<model>`) are kept in separate
|
|
18
20
|
* columns so a budget never mistakes one for the other.
|
|
21
|
+
*
|
|
22
|
+
* The trace is the source for timing, the cost ledger (`CostLedger`) for the
|
|
23
|
+
* run's total: both come from the same `TokensUsed` events, but the trace is
|
|
24
|
+
* a file in the target's working tree that a coder's shell, a `git clean`
|
|
25
|
+
* or a recorder that lost its disk can shorten, while the ledger line is
|
|
26
|
+
* written once from the run's own cost tracker, the same numbers the run
|
|
27
|
+
* printed. `reconcileWithLedger` tops a short trace up from its ledger line,
|
|
28
|
+
* so `llm4ts costs` and the run's summary agree.
|
|
19
29
|
*/
|
|
20
30
|
|
|
21
31
|
export const UnknownModel = "(unknown)"
|
|
22
32
|
|
|
33
|
+
/** Where a sample's numbers came from: a trace line, or the run's ledger line filling a short trace. */
|
|
34
|
+
export const UsageSource = Schema.Literals(["trace", "ledger"])
|
|
35
|
+
export type UsageSource = typeof UsageSource.Type
|
|
36
|
+
|
|
23
37
|
export class UsageSample extends Schema.Class<UsageSample>("UsageSample")({
|
|
24
38
|
/** Epoch milliseconds of the token report. */
|
|
25
39
|
at: Schema.Number,
|
|
@@ -34,7 +48,11 @@ export class UsageSample extends Schema.Class<UsageSample>("UsageSample")({
|
|
|
34
48
|
estimated: Schema.Boolean,
|
|
35
49
|
/** The lane's executor and clone, when the usage came from a roster lane (ADR 0033). */
|
|
36
50
|
executor: Schema.optionalKey(Schema.String),
|
|
37
|
-
clone: Schema.optionalKey(Schema.Int)
|
|
51
|
+
clone: Schema.optionalKey(Schema.Int),
|
|
52
|
+
source: UsageSource.pipe(
|
|
53
|
+
Schema.withConstructorDefault(Effect.succeed("trace" as const)),
|
|
54
|
+
Schema.withDecodingDefaultKey(Effect.succeed("trace" as const))
|
|
55
|
+
)
|
|
38
56
|
}) {}
|
|
39
57
|
|
|
40
58
|
export class UsageTotals extends Schema.Class<UsageTotals>("UsageTotals")({
|
|
@@ -173,7 +191,8 @@ const totalsOf = (samples: ReadonlyArray<UsageSample>): UsageTotals => {
|
|
|
173
191
|
const cached = sumOptional(samples.map((sample) => sample.cached))
|
|
174
192
|
const costUsd = sumOptional(samples.map((sample) => sample.costUsd))
|
|
175
193
|
return UsageTotals.make({
|
|
176
|
-
|
|
194
|
+
// A ledger sample is a remainder, not a call the trace saw.
|
|
195
|
+
requests: samples.filter((sample) => sample.source === "trace").length,
|
|
177
196
|
prompt: samples.reduce((sum, sample) => sum + sample.prompt, 0),
|
|
178
197
|
completion: samples.reduce((sum, sample) => sum + sample.completion, 0),
|
|
179
198
|
total: samples.reduce((sum, sample) => sum + sample.total, 0),
|
|
@@ -182,6 +201,137 @@ const totalsOf = (samples: ReadonlyArray<UsageSample>): UsageTotals => {
|
|
|
182
201
|
})
|
|
183
202
|
}
|
|
184
203
|
|
|
204
|
+
interface UsageSums {
|
|
205
|
+
prompt: number
|
|
206
|
+
completion: number
|
|
207
|
+
total: number
|
|
208
|
+
cached: number | undefined
|
|
209
|
+
costUsd: number | undefined
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const emptySums = (): UsageSums => ({
|
|
213
|
+
prompt: 0,
|
|
214
|
+
completion: 0,
|
|
215
|
+
total: 0,
|
|
216
|
+
cached: undefined,
|
|
217
|
+
costUsd: undefined
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
const addSums = (into: UsageSums, usage: UsageSums): UsageSums => ({
|
|
221
|
+
prompt: into.prompt + usage.prompt,
|
|
222
|
+
completion: into.completion + usage.completion,
|
|
223
|
+
total: into.total + usage.total,
|
|
224
|
+
cached: sumOptional([into.cached, usage.cached]),
|
|
225
|
+
costUsd: sumOptional([into.costUsd, usage.costUsd])
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
const positive = (value: number): number => (value > 0 ? value : 0)
|
|
229
|
+
|
|
230
|
+
/** What `b` has beyond `a`, per field, never below zero. */
|
|
231
|
+
const remainderOf = (ledger: UsageSums, trace: UsageSums): UsageSums => ({
|
|
232
|
+
prompt: positive(ledger.prompt - trace.prompt),
|
|
233
|
+
completion: positive(ledger.completion - trace.completion),
|
|
234
|
+
total: positive(ledger.total - trace.total),
|
|
235
|
+
cached: ledger.cached === undefined ? undefined : positive(ledger.cached - (trace.cached ?? 0)),
|
|
236
|
+
costUsd:
|
|
237
|
+
ledger.costUsd === undefined ? undefined : positive(ledger.costUsd - (trace.costUsd ?? 0))
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
const hasRemainder = (sums: UsageSums): boolean =>
|
|
241
|
+
sums.prompt > 0 ||
|
|
242
|
+
sums.completion > 0 ||
|
|
243
|
+
sums.total > 0 ||
|
|
244
|
+
(sums.cached ?? 0) > 0 ||
|
|
245
|
+
(sums.costUsd ?? 0) > 1e-9
|
|
246
|
+
|
|
247
|
+
interface UsageRow {
|
|
248
|
+
readonly agent: string
|
|
249
|
+
readonly model: string
|
|
250
|
+
readonly prompt: number
|
|
251
|
+
readonly completion: number
|
|
252
|
+
readonly total: number
|
|
253
|
+
readonly cached?: number
|
|
254
|
+
readonly costUsd?: number
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** A run's sums per agent and model, from its trace samples or its ledger cells. */
|
|
258
|
+
const sumsByAgentModel = (
|
|
259
|
+
rows: ReadonlyArray<UsageRow>
|
|
260
|
+
): ReadonlyMap<
|
|
261
|
+
string,
|
|
262
|
+
{ readonly agent: string; readonly model: string; readonly sums: UsageSums }
|
|
263
|
+
> => {
|
|
264
|
+
const groups = new Map<string, { agent: string; model: string; sums: UsageSums }>()
|
|
265
|
+
for (const row of rows) {
|
|
266
|
+
const key = JSON.stringify([row.agent, row.model])
|
|
267
|
+
const group = groups.get(key) ?? { agent: row.agent, model: row.model, sums: emptySums() }
|
|
268
|
+
groups.set(key, {
|
|
269
|
+
...group,
|
|
270
|
+
sums: addSums(group.sums, {
|
|
271
|
+
prompt: row.prompt,
|
|
272
|
+
completion: row.completion,
|
|
273
|
+
total: row.total,
|
|
274
|
+
cached: row.cached,
|
|
275
|
+
costUsd: row.costUsd
|
|
276
|
+
})
|
|
277
|
+
})
|
|
278
|
+
}
|
|
279
|
+
return groups
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export interface LedgerReconciliation {
|
|
283
|
+
readonly samples: ReadonlyArray<UsageSample>
|
|
284
|
+
/** Runs whose ledger line carried usage the trace did not: filled, in ledger order. */
|
|
285
|
+
readonly filledRuns: ReadonlyArray<string>
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Tops the trace's samples up from the ledger: for every ledger record, what
|
|
290
|
+
* its cells hold beyond the trace's samples of that run, per agent and
|
|
291
|
+
* model, becomes one `ledger` sample at the run's start (the only time the
|
|
292
|
+
* ledger knows). A run with no trace at all is taken wholly from its line;
|
|
293
|
+
* a run whose trace matches its line adds nothing. The result's total
|
|
294
|
+
* therefore equals the ledger's, which is what the run itself printed.
|
|
295
|
+
*/
|
|
296
|
+
export const reconcileWithLedger = (
|
|
297
|
+
samples: ReadonlyArray<UsageSample>,
|
|
298
|
+
records: ReadonlyArray<CostRecord>
|
|
299
|
+
): LedgerReconciliation => {
|
|
300
|
+
const filled: Array<UsageSample> = []
|
|
301
|
+
const filledRuns: Array<string> = []
|
|
302
|
+
for (const record of records) {
|
|
303
|
+
const traced = sumsByAgentModel(samples.filter((sample) => sample.runId === record.runId))
|
|
304
|
+
const ledgered = sumsByAgentModel(record.cells)
|
|
305
|
+
let filledThisRun = false
|
|
306
|
+
for (const [key, { agent, model, sums }] of ledgered) {
|
|
307
|
+
const remainder = remainderOf(sums, traced.get(key)?.sums ?? emptySums())
|
|
308
|
+
if (!hasRemainder(remainder)) {
|
|
309
|
+
continue
|
|
310
|
+
}
|
|
311
|
+
filledThisRun = true
|
|
312
|
+
filled.push(
|
|
313
|
+
UsageSample.make({
|
|
314
|
+
at: Date.parse(record.at),
|
|
315
|
+
runId: record.runId,
|
|
316
|
+
agent,
|
|
317
|
+
model,
|
|
318
|
+
prompt: remainder.prompt,
|
|
319
|
+
completion: remainder.completion,
|
|
320
|
+
total: remainder.total,
|
|
321
|
+
...(remainder.cached === undefined ? {} : { cached: remainder.cached }),
|
|
322
|
+
...(remainder.costUsd === undefined ? {} : { costUsd: remainder.costUsd }),
|
|
323
|
+
estimated: isEstimatedModel(model),
|
|
324
|
+
source: "ledger"
|
|
325
|
+
})
|
|
326
|
+
)
|
|
327
|
+
}
|
|
328
|
+
if (filledThisRun) {
|
|
329
|
+
filledRuns.push(record.runId)
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return { samples: [...samples, ...filled], filledRuns }
|
|
333
|
+
}
|
|
334
|
+
|
|
185
335
|
const distinctRuns = (samples: ReadonlyArray<UsageSample>): number =>
|
|
186
336
|
new Set(samples.map((sample) => sample.runId)).size
|
|
187
337
|
|
package/src/FlowRecorder.ts
CHANGED
|
@@ -24,9 +24,18 @@ export class TraceLine extends Schema.Class<TraceLine>("TraceLine")({
|
|
|
24
24
|
export const RunOutcome = Schema.Literals(["completed", "failed", "interrupted"])
|
|
25
25
|
export type RunOutcome = typeof RunOutcome.Type
|
|
26
26
|
|
|
27
|
+
/** Lines the recorder could not write: the trace is short by this much, from the first failure on. */
|
|
28
|
+
export interface TraceShortfall {
|
|
29
|
+
readonly lines: number
|
|
30
|
+
/** The first write failure, as the file store described it. */
|
|
31
|
+
readonly reason: string
|
|
32
|
+
}
|
|
33
|
+
|
|
27
34
|
export interface FlowRecorderShape extends StreamRecorder {
|
|
28
35
|
readonly runId: string
|
|
29
36
|
readonly tracePath: string
|
|
37
|
+
/** What the trace is missing, once a write failed; `undefined` while every line landed. */
|
|
38
|
+
readonly shortfall: Effect.Effect<TraceShortfall | undefined>
|
|
30
39
|
readonly record: (event: FlowEvent) => Effect.Effect<void>
|
|
31
40
|
readonly consume: (hub: FlowEventHub) => Effect.Effect<void, never, Scope.Scope>
|
|
32
41
|
/** Appends the `RunEnded` line: a reader knows the run is over, not stalled. */
|
|
@@ -53,12 +62,18 @@ export const makeFlowRecorder = Effect.fn("@llm4ts/flow/FlowRecorder.make")(func
|
|
|
53
62
|
const sequence = yield* Ref.make(0)
|
|
54
63
|
const consumed = yield* Ref.make(0)
|
|
55
64
|
const lock = yield* Semaphore.make(1)
|
|
56
|
-
|
|
65
|
+
// Set by the first failed write and never cleared: a disk that refused one
|
|
66
|
+
// line is not asked again, but every line it then loses is counted, so
|
|
67
|
+
// the run can say how short its trace is (`llm4ts costs` reads the
|
|
68
|
+
// ledger for the rest).
|
|
69
|
+
const degraded = yield* Ref.make<TraceShortfall | undefined>(undefined)
|
|
57
70
|
|
|
58
71
|
const append = (kind: string, fields: Readonly<Record<string, JsonValue>>): Effect.Effect<void> =>
|
|
59
72
|
lock.withPermit(
|
|
60
73
|
Effect.gen(function* () {
|
|
61
|
-
|
|
74
|
+
const shortfall = yield* Ref.get(degraded)
|
|
75
|
+
if (shortfall !== undefined) {
|
|
76
|
+
yield* Ref.set(degraded, { ...shortfall, lines: shortfall.lines + 1 })
|
|
62
77
|
return
|
|
63
78
|
}
|
|
64
79
|
const seq = yield* Ref.getAndUpdate(sequence, (current) => current + 1)
|
|
@@ -73,7 +88,7 @@ export const makeFlowRecorder = Effect.fn("@llm4ts/flow/FlowRecorder.make")(func
|
|
|
73
88
|
})
|
|
74
89
|
yield* files
|
|
75
90
|
.append(tracePath, `${encode(line)}\n`)
|
|
76
|
-
.pipe(Effect.catch(() => Ref.set(degraded,
|
|
91
|
+
.pipe(Effect.catch((error) => Ref.set(degraded, { lines: 1, reason: error.message })))
|
|
77
92
|
})
|
|
78
93
|
)
|
|
79
94
|
|
|
@@ -83,6 +98,7 @@ export const makeFlowRecorder = Effect.fn("@llm4ts/flow/FlowRecorder.make")(func
|
|
|
83
98
|
const recorder: FlowRecorderShape = {
|
|
84
99
|
runId,
|
|
85
100
|
tracePath,
|
|
101
|
+
shortfall: Ref.get(degraded),
|
|
86
102
|
record,
|
|
87
103
|
rawLine: (provider, model, line) =>
|
|
88
104
|
append("RawLine", {
|
package/src/GateTriage.ts
CHANGED
|
@@ -16,6 +16,97 @@ export type GateClass = typeof GateClass.Type
|
|
|
16
16
|
export const FailureOrigin = Schema.Literals(["new", "base", "flaky"])
|
|
17
17
|
export type FailureOrigin = typeof FailureOrigin.Type
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Why a test gate's exit code proves nothing: the runner collected or ran
|
|
21
|
+
* zero tests, or test files failed to load (an import that no longer
|
|
22
|
+
* resolves, a suite that errors before its first test). Either makes the
|
|
23
|
+
* gate red whatever the runner's exit code was.
|
|
24
|
+
*/
|
|
25
|
+
export const GateReason = Schema.Literals(["zero-tests", "load-failure"])
|
|
26
|
+
export type GateReason = typeof GateReason.Type
|
|
27
|
+
|
|
28
|
+
// One suite that errored before its tests ran, per runner: Vitest's
|
|
29
|
+
// "Failed Suites", Jest's "Test suite failed to run", pytest's collection
|
|
30
|
+
// errors, JUnit's class initialisation error under Surefire.
|
|
31
|
+
const loadFailureMarkers: ReadonlyArray<RegExp> = [
|
|
32
|
+
/Failed Suites\s+\d+/u,
|
|
33
|
+
/Test suite failed to run/u,
|
|
34
|
+
/\berrors? during collection\b/u,
|
|
35
|
+
/ExceptionInInitializerError|initializationError/u
|
|
36
|
+
]
|
|
37
|
+
const vitestNoTests = /\bTests\s+no tests\b/u
|
|
38
|
+
const vitestFilesFailed = /\bTest Files\s+\d+ failed\b/u
|
|
39
|
+
// Nothing collected: Vitest, Jest, pytest, Surefire, Gradle.
|
|
40
|
+
const zeroTestsMarkers: ReadonlyArray<RegExp> = [
|
|
41
|
+
/No test files found/u,
|
|
42
|
+
vitestNoTests,
|
|
43
|
+
/No tests found, exiting with code/u,
|
|
44
|
+
/\bTests:\s+0 total\b/u,
|
|
45
|
+
/\bno tests ran\b/u,
|
|
46
|
+
/\bcollected 0 items\b/u,
|
|
47
|
+
/No tests to run\./u,
|
|
48
|
+
/No tests found for given includes/u
|
|
49
|
+
]
|
|
50
|
+
const cargoRunning = /\brunning (\d+) tests?\b/gu
|
|
51
|
+
const junitRun = /\bTests run:\s*(\d+)/gu
|
|
52
|
+
const mochaPassing = /^\s*0 passing\b/mu
|
|
53
|
+
const mochaFailing = /^\s*\d+ failing\b/mu
|
|
54
|
+
|
|
55
|
+
const everyCountZero = (pattern: RegExp, output: string): boolean => {
|
|
56
|
+
let seen = false
|
|
57
|
+
pattern.lastIndex = 0
|
|
58
|
+
for (let match = pattern.exec(output); match !== null; match = pattern.exec(output)) {
|
|
59
|
+
seen = true
|
|
60
|
+
if (Number.parseInt(match[1] ?? "0", 10) > 0) {
|
|
61
|
+
return false
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return seen
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The reason a test runner's output cannot stand for a green gate, when
|
|
69
|
+
* it has one: `load-failure` when any suite errored before its tests ran
|
|
70
|
+
* (it wins, since a load failure also leaves tests unrun), `zero-tests`
|
|
71
|
+
* when the runner collected nothing (Vitest, Jest, pytest, cargo test,
|
|
72
|
+
* mocha, JUnit via Maven or Gradle). `undefined` means tests ran.
|
|
73
|
+
*/
|
|
74
|
+
export const testGateReasonOf = (output: string): GateReason | undefined => {
|
|
75
|
+
if (loadFailureMarkers.some((pattern) => pattern.test(output))) {
|
|
76
|
+
return "load-failure"
|
|
77
|
+
}
|
|
78
|
+
if (vitestNoTests.test(output) && vitestFilesFailed.test(output)) {
|
|
79
|
+
return "load-failure"
|
|
80
|
+
}
|
|
81
|
+
if (zeroTestsMarkers.some((pattern) => pattern.test(output))) {
|
|
82
|
+
return "zero-tests"
|
|
83
|
+
}
|
|
84
|
+
if (everyCountZero(cargoRunning, output) || everyCountZero(junitRun, output)) {
|
|
85
|
+
return "zero-tests"
|
|
86
|
+
}
|
|
87
|
+
if (mochaPassing.test(output) && !mochaFailing.test(output)) {
|
|
88
|
+
return "zero-tests"
|
|
89
|
+
}
|
|
90
|
+
return undefined
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The reason as the fix prompt and the findings read it. */
|
|
94
|
+
export const describeGateReason = (reason: GateReason): string =>
|
|
95
|
+
reason === "zero-tests"
|
|
96
|
+
? "test gate ran zero tests (zero-tests): the runner collected no test, so its exit code proves nothing"
|
|
97
|
+
: "test files failed to load (load-failure): a suite errored before its tests ran, so the gate is red whatever its exit code"
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The reason as a failing line, so a baseline records it and triage
|
|
101
|
+
* compares it like any other line: a target whose base already ran zero
|
|
102
|
+
* tests inherits that, a change that breaks loading is charged with it.
|
|
103
|
+
*/
|
|
104
|
+
export const gateReasonLine = (reason: GateReason): string => `gate reason: ${reason}`
|
|
105
|
+
|
|
106
|
+
/** The first gate reason a result carries, if any. */
|
|
107
|
+
export const gateReasonOf = (result: ReviewResult): GateReason | undefined =>
|
|
108
|
+
result.issues.find((issue) => isGateIssue(issue) && issue.gateReason !== undefined)?.gateReason
|
|
109
|
+
|
|
19
110
|
export class GateBaseline extends Schema.Class<GateBaseline>("GateBaseline")({
|
|
20
111
|
baseCommit: Schema.String,
|
|
21
112
|
appDir: Schema.String,
|
|
@@ -87,17 +178,22 @@ const legacyGateIssuePrefix = "lint failed: "
|
|
|
87
178
|
export const isGateIssue = (issue: ReviewIssue): boolean =>
|
|
88
179
|
issue.title.startsWith(gateIssuePrefix) || issue.title.startsWith(legacyGateIssuePrefix)
|
|
89
180
|
|
|
181
|
+
/** A gate issue's comparable lines: its reason first, then its normalized output. */
|
|
182
|
+
const failingLinesOfIssue = (
|
|
183
|
+
issue: ReviewIssue,
|
|
184
|
+
roots: ReadonlyArray<string>
|
|
185
|
+
): ReadonlyArray<string> => [
|
|
186
|
+
...(issue.gateReason === undefined ? [] : [gateReasonLine(issue.gateReason)]),
|
|
187
|
+
...normalizeGateOutput(issue.description, roots)
|
|
188
|
+
]
|
|
189
|
+
|
|
90
190
|
/** Every gate issue's normalized failing lines, in order, each once. */
|
|
91
191
|
export const failingLinesOf = (
|
|
92
192
|
result: ReviewResult,
|
|
93
193
|
roots: ReadonlyArray<string>
|
|
94
194
|
): ReadonlyArray<string> =>
|
|
95
195
|
Array.from(
|
|
96
|
-
new Set(
|
|
97
|
-
result.issues
|
|
98
|
-
.filter(isGateIssue)
|
|
99
|
-
.flatMap((issue) => normalizeGateOutput(issue.description, roots))
|
|
100
|
-
)
|
|
196
|
+
new Set(result.issues.filter(isGateIssue).flatMap((issue) => failingLinesOfIssue(issue, roots)))
|
|
101
197
|
)
|
|
102
198
|
|
|
103
199
|
export interface GateTriage {
|
|
@@ -134,18 +230,28 @@ export const triageGates = (
|
|
|
134
230
|
issues.push(issue)
|
|
135
231
|
continue
|
|
136
232
|
}
|
|
137
|
-
const lines =
|
|
233
|
+
const lines = failingLinesOfIssue(issue, roots)
|
|
138
234
|
const fresh = lines.filter((line) => !known.has(line))
|
|
139
235
|
inherited.push(...lines.filter((line) => known.has(line)))
|
|
140
236
|
if (lines.length > 0 && fresh.length === 0) {
|
|
141
237
|
continue
|
|
142
238
|
}
|
|
143
239
|
newLines.push(...fresh)
|
|
240
|
+
// A fresh reason keeps its sentence, so the fix prompt says what the
|
|
241
|
+
// runner's output meant, not only which lines were new.
|
|
242
|
+
const reasonLine = issue.gateReason === undefined ? undefined : gateReasonLine(issue.gateReason)
|
|
243
|
+
const freshOutput = fresh.filter((line) => line !== reasonLine)
|
|
244
|
+
const description =
|
|
245
|
+
issue.gateReason !== undefined && fresh.includes(reasonLine ?? "")
|
|
246
|
+
? [describeGateReason(issue.gateReason), ...freshOutput].join("\n")
|
|
247
|
+
: fresh.length > 0
|
|
248
|
+
? fresh.join("\n")
|
|
249
|
+
: issue.description
|
|
144
250
|
issues.push(
|
|
145
251
|
ReviewIssue.make({
|
|
146
252
|
...issue,
|
|
147
253
|
origin: "new",
|
|
148
|
-
description
|
|
254
|
+
description
|
|
149
255
|
})
|
|
150
256
|
)
|
|
151
257
|
}
|
package/src/GitTool.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { MergeConflict, ProcessError, type FlowError } from "./FlowError.ts"
|
|
|
6
6
|
import { Began, Timed, type FlowEventsShape } from "./FlowEvents.ts"
|
|
7
7
|
import { timeEffect } from "./Timing.ts"
|
|
8
8
|
import { guarded } from "./CapabilityGuard.ts"
|
|
9
|
+
import { RunStateDirectory } from "./RunState.ts"
|
|
9
10
|
|
|
10
11
|
export class BranchCreated extends Schema.TaggedClass<BranchCreated>()("Created", {}) {}
|
|
11
12
|
export class BranchAlreadyExists extends Schema.TaggedClass<BranchAlreadyExists>()(
|
|
@@ -149,15 +150,20 @@ const problem = (result: ProcessResult): string => {
|
|
|
149
150
|
}
|
|
150
151
|
|
|
151
152
|
/**
|
|
152
|
-
* The
|
|
153
|
-
*
|
|
154
|
-
* commits and
|
|
155
|
-
*
|
|
153
|
+
* The run's own state under `workDir/.llm4ts/`: traces, the cost ledger,
|
|
154
|
+
* plans, transcripts, epics. `commitAll` never stages it — it grows while
|
|
155
|
+
* the run commits and belongs to the machine, not the repository (a
|
|
156
|
+
* committed trace was read by every reviewer at real cost). `commitPaths`
|
|
157
|
+
* stages exactly what it is given, so a flow that keeps something there on
|
|
158
|
+
* purpose (a forked pack, a port ledger) names it.
|
|
156
159
|
*/
|
|
157
|
-
export const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
160
|
+
export const runStatePaths: ReadonlyArray<string> = [RunStateDirectory]
|
|
161
|
+
|
|
162
|
+
/** Whether a repository-relative path is the run state or inside it. */
|
|
163
|
+
export const isRunStatePath = (path: string): boolean => {
|
|
164
|
+
const normalized = path.replaceAll("\\", "/").replace(/^\.\//u, "")
|
|
165
|
+
return normalized === RunStateDirectory || normalized.startsWith(`${RunStateDirectory}/`)
|
|
166
|
+
}
|
|
161
167
|
|
|
162
168
|
export const makeGitTool = (
|
|
163
169
|
process: ProcessExecutorShape,
|
|
@@ -277,12 +283,14 @@ export const makeGitTool = (
|
|
|
277
283
|
const checkout = (name: string): Effect.Effect<void, FlowError> =>
|
|
278
284
|
write("git checkout", runOrFail(["checkout", name]).pipe(Effect.asVoid))
|
|
279
285
|
|
|
280
|
-
// Stage everything, then take the
|
|
281
|
-
//
|
|
282
|
-
//
|
|
283
|
-
//
|
|
286
|
+
// Stage everything, then take the run state back out of the index:
|
|
287
|
+
// `reset -- <path>` leaves an ignored or missing path alone and keeps a
|
|
288
|
+
// previously committed plan or trace at its HEAD version (so neither its
|
|
289
|
+
// change nor its deletion is staged), whereas an `:(exclude)` pathspec
|
|
290
|
+
// makes `add` refuse the ignored files outright. The `.gitignore` the
|
|
291
|
+
// runner leaves there (`RunState`) already keeps new files out of `add`.
|
|
284
292
|
const addAll = runOrFail(["add", "-A"]).pipe(
|
|
285
|
-
Effect.andThen(runOrFail(["reset", "-q", "--", ...
|
|
293
|
+
Effect.andThen(runOrFail(["reset", "-q", "--", ...runStatePaths])),
|
|
286
294
|
Effect.asVoid
|
|
287
295
|
)
|
|
288
296
|
|
|
@@ -294,9 +302,15 @@ export const makeGitTool = (
|
|
|
294
302
|
}
|
|
295
303
|
// "nothing to commit, working tree clean" when the tree is clean;
|
|
296
304
|
// "nothing added to commit but untracked files present" when the only
|
|
297
|
-
// changes are files this commit was not asked to stage
|
|
305
|
+
// changes are files this commit was not asked to stage; "no changes
|
|
306
|
+
// added to commit" when the only changes are to tracked files it was
|
|
307
|
+
// not asked to stage (a plan under the run state, revised by the run).
|
|
298
308
|
const detail = problem(result)
|
|
299
|
-
if (
|
|
309
|
+
if (
|
|
310
|
+
detail.includes("nothing to commit") ||
|
|
311
|
+
detail.includes("nothing added to commit") ||
|
|
312
|
+
detail.includes("no changes added to commit")
|
|
313
|
+
) {
|
|
300
314
|
return new NothingToCommit()
|
|
301
315
|
}
|
|
302
316
|
return yield* ProcessError.make({
|
|
@@ -311,13 +325,27 @@ export const makeGitTool = (
|
|
|
311
325
|
// `add -A -- <paths>` stages additions, modifications, AND deletions of
|
|
312
326
|
// exactly those paths; a plain `add` would refuse a deleted file. `--only`
|
|
313
327
|
// on commit would also work but ignores paths that were never tracked.
|
|
328
|
+
// A path under the run state is force-added: it is wanted (a forked
|
|
329
|
+
// pack, a port ledger) where the runner's own ignore file would refuse
|
|
330
|
+
// it. Any other path keeps the repository's ignore rules.
|
|
314
331
|
const commitPathsEffect = (
|
|
315
332
|
message: string,
|
|
316
333
|
paths: ReadonlyArray<string>
|
|
317
|
-
): Effect.Effect<CommitResult, FlowError> =>
|
|
318
|
-
paths.
|
|
334
|
+
): Effect.Effect<CommitResult, FlowError> => {
|
|
335
|
+
const underRunState = paths.filter(isRunStatePath)
|
|
336
|
+
const elsewhere = paths.filter((path) => !isRunStatePath(path))
|
|
337
|
+
return paths.length === 0
|
|
319
338
|
? Effect.succeed(new NothingToCommit())
|
|
320
|
-
:
|
|
339
|
+
: Effect.gen(function* () {
|
|
340
|
+
if (elsewhere.length > 0) {
|
|
341
|
+
yield* runOrFail(["add", "-A", "--", ...elsewhere])
|
|
342
|
+
}
|
|
343
|
+
if (underRunState.length > 0) {
|
|
344
|
+
yield* runOrFail(["add", "-A", "-f", "--", ...underRunState])
|
|
345
|
+
}
|
|
346
|
+
return yield* commitStaged(message)
|
|
347
|
+
})
|
|
348
|
+
}
|
|
321
349
|
|
|
322
350
|
return {
|
|
323
351
|
init: write(
|