@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
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import * as Option from "effect/Option"
|
|
2
|
+
import * as Schema from "effect/Schema"
|
|
3
|
+
import { AuthStatus, Availability } from "@llm4ts/core/Models"
|
|
4
|
+
import { BoardStatus } from "./BoardSync.ts"
|
|
5
|
+
import { CostReport } from "./CostReport.ts"
|
|
6
|
+
import { RunOutcome } from "./FlowRecorder.ts"
|
|
7
|
+
import { StoryStatus } from "./Stories.ts"
|
|
8
|
+
|
|
9
|
+
/** Where a run the operator started stands, as the registry last recorded it. */
|
|
10
|
+
export const OperatorRunState = Schema.Literals([
|
|
11
|
+
"starting",
|
|
12
|
+
"running",
|
|
13
|
+
"exited",
|
|
14
|
+
"interrupted",
|
|
15
|
+
"lost"
|
|
16
|
+
])
|
|
17
|
+
export type OperatorRunState = typeof OperatorRunState.Type
|
|
18
|
+
|
|
19
|
+
/** The typed knobs a client may set when it starts a run; every one is optional. */
|
|
20
|
+
export const OperatorKnobs = Schema.Struct({
|
|
21
|
+
task: Schema.optionalKey(Schema.String),
|
|
22
|
+
epic: Schema.optionalKey(Schema.String),
|
|
23
|
+
pack: Schema.optionalKey(Schema.String),
|
|
24
|
+
executors: Schema.optionalKey(Schema.Array(Schema.String)),
|
|
25
|
+
coder: Schema.optionalKey(Schema.String),
|
|
26
|
+
judgeRounds: Schema.optionalKey(Schema.Int),
|
|
27
|
+
mergeRevisions: Schema.optionalKey(Schema.Int),
|
|
28
|
+
/** The branch to land. */
|
|
29
|
+
land: Schema.optionalKey(Schema.String),
|
|
30
|
+
flags: Schema.optionalKey(Schema.Array(Schema.String)),
|
|
31
|
+
requestId: Schema.optionalKey(Schema.String)
|
|
32
|
+
})
|
|
33
|
+
export type OperatorKnobs = typeof OperatorKnobs.Type
|
|
34
|
+
|
|
35
|
+
/** One registry record: a run the operator started, appended on every state change. */
|
|
36
|
+
export class OperatorRun extends Schema.Class<OperatorRun>("OperatorRun")({
|
|
37
|
+
runId: Schema.String,
|
|
38
|
+
repo: Schema.String,
|
|
39
|
+
flow: Schema.String,
|
|
40
|
+
args: Schema.Array(Schema.String),
|
|
41
|
+
knobs: OperatorKnobs,
|
|
42
|
+
startedAt: Schema.Number,
|
|
43
|
+
pid: Schema.optionalKey(Schema.Int),
|
|
44
|
+
tracePath: Schema.optionalKey(Schema.String),
|
|
45
|
+
logDir: Schema.String,
|
|
46
|
+
state: OperatorRunState,
|
|
47
|
+
exitCode: Schema.optionalKey(Schema.Int),
|
|
48
|
+
signal: Schema.optionalKey(Schema.String),
|
|
49
|
+
endedAt: Schema.optionalKey(Schema.Number),
|
|
50
|
+
requestId: Schema.optionalKey(Schema.String)
|
|
51
|
+
}) {}
|
|
52
|
+
|
|
53
|
+
export const decodeOperatorRunLine = Schema.decodeUnknownOption(Schema.fromJsonString(OperatorRun))
|
|
54
|
+
export const encodeOperatorRunLine = Schema.encodeSync(Schema.fromJsonString(OperatorRun))
|
|
55
|
+
|
|
56
|
+
/** The last record per run, newest `startedAt` first; a line that does not decode is skipped. */
|
|
57
|
+
export const latestRuns = (lines: ReadonlyArray<string>): ReadonlyArray<OperatorRun> => {
|
|
58
|
+
const byRun = new Map<string, OperatorRun>()
|
|
59
|
+
for (const line of lines) {
|
|
60
|
+
if (line.trim().length === 0) continue
|
|
61
|
+
for (const run of Option.toArray(decodeOperatorRunLine(line))) byRun.set(run.runId, run)
|
|
62
|
+
}
|
|
63
|
+
return [...byRun.values()].sort((a, b) => b.startedAt - a.startedAt)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const LaneStatusOut = Schema.Struct({
|
|
67
|
+
id: Schema.String,
|
|
68
|
+
status: Schema.Literals(["running", "done", "failed"]),
|
|
69
|
+
stage: Schema.optionalKey(Schema.String),
|
|
70
|
+
executor: Schema.optionalKey(Schema.String),
|
|
71
|
+
activity: Schema.optionalKey(Schema.String),
|
|
72
|
+
lastEventAgeMs: Schema.Int,
|
|
73
|
+
costUsd: Schema.Number
|
|
74
|
+
})
|
|
75
|
+
export type LaneStatusOut = typeof LaneStatusOut.Type
|
|
76
|
+
|
|
77
|
+
export class RunStatus extends Schema.Class<RunStatus>("RunStatus")({
|
|
78
|
+
runId: Schema.String,
|
|
79
|
+
state: OperatorRunState,
|
|
80
|
+
ended: Schema.optionalKey(RunOutcome),
|
|
81
|
+
elapsedMs: Schema.Int,
|
|
82
|
+
lanes: Schema.Array(LaneStatusOut),
|
|
83
|
+
stories: Schema.Array(Schema.Struct({ id: Schema.String, status: BoardStatus })),
|
|
84
|
+
tokens: Schema.Int,
|
|
85
|
+
costUsd: Schema.Number,
|
|
86
|
+
warnings: Schema.Array(Schema.String),
|
|
87
|
+
seq: Schema.Int,
|
|
88
|
+
summary: Schema.String
|
|
89
|
+
}) {}
|
|
90
|
+
|
|
91
|
+
export const MeaningfulEvent = Schema.Struct({
|
|
92
|
+
seq: Schema.Int,
|
|
93
|
+
at: Schema.Number,
|
|
94
|
+
kind: Schema.String,
|
|
95
|
+
lane: Schema.optionalKey(Schema.String),
|
|
96
|
+
text: Schema.String
|
|
97
|
+
})
|
|
98
|
+
export type MeaningfulEvent = typeof MeaningfulEvent.Type
|
|
99
|
+
|
|
100
|
+
export class RunWaitResult extends Schema.Class<RunWaitResult>("RunWaitResult")({
|
|
101
|
+
events: Schema.Array(MeaningfulEvent),
|
|
102
|
+
seq: Schema.Int,
|
|
103
|
+
ended: Schema.optionalKey(RunOutcome),
|
|
104
|
+
exited: Schema.Boolean,
|
|
105
|
+
summary: Schema.String
|
|
106
|
+
}) {}
|
|
107
|
+
|
|
108
|
+
export const ConnectorHealthOut = Schema.Struct({
|
|
109
|
+
id: Schema.String,
|
|
110
|
+
availability: Availability,
|
|
111
|
+
authStatus: AuthStatus,
|
|
112
|
+
// "auth": the CLI's own login check answered; "version": only `--version`
|
|
113
|
+
// ran, which proves no live login; "unknown": nothing more can be said.
|
|
114
|
+
probe: Schema.Literals(["version", "auth", "unknown"]),
|
|
115
|
+
latencyMs: Schema.optionalKey(Schema.Int)
|
|
116
|
+
})
|
|
117
|
+
export type ConnectorHealthOut = typeof ConnectorHealthOut.Type
|
|
118
|
+
|
|
119
|
+
export const ExecutorHealthOut = Schema.Struct({
|
|
120
|
+
id: Schema.String,
|
|
121
|
+
harness: Schema.String,
|
|
122
|
+
roles: Schema.Array(Schema.String),
|
|
123
|
+
disabled: Schema.Boolean,
|
|
124
|
+
healthy: Schema.optionalKey(Schema.Boolean)
|
|
125
|
+
})
|
|
126
|
+
export type ExecutorHealthOut = typeof ExecutorHealthOut.Type
|
|
127
|
+
|
|
128
|
+
export class PreflightResult extends Schema.Class<PreflightResult>("PreflightResult")({
|
|
129
|
+
llm4ts: Schema.String,
|
|
130
|
+
node: Schema.String,
|
|
131
|
+
connectors: Schema.Array(ConnectorHealthOut),
|
|
132
|
+
executors: Schema.Array(ExecutorHealthOut),
|
|
133
|
+
notes: Schema.Array(Schema.String)
|
|
134
|
+
}) {}
|
|
135
|
+
|
|
136
|
+
export class StoryDetail extends Schema.Class<StoryDetail>("StoryDetail")({
|
|
137
|
+
id: Schema.String,
|
|
138
|
+
title: Schema.String,
|
|
139
|
+
state: Schema.optionalKey(
|
|
140
|
+
Schema.Struct({
|
|
141
|
+
status: StoryStatus,
|
|
142
|
+
branch: Schema.String,
|
|
143
|
+
worktree: Schema.String,
|
|
144
|
+
executor: Schema.optionalKey(Schema.String)
|
|
145
|
+
})
|
|
146
|
+
),
|
|
147
|
+
board: Schema.optionalKey(
|
|
148
|
+
Schema.Struct({ status: BoardStatus, detail: Schema.optionalKey(Schema.String) })
|
|
149
|
+
),
|
|
150
|
+
tasks: Schema.Array(Schema.Struct({ title: Schema.String, completed: Schema.Boolean })),
|
|
151
|
+
findings: Schema.optionalKey(Schema.String),
|
|
152
|
+
verdict: Schema.optionalKey(Schema.Json),
|
|
153
|
+
gateLogs: Schema.Array(Schema.String)
|
|
154
|
+
}) {}
|
|
155
|
+
|
|
156
|
+
export class Diagnosis extends Schema.Class<Diagnosis>("Diagnosis")({
|
|
157
|
+
runId: Schema.String,
|
|
158
|
+
story: Schema.optionalKey(Schema.String),
|
|
159
|
+
digest: Schema.String,
|
|
160
|
+
failure: Schema.optionalKey(
|
|
161
|
+
Schema.Struct({
|
|
162
|
+
stage: Schema.optionalKey(Schema.String),
|
|
163
|
+
message: Schema.String,
|
|
164
|
+
lane: Schema.optionalKey(Schema.String)
|
|
165
|
+
})
|
|
166
|
+
),
|
|
167
|
+
gateLogTails: Schema.Array(Schema.Struct({ path: Schema.String, tail: Schema.String })),
|
|
168
|
+
transcriptTail: Schema.Array(Schema.String)
|
|
169
|
+
}) {}
|
|
170
|
+
|
|
171
|
+
export class CostsOut extends Schema.Class<CostsOut>("CostsOut")({
|
|
172
|
+
report: CostReport,
|
|
173
|
+
/** The run's cost: its trace, topped up from its ledger line where the trace was short. */
|
|
174
|
+
total: Schema.Number,
|
|
175
|
+
ledger: Schema.optionalKey(Schema.Number),
|
|
176
|
+
/** USD the ledger line supplied beyond the trace, when the trace lost lines. */
|
|
177
|
+
fromLedger: Schema.optionalKey(Schema.Number),
|
|
178
|
+
/** The trace says more than the ledger line, which no line can fill. */
|
|
179
|
+
differs: Schema.Boolean
|
|
180
|
+
}) {}
|
|
181
|
+
|
|
182
|
+
export class UnknownRepo extends Schema.TaggedError<UnknownRepo>()("UnknownRepo", {
|
|
183
|
+
message: Schema.String
|
|
184
|
+
}) {}
|
|
185
|
+
|
|
186
|
+
export class UnknownRun extends Schema.TaggedError<UnknownRun>()("UnknownRun", {
|
|
187
|
+
message: Schema.String
|
|
188
|
+
}) {}
|
|
189
|
+
|
|
190
|
+
export class UnknownStory extends Schema.TaggedError<UnknownStory>()("UnknownStory", {
|
|
191
|
+
message: Schema.String
|
|
192
|
+
}) {}
|
|
193
|
+
|
|
194
|
+
export class UnknownFlow extends Schema.TaggedError<UnknownFlow>()("UnknownFlow", {
|
|
195
|
+
message: Schema.String
|
|
196
|
+
}) {}
|
|
197
|
+
|
|
198
|
+
export class NotAllowed extends Schema.TaggedError<NotAllowed>()("NotAllowed", {
|
|
199
|
+
message: Schema.String
|
|
200
|
+
}) {}
|
|
201
|
+
|
|
202
|
+
/** A start knob that would reach the flow's argv or environment in an unsafe shape. */
|
|
203
|
+
export class InvalidKnob extends Schema.TaggedError<InvalidKnob>()("InvalidKnob", {
|
|
204
|
+
knob: Schema.String,
|
|
205
|
+
message: Schema.String
|
|
206
|
+
}) {}
|
|
207
|
+
|
|
208
|
+
export class RunInProgress extends Schema.TaggedError<RunInProgress>()("RunInProgress", {
|
|
209
|
+
message: Schema.String,
|
|
210
|
+
runId: Schema.String
|
|
211
|
+
}) {}
|
|
212
|
+
|
|
213
|
+
/** An epic id that is not a folder under the repository's `.llm4ts/epics/`. */
|
|
214
|
+
export class UnknownEpic extends Schema.TaggedError<UnknownEpic>()("UnknownEpic", {
|
|
215
|
+
message: Schema.String
|
|
216
|
+
}) {}
|
|
217
|
+
|
|
218
|
+
/** A state file that exists but could not be read or parsed; the message names its path. */
|
|
219
|
+
export class ReadFailed extends Schema.TaggedError<ReadFailed>()("ReadFailed", {
|
|
220
|
+
message: Schema.String
|
|
221
|
+
}) {}
|
package/src/Retro.ts
CHANGED
|
@@ -431,6 +431,17 @@ const runLinePrefixes = [
|
|
|
431
431
|
"retro:"
|
|
432
432
|
]
|
|
433
433
|
|
|
434
|
+
/** One story's digest section, whatever its outcome; `undefined` when the plan has no such story. */
|
|
435
|
+
export const renderStoryDigest = (
|
|
436
|
+
inputs: RetroInputs,
|
|
437
|
+
storyId: string,
|
|
438
|
+
share: number = perStoryShare
|
|
439
|
+
): string | undefined => {
|
|
440
|
+
const trace = traceView(inputs.trace)
|
|
441
|
+
const entry = inputs.stories.find((candidate) => candidate.story.id === storyId)
|
|
442
|
+
return entry === undefined ? undefined : storySection(inputs, trace, entry, share)
|
|
443
|
+
}
|
|
444
|
+
|
|
434
445
|
/** The digest a retro seat is given: code-built, deterministic, capped. */
|
|
435
446
|
export const renderRetroDigest = (inputs: RetroInputs): string => {
|
|
436
447
|
const trace = traceView(inputs.trace)
|
package/src/Review.ts
CHANGED
|
@@ -19,7 +19,14 @@ import {
|
|
|
19
19
|
type FlowError
|
|
20
20
|
} from "./FlowError.ts"
|
|
21
21
|
import type { PlainFileStoreShape } from "./Persistence.ts"
|
|
22
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
GateBaseline,
|
|
24
|
+
describeGateReason,
|
|
25
|
+
gateIssuePrefix,
|
|
26
|
+
isGateIssue,
|
|
27
|
+
testGateReasonOf,
|
|
28
|
+
triageGates
|
|
29
|
+
} from "./GateTriage.ts"
|
|
23
30
|
import {
|
|
24
31
|
type OracleRules,
|
|
25
32
|
checkOracle,
|
|
@@ -75,6 +82,8 @@ export class ReviewIssue extends Schema.Class<ReviewIssue>("ReviewIssue")({
|
|
|
75
82
|
origin: Schema.optionalKey(Schema.Literals(["new", "base", "flaky"])),
|
|
76
83
|
/** How a gate ended: a red exit, killed at the timeout, or a signal exit. */
|
|
77
84
|
gateClass: Schema.optionalKey(Schema.Literals(["red", "hang", "crash"])),
|
|
85
|
+
/** Why a test gate's output could not stand for green: no tests ran, or test files failed to load. */
|
|
86
|
+
gateReason: Schema.optionalKey(Schema.Literals(["zero-tests", "load-failure"])),
|
|
78
87
|
/** Where the gate's full output was written, when the caller asked for a log. */
|
|
79
88
|
logPath: Schema.optionalKey(Schema.String)
|
|
80
89
|
}) {}
|
|
@@ -538,7 +547,11 @@ export const lintCommand = Effect.fn("@llm4ts/flow/Review.lintCommand")(function
|
|
|
538
547
|
)
|
|
539
548
|
)
|
|
540
549
|
)
|
|
541
|
-
const
|
|
550
|
+
const output = result === undefined ? "" : [...result.stdout, ...result.stderr].join("\n")
|
|
551
|
+
// A runner that collected nothing, or whose files failed to load, is red
|
|
552
|
+
// whatever it exited with: a green exit over zero tests proves nothing.
|
|
553
|
+
const reason = result === undefined ? undefined : testGateReasonOf(output)
|
|
554
|
+
const failed = result === undefined || result.exitCode !== 0 || reason !== undefined
|
|
542
555
|
yield* events.publish(
|
|
543
556
|
Timed.make({
|
|
544
557
|
kind: "gate",
|
|
@@ -550,16 +563,15 @@ export const lintCommand = Effect.fn("@llm4ts/flow/Review.lintCommand")(function
|
|
|
550
563
|
)
|
|
551
564
|
let logPath: string | undefined
|
|
552
565
|
if (options.log !== undefined) {
|
|
553
|
-
|
|
554
|
-
result === undefined ? "" : [...result.stdout, ...result.stderr].join("\n").trim()
|
|
555
|
-
yield* options.log.files.writeAtomic(options.log.path, output)
|
|
566
|
+
yield* options.log.files.writeAtomic(options.log.path, output.trim())
|
|
556
567
|
logPath = options.log.path
|
|
557
568
|
}
|
|
569
|
+
// No summary line to count and a reason not to trust the run: zero passed.
|
|
558
570
|
const passed =
|
|
559
571
|
result === undefined
|
|
560
572
|
? undefined
|
|
561
|
-
: passedCountIn(
|
|
562
|
-
if (
|
|
573
|
+
: (passedCountIn(output) ?? (reason === undefined ? undefined : 0))
|
|
574
|
+
if (!failed) {
|
|
563
575
|
return ReviewResult.make({
|
|
564
576
|
issues: [],
|
|
565
577
|
summary: "lint passed",
|
|
@@ -568,16 +580,18 @@ export const lintCommand = Effect.fn("@llm4ts/flow/Review.lintCommand")(function
|
|
|
568
580
|
}
|
|
569
581
|
const seconds =
|
|
570
582
|
options.timeout === undefined ? 0 : Math.round(Duration.toSeconds(options.timeout))
|
|
583
|
+
const problem =
|
|
584
|
+
result === undefined
|
|
585
|
+
? `gate killed: no exit after ${seconds} seconds (LLM4TS_GATE_TIMEOUT)`
|
|
586
|
+
: processProblem(result.stdout, result.stderr, result.exitCode)
|
|
571
587
|
return ReviewResult.make({
|
|
572
588
|
issues: [
|
|
573
589
|
ReviewIssue.make({
|
|
574
590
|
severity: "Critical",
|
|
575
591
|
title: `${gateIssuePrefix}${label}`,
|
|
576
|
-
description:
|
|
577
|
-
result === undefined
|
|
578
|
-
? `gate killed: no exit after ${seconds} seconds (LLM4TS_GATE_TIMEOUT)`
|
|
579
|
-
: processProblem(result.stdout, result.stderr, result.exitCode),
|
|
592
|
+
description: reason === undefined ? problem : `${describeGateReason(reason)}\n\n${problem}`,
|
|
580
593
|
gateClass: result === undefined ? "hang" : gateClassOf(result.exitCode),
|
|
594
|
+
...(reason === undefined ? {} : { gateReason: reason }),
|
|
581
595
|
...(logPath === undefined ? {} : { logPath })
|
|
582
596
|
})
|
|
583
597
|
],
|
package/src/RunState.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// The run's own state lives under `<repo>/.llm4ts/`: traces, the cost
|
|
2
|
+
// ledger, plans, transcripts, epics. It belongs to the machine the run
|
|
3
|
+
// happened on, not to the repository: a committed trace is read by every
|
|
4
|
+
// reviewer at real cost, and a coder that reaches it can wipe an earlier
|
|
5
|
+
// run. So the first write under a `.llm4ts/` directory leaves a
|
|
6
|
+
// `.gitignore` there that ignores everything, and a flow commit never
|
|
7
|
+
// stages the directory (see `GitTool.commitAll`).
|
|
8
|
+
import * as Effect from "effect/Effect"
|
|
9
|
+
import type { PlainFileStoreShape } from "./Persistence.ts"
|
|
10
|
+
|
|
11
|
+
/** The directory name, as a path segment. */
|
|
12
|
+
export const RunStateDirectory = ".llm4ts"
|
|
13
|
+
|
|
14
|
+
/** What `<dir>/.llm4ts/.gitignore` holds: everything under it is ignored. */
|
|
15
|
+
export const runStateIgnoreContents = "*\n"
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The glob every CLI seat is denied by default (ADR 0027 amendment,
|
|
19
|
+
* 2026-10-22): the run state, relative to the seat's working directory.
|
|
20
|
+
*/
|
|
21
|
+
export const runStateDeniedPaths: ReadonlyArray<string> = [`${RunStateDirectory}/**`]
|
|
22
|
+
|
|
23
|
+
const ignoreName = ".gitignore"
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The `<dir>/.llm4ts` prefix of `path` when the path has a `.llm4ts`
|
|
27
|
+
* segment; the first such segment, so a worktree nested under the run
|
|
28
|
+
* state (`.llm4ts/port/shards/1/.llm4ts/…`) still ignores the outer one
|
|
29
|
+
* first. Separators are kept as written.
|
|
30
|
+
*/
|
|
31
|
+
export const runStateRootOf = (path: string): string | undefined => {
|
|
32
|
+
const match = /^(.*?)(^|[\\/])\.llm4ts(?=[\\/]|$)/.exec(path)
|
|
33
|
+
return match === null ? undefined : `${match[1] ?? ""}${match[2] ?? ""}${RunStateDirectory}`
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The store with one rule on top: the first write under any `.llm4ts/`
|
|
38
|
+
* directory creates its `.gitignore` (`*`) when none exists, so nothing
|
|
39
|
+
* the run writes there is ever staged. An ignore file the repository
|
|
40
|
+
* already has is left as it is: a team that commits its own flows or
|
|
41
|
+
* forked packs keeps its exceptions. Idempotent to wrap twice.
|
|
42
|
+
*/
|
|
43
|
+
export const ignoringRunState = (store: PlainFileStoreShape): PlainFileStoreShape => {
|
|
44
|
+
const ensured = new Set<string>()
|
|
45
|
+
const ensureIgnore = (path: string): Effect.Effect<void, never> => {
|
|
46
|
+
const root = runStateRootOf(path)
|
|
47
|
+
if (root === undefined || ensured.has(root)) {
|
|
48
|
+
return Effect.void
|
|
49
|
+
}
|
|
50
|
+
const separator = root.includes("\\") && !root.includes("/") ? "\\" : "/"
|
|
51
|
+
const ignorePath = `${root}${separator}${ignoreName}`
|
|
52
|
+
if (path === ignorePath) {
|
|
53
|
+
return Effect.void
|
|
54
|
+
}
|
|
55
|
+
return store.read(ignorePath).pipe(
|
|
56
|
+
Effect.flatMap((existing) =>
|
|
57
|
+
existing === undefined ? store.writeAtomic(ignorePath, runStateIgnoreContents) : Effect.void
|
|
58
|
+
),
|
|
59
|
+
// A store that cannot write the ignore file is about to fail the
|
|
60
|
+
// write itself, with its own error; nothing to add here.
|
|
61
|
+
Effect.catch(() => Effect.void),
|
|
62
|
+
Effect.tap(() => Effect.sync(() => void ensured.add(root)))
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
...store,
|
|
67
|
+
writeAtomic: (path, contents) =>
|
|
68
|
+
ensureIgnore(path).pipe(Effect.andThen(store.writeAtomic(path, contents))),
|
|
69
|
+
append: (path, contents) =>
|
|
70
|
+
ensureIgnore(path).pipe(Effect.andThen(store.append(path, contents)))
|
|
71
|
+
}
|
|
72
|
+
}
|