@llm4ts/shell 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/Cli.d.ts +3 -7
- package/dist/Cli.d.ts.map +1 -1
- package/dist/Cli.js +20 -4
- package/dist/Cli.js.map +1 -1
- package/dist/FlowLaunch.d.ts +2 -16
- package/dist/FlowLaunch.d.ts.map +1 -1
- package/dist/FlowLaunch.js +20 -66
- package/dist/FlowLaunch.js.map +1 -1
- package/dist/FlowProcess.d.ts +67 -0
- package/dist/FlowProcess.d.ts.map +1 -0
- package/dist/FlowProcess.js +175 -0
- package/dist/FlowProcess.js.map +1 -0
- package/dist/Menu.d.ts +1 -1
- package/dist/Menu.d.ts.map +1 -1
- package/dist/OperatorActTools.d.ts +170 -0
- package/dist/OperatorActTools.d.ts.map +1 -0
- package/dist/OperatorActTools.js +332 -0
- package/dist/OperatorActTools.js.map +1 -0
- package/dist/OperatorConfig.d.ts +44 -0
- package/dist/OperatorConfig.d.ts.map +1 -0
- package/dist/OperatorConfig.js +88 -0
- package/dist/OperatorConfig.js.map +1 -0
- package/dist/OperatorNode.d.ts +45 -0
- package/dist/OperatorNode.d.ts.map +1 -0
- package/dist/OperatorNode.js +103 -0
- package/dist/OperatorNode.js.map +1 -0
- package/dist/OperatorRuns.d.ts +76 -0
- package/dist/OperatorRuns.d.ts.map +1 -0
- package/dist/OperatorRuns.js +146 -0
- package/dist/OperatorRuns.js.map +1 -0
- package/dist/OperatorServer.d.ts +160 -0
- package/dist/OperatorServer.d.ts.map +1 -0
- package/dist/OperatorServer.js +123 -0
- package/dist/OperatorServer.js.map +1 -0
- package/dist/OperatorTools.d.ts +558 -0
- package/dist/OperatorTools.d.ts.map +1 -0
- package/dist/OperatorTools.js +398 -0
- package/dist/OperatorTools.js.map +1 -0
- package/dist/Refine.d.ts +2 -2
- package/dist/Refine.d.ts.map +1 -1
- package/dist/Refine.js +3 -5
- package/dist/Refine.js.map +1 -1
- package/dist/RunSupervisor.d.ts +51 -0
- package/dist/RunSupervisor.d.ts.map +1 -0
- package/dist/RunSupervisor.js +282 -0
- package/dist/RunSupervisor.js.map +1 -0
- package/dist/ShellErrors.d.ts +9 -0
- package/dist/ShellErrors.d.ts.map +1 -0
- package/dist/ShellErrors.js +7 -0
- package/dist/ShellErrors.js.map +1 -0
- package/flows/port-guide.js +4 -1
- package/package.json +13 -4
- package/src/Cli.ts +64 -3
- package/src/FlowLaunch.ts +20 -66
- package/src/FlowProcess.ts +239 -0
- package/src/OperatorActTools.ts +407 -0
- package/src/OperatorConfig.ts +148 -0
- package/src/OperatorNode.ts +147 -0
- package/src/OperatorRuns.ts +270 -0
- package/src/OperatorServer.ts +192 -0
- package/src/OperatorTools.ts +610 -0
- package/src/Refine.ts +4 -7
- package/src/RunSupervisor.ts +403 -0
- package/src/ShellErrors.ts +6 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { spawn } from "node:child_process"
|
|
2
|
+
import { closeSync, existsSync, mkdirSync, openSync } from "node:fs"
|
|
3
|
+
import { dirname, join, resolve } from "node:path"
|
|
4
|
+
import { fileURLToPath } from "node:url"
|
|
5
|
+
import * as Deferred from "effect/Deferred"
|
|
6
|
+
import * as Effect from "effect/Effect"
|
|
7
|
+
import * as Ref from "effect/Ref"
|
|
8
|
+
import * as Schema from "effect/Schema"
|
|
9
|
+
|
|
10
|
+
export class FlowLaunchError extends Schema.TaggedError<FlowLaunchError>()("FlowLaunch", {
|
|
11
|
+
message: Schema.String
|
|
12
|
+
}) {}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* True when the flow script cannot resolve `@llm4ts/runner` from its own
|
|
16
|
+
* location — a global flow, a built-in copy, or a bare repository. Project
|
|
17
|
+
* flows inside a repository that installs `@llm4ts/*` resolve their own pin
|
|
18
|
+
* and need no help (the project-wins policy of ADR 0006). Implemented as the
|
|
19
|
+
* same `node_modules` directory walk ESM resolution performs, so the check
|
|
20
|
+
* stays honest under test runners that patch the module system.
|
|
21
|
+
*/
|
|
22
|
+
export const needsResolveFallback = (flowPath: string): boolean => {
|
|
23
|
+
let directory = dirname(resolve(flowPath))
|
|
24
|
+
while (true) {
|
|
25
|
+
if (existsSync(join(directory, "node_modules", "@llm4ts", "runner", "package.json"))) {
|
|
26
|
+
return false
|
|
27
|
+
}
|
|
28
|
+
const parent = dirname(directory)
|
|
29
|
+
if (parent === directory) {
|
|
30
|
+
return true
|
|
31
|
+
}
|
|
32
|
+
directory = parent
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// In the published package the hook is the compiled sibling; when running
|
|
37
|
+
// from src (tests), it is the TypeScript source, which the child can load
|
|
38
|
+
// because it always runs with type stripping enabled.
|
|
39
|
+
const resolveFallbackUrl = (() => {
|
|
40
|
+
const compiled = new URL("./ResolveFallback.js", import.meta.url)
|
|
41
|
+
return existsSync(fileURLToPath(compiled))
|
|
42
|
+
? compiled.href
|
|
43
|
+
: new URL("./ResolveFallback.ts", import.meta.url).href
|
|
44
|
+
})()
|
|
45
|
+
|
|
46
|
+
export interface FlowProcessStart {
|
|
47
|
+
readonly flowPath: string
|
|
48
|
+
readonly args: ReadonlyArray<string>
|
|
49
|
+
readonly environment: Readonly<Record<string, string | undefined>>
|
|
50
|
+
readonly cwd?: string
|
|
51
|
+
/**
|
|
52
|
+
* File paths are opened for append by the Node implementation. `ignore`
|
|
53
|
+
* discards both streams (used by the terminal launcher's tests).
|
|
54
|
+
*/
|
|
55
|
+
readonly stdio: "inherit" | "ignore" | { readonly stdout: string; readonly stderr: string }
|
|
56
|
+
/**
|
|
57
|
+
* Runs the child in its own process group, so a Ctrl-C on the parent's
|
|
58
|
+
* terminal or a whole-group kill reaches only the parent, which then stops
|
|
59
|
+
* the child once. The terminal launcher leaves it off: there the
|
|
60
|
+
* terminal's SIGINT must keep reaching the child.
|
|
61
|
+
*/
|
|
62
|
+
readonly detached?: boolean
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface FlowProcessExit {
|
|
66
|
+
readonly code: number | undefined
|
|
67
|
+
readonly signal: string | undefined
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface FlowProcessHandle {
|
|
71
|
+
readonly pid: number
|
|
72
|
+
readonly exited: Effect.Effect<FlowProcessExit>
|
|
73
|
+
readonly signal: (signal: "SIGINT" | "SIGTERM") => Effect.Effect<void>
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface FlowProcessShape {
|
|
77
|
+
readonly start: (options: FlowProcessStart) => Effect.Effect<FlowProcessHandle, FlowLaunchError>
|
|
78
|
+
readonly isAlive: (pid: number) => boolean
|
|
79
|
+
/** Signals a process this shape did not start (a run left by an earlier server). */
|
|
80
|
+
readonly signal: (pid: number, signal: "SIGINT" | "SIGTERM") => Effect.Effect<void>
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const flowNodeArgs = (flowPath: string): ReadonlyArray<string> => [
|
|
84
|
+
"--experimental-strip-types",
|
|
85
|
+
...(needsResolveFallback(flowPath) ? ["--import", resolveFallbackUrl] : [])
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
const openAppend = (path: string): number => {
|
|
89
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
90
|
+
return openSync(path, "a")
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export const nodeFlowProcess: FlowProcessShape = {
|
|
94
|
+
start: (options) =>
|
|
95
|
+
Effect.callback<FlowProcessHandle, FlowLaunchError>((resume) => {
|
|
96
|
+
const exited = Deferred.makeUnsafe<FlowProcessExit>()
|
|
97
|
+
const fds: Array<number> = []
|
|
98
|
+
const closeAll = (): void => {
|
|
99
|
+
for (const fd of fds.splice(0)) {
|
|
100
|
+
try {
|
|
101
|
+
closeSync(fd)
|
|
102
|
+
} catch {
|
|
103
|
+
// already closed
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
let stdio: "inherit" | "ignore" | Array<"ignore" | number>
|
|
108
|
+
try {
|
|
109
|
+
if (typeof options.stdio === "string") {
|
|
110
|
+
stdio = options.stdio
|
|
111
|
+
} else {
|
|
112
|
+
const out = openAppend(options.stdio.stdout)
|
|
113
|
+
fds.push(out)
|
|
114
|
+
const err = openAppend(options.stdio.stderr)
|
|
115
|
+
fds.push(err)
|
|
116
|
+
stdio = ["ignore", out, err]
|
|
117
|
+
}
|
|
118
|
+
} catch (error) {
|
|
119
|
+
closeAll()
|
|
120
|
+
resume(
|
|
121
|
+
Effect.fail(
|
|
122
|
+
new FlowLaunchError({ message: error instanceof Error ? error.message : String(error) })
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
return Effect.void
|
|
126
|
+
}
|
|
127
|
+
const child = spawn(
|
|
128
|
+
process.execPath,
|
|
129
|
+
[...flowNodeArgs(options.flowPath), options.flowPath, ...options.args],
|
|
130
|
+
{
|
|
131
|
+
stdio,
|
|
132
|
+
env: { ...options.environment },
|
|
133
|
+
...(options.detached === true ? { detached: true } : {}),
|
|
134
|
+
...(options.cwd === undefined ? {} : { cwd: options.cwd })
|
|
135
|
+
}
|
|
136
|
+
)
|
|
137
|
+
closeAll()
|
|
138
|
+
let started = false
|
|
139
|
+
child.on("error", (error) => {
|
|
140
|
+
if (!started) {
|
|
141
|
+
resume(Effect.fail(new FlowLaunchError({ message: error.message })))
|
|
142
|
+
}
|
|
143
|
+
})
|
|
144
|
+
child.on("spawn", () => {
|
|
145
|
+
started = true
|
|
146
|
+
resume(
|
|
147
|
+
Effect.succeed({
|
|
148
|
+
pid: child.pid ?? 0,
|
|
149
|
+
exited: Deferred.await(exited),
|
|
150
|
+
signal: (signal) =>
|
|
151
|
+
Effect.sync(() => {
|
|
152
|
+
child.kill(signal)
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
)
|
|
156
|
+
})
|
|
157
|
+
child.on("exit", (code, signal) => {
|
|
158
|
+
Deferred.doneUnsafe(
|
|
159
|
+
exited,
|
|
160
|
+
Effect.succeed({ code: code ?? undefined, signal: signal ?? undefined })
|
|
161
|
+
)
|
|
162
|
+
})
|
|
163
|
+
return Effect.void
|
|
164
|
+
}),
|
|
165
|
+
isAlive: (pid) => {
|
|
166
|
+
try {
|
|
167
|
+
process.kill(pid, 0)
|
|
168
|
+
return true
|
|
169
|
+
} catch {
|
|
170
|
+
return false
|
|
171
|
+
}
|
|
172
|
+
},
|
|
173
|
+
signal: (pid, signal) =>
|
|
174
|
+
Effect.sync(() => {
|
|
175
|
+
try {
|
|
176
|
+
process.kill(pid, signal)
|
|
177
|
+
} catch {
|
|
178
|
+
// already gone (ESRCH) or not ours to signal
|
|
179
|
+
}
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export interface FakeFlowProcess {
|
|
184
|
+
readonly process: FlowProcessShape
|
|
185
|
+
readonly started: Effect.Effect<ReadonlyArray<FlowProcessStart>>
|
|
186
|
+
readonly exit: (pid: number, exit: FlowProcessExit) => Effect.Effect<void>
|
|
187
|
+
readonly signals: Effect.Effect<ReadonlyArray<{ pid: number; signal: string }>>
|
|
188
|
+
readonly setAlive: (pid: number, alive: boolean) => Effect.Effect<void>
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export const makeFakeFlowProcess = (): Effect.Effect<FakeFlowProcess> =>
|
|
192
|
+
Effect.gen(function* () {
|
|
193
|
+
const starts = yield* Ref.make<ReadonlyArray<FlowProcessStart>>([])
|
|
194
|
+
const signals = yield* Ref.make<ReadonlyArray<{ pid: number; signal: string }>>([])
|
|
195
|
+
const alive = yield* Ref.make<ReadonlySet<number>>(new Set())
|
|
196
|
+
const exits = new Map<number, Deferred.Deferred<FlowProcessExit>>()
|
|
197
|
+
let nextPid = 1000
|
|
198
|
+
const setMember = (pid: number, member: boolean) =>
|
|
199
|
+
Ref.update(alive, (set) => {
|
|
200
|
+
const next = new Set(set)
|
|
201
|
+
if (member) {
|
|
202
|
+
next.add(pid)
|
|
203
|
+
} else {
|
|
204
|
+
next.delete(pid)
|
|
205
|
+
}
|
|
206
|
+
return next
|
|
207
|
+
})
|
|
208
|
+
const process: FlowProcessShape = {
|
|
209
|
+
start: (options) =>
|
|
210
|
+
Effect.gen(function* () {
|
|
211
|
+
const pid = nextPid++
|
|
212
|
+
const exited = yield* Deferred.make<FlowProcessExit>()
|
|
213
|
+
exits.set(pid, exited)
|
|
214
|
+
yield* Ref.update(starts, (all) => [...all, options])
|
|
215
|
+
yield* setMember(pid, true)
|
|
216
|
+
return {
|
|
217
|
+
pid,
|
|
218
|
+
exited: Deferred.await(exited),
|
|
219
|
+
signal: (signal) => Ref.update(signals, (all) => [...all, { pid, signal }])
|
|
220
|
+
}
|
|
221
|
+
}),
|
|
222
|
+
isAlive: (pid) => Ref.getUnsafe(alive).has(pid),
|
|
223
|
+
signal: (pid, signal) => Ref.update(signals, (all) => [...all, { pid, signal }])
|
|
224
|
+
}
|
|
225
|
+
return {
|
|
226
|
+
process,
|
|
227
|
+
started: Ref.get(starts),
|
|
228
|
+
signals: Ref.get(signals),
|
|
229
|
+
exit: (pid, exit) =>
|
|
230
|
+
Effect.gen(function* () {
|
|
231
|
+
yield* setMember(pid, false)
|
|
232
|
+
const deferred = exits.get(pid)
|
|
233
|
+
if (deferred !== undefined) {
|
|
234
|
+
yield* Deferred.succeed(deferred, exit)
|
|
235
|
+
}
|
|
236
|
+
}),
|
|
237
|
+
setAlive: (pid, value) => setMember(pid, value)
|
|
238
|
+
}
|
|
239
|
+
})
|
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
import * as Clock from "effect/Clock"
|
|
2
|
+
import * as Effect from "effect/Effect"
|
|
3
|
+
import * as Schema from "effect/Schema"
|
|
4
|
+
import * as Tool from "effect/ai/Tool"
|
|
5
|
+
import * as Toolkit from "effect/ai/Toolkit"
|
|
6
|
+
import { approveMarkdown } from "@llm4ts/flow/Approval"
|
|
7
|
+
import { PersistenceError } from "@llm4ts/flow/FlowError"
|
|
8
|
+
import {
|
|
9
|
+
InvalidKnob,
|
|
10
|
+
NotAllowed,
|
|
11
|
+
OperatorRun,
|
|
12
|
+
ReadFailed,
|
|
13
|
+
RunInProgress,
|
|
14
|
+
RunWaitResult,
|
|
15
|
+
UnknownEpic,
|
|
16
|
+
UnknownFlow,
|
|
17
|
+
UnknownRepo,
|
|
18
|
+
UnknownRun,
|
|
19
|
+
type OperatorKnobs,
|
|
20
|
+
type OperatorRunState
|
|
21
|
+
} from "@llm4ts/flow/OperatorSchemas"
|
|
22
|
+
import { retroApprovalOf, retroPaths } from "@llm4ts/flow/Retro"
|
|
23
|
+
import { emptyTree } from "@llm4ts/runner/AgentTree"
|
|
24
|
+
import {
|
|
25
|
+
TraceUnreadable,
|
|
26
|
+
foldTrace,
|
|
27
|
+
meaningfulEventsAfter,
|
|
28
|
+
runStatusOf,
|
|
29
|
+
type TraceFold
|
|
30
|
+
} from "@llm4ts/runner/RunReaders"
|
|
31
|
+
import { FlowLaunchError } from "./FlowProcess.ts"
|
|
32
|
+
import { resolveRepo, type OperatorDeps } from "./OperatorTools.ts"
|
|
33
|
+
import {
|
|
34
|
+
epicDirOf,
|
|
35
|
+
epicOfRun,
|
|
36
|
+
readEpicRunsStrict,
|
|
37
|
+
readFailed,
|
|
38
|
+
resolveRun,
|
|
39
|
+
tracePending,
|
|
40
|
+
type Repo
|
|
41
|
+
} from "./OperatorRuns.ts"
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The operator's `run_wait` and act tools (`llm4ts mcp`, ADR 0041): waiting on
|
|
45
|
+
* a run without polling from the client, and starting, stopping, retro-ing and
|
|
46
|
+
* approving through the run supervisor. `run_wait` only reads, so it has its
|
|
47
|
+
* own toolkit the server registers beside the read tools; the act toolkit is
|
|
48
|
+
* registered only when the configuration allows runs or approvals.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
const repoParam = Schema.optional(Schema.String)
|
|
52
|
+
|
|
53
|
+
// ---- run_wait -----------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
export const runWaitTool = Tool.make("run_wait", {
|
|
56
|
+
description:
|
|
57
|
+
"Wait for a run's next meaningful events after sinceSeq: returns as soon as one appears or the run is no longer running, else after timeoutSeconds (default 120, at most 300) with no events.",
|
|
58
|
+
parameters: Schema.Struct({
|
|
59
|
+
runId: Schema.String,
|
|
60
|
+
sinceSeq: Schema.optional(Schema.Int),
|
|
61
|
+
timeoutSeconds: Schema.optional(Schema.Int)
|
|
62
|
+
}),
|
|
63
|
+
success: RunWaitResult,
|
|
64
|
+
failure: Schema.Union([UnknownRun, TraceUnreadable, ReadFailed])
|
|
65
|
+
})
|
|
66
|
+
.annotate(Tool.Readonly, true)
|
|
67
|
+
.annotate(Tool.Destructive, false)
|
|
68
|
+
|
|
69
|
+
export const waitToolkit = Toolkit.make(runWaitTool)
|
|
70
|
+
|
|
71
|
+
const pollInterval = "500 millis"
|
|
72
|
+
const defaultWaitSeconds = 120
|
|
73
|
+
const maxWaitSeconds = 300
|
|
74
|
+
|
|
75
|
+
const isActive = (state: OperatorRunState): boolean => state === "starting" || state === "running"
|
|
76
|
+
|
|
77
|
+
const emptyFold = (): TraceFold => ({ lines: [], state: emptyTree(), seq: 0 })
|
|
78
|
+
|
|
79
|
+
export const waitHandlers = (
|
|
80
|
+
deps: OperatorDeps
|
|
81
|
+
): Toolkit.HandlersFrom<Toolkit.Tools<typeof waitToolkit>> =>
|
|
82
|
+
waitToolkit.of({
|
|
83
|
+
run_wait: ({ runId, sinceSeq, timeoutSeconds }) =>
|
|
84
|
+
Effect.gen(function* () {
|
|
85
|
+
const since = sinceSeq ?? 0
|
|
86
|
+
const timeoutMs =
|
|
87
|
+
Math.min(Math.max(0, timeoutSeconds ?? defaultWaitSeconds), maxWaitSeconds) * 1_000
|
|
88
|
+
const run = yield* resolveRun(deps, runId)
|
|
89
|
+
const epic = yield* epicOfRun(deps, run)
|
|
90
|
+
const startedAt = yield* Clock.currentTimeMillis
|
|
91
|
+
for (;;) {
|
|
92
|
+
const fold = (yield* tracePending(deps.files, run))
|
|
93
|
+
? emptyFold()
|
|
94
|
+
: yield* foldTrace(deps.files, run.tracePath, epic?.dir)
|
|
95
|
+
// A supervised run's state is the supervisor's; a run found on disk ends with its trace.
|
|
96
|
+
const state: OperatorRunState =
|
|
97
|
+
run.supervised === undefined
|
|
98
|
+
? fold.lines.some((line) => line.kind === "RunEnded")
|
|
99
|
+
? "exited"
|
|
100
|
+
: run.state
|
|
101
|
+
: ((yield* deps.supervisor.get(runId))?.state ?? run.state)
|
|
102
|
+
const events = meaningfulEventsAfter(fold.lines, since)
|
|
103
|
+
const exited = !isActive(state)
|
|
104
|
+
const elapsed = (yield* Clock.currentTimeMillis) - startedAt
|
|
105
|
+
if (events.length > 0 || exited || elapsed >= timeoutMs) {
|
|
106
|
+
const status = runStatusOf(fold, { ...run, state }, yield* deps.now)
|
|
107
|
+
return RunWaitResult.make({
|
|
108
|
+
events,
|
|
109
|
+
seq: Math.max(fold.seq, since),
|
|
110
|
+
...(status.ended === undefined ? {} : { ended: status.ended }),
|
|
111
|
+
exited,
|
|
112
|
+
summary: status.summary
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
yield* Effect.sleep(pollInterval)
|
|
116
|
+
}
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
// ---- Act tools ----------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
export const StartedRun = Schema.Struct({
|
|
123
|
+
runId: Schema.String,
|
|
124
|
+
tracePath: Schema.optionalKey(Schema.String),
|
|
125
|
+
pid: Schema.optionalKey(Schema.Int)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
const startFailures = Schema.Union([
|
|
129
|
+
NotAllowed,
|
|
130
|
+
InvalidKnob,
|
|
131
|
+
UnknownRepo,
|
|
132
|
+
UnknownEpic,
|
|
133
|
+
UnknownRun,
|
|
134
|
+
UnknownFlow,
|
|
135
|
+
ReadFailed,
|
|
136
|
+
FlowLaunchError,
|
|
137
|
+
RunInProgress,
|
|
138
|
+
PersistenceError
|
|
139
|
+
])
|
|
140
|
+
|
|
141
|
+
const StartRunParams = Schema.Struct({
|
|
142
|
+
repo: repoParam,
|
|
143
|
+
flow: Schema.String,
|
|
144
|
+
task: Schema.optional(Schema.String),
|
|
145
|
+
epic: Schema.optional(Schema.String),
|
|
146
|
+
pack: Schema.optional(Schema.String),
|
|
147
|
+
executors: Schema.optional(Schema.Array(Schema.String)),
|
|
148
|
+
coder: Schema.optional(Schema.String),
|
|
149
|
+
judgeRounds: Schema.optional(Schema.Int),
|
|
150
|
+
mergeRevisions: Schema.optional(Schema.Int),
|
|
151
|
+
land: Schema.optional(Schema.String),
|
|
152
|
+
flags: Schema.optional(Schema.Array(Schema.String)),
|
|
153
|
+
requestId: Schema.optional(Schema.String)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
export const startRunTool = Tool.make("start_run", {
|
|
157
|
+
description:
|
|
158
|
+
"Start a flow by name in the background with typed knobs; returns the run id at once. Repeating a call is safe only with the same requestId, which returns the run it already started; without one, every call starts a new run.",
|
|
159
|
+
parameters: StartRunParams,
|
|
160
|
+
success: StartedRun,
|
|
161
|
+
failure: startFailures
|
|
162
|
+
}).annotate(Tool.Destructive, false)
|
|
163
|
+
|
|
164
|
+
export const stopRunTool = Tool.make("stop_run", {
|
|
165
|
+
description:
|
|
166
|
+
"Stop a run this server started: SIGINT, then SIGTERM after a grace period; returns its final record.",
|
|
167
|
+
parameters: Schema.Struct({ runId: Schema.String }),
|
|
168
|
+
success: OperatorRun,
|
|
169
|
+
failure: Schema.Union([NotAllowed, UnknownRun])
|
|
170
|
+
}).annotate(Tool.Destructive, true)
|
|
171
|
+
|
|
172
|
+
export const retroStartTool = Tool.make("retro_start", {
|
|
173
|
+
description:
|
|
174
|
+
"Start epic-retro for an epic, optionally for one of its recorded runs; the retro report then waits for approve.",
|
|
175
|
+
parameters: Schema.Struct({
|
|
176
|
+
repo: repoParam,
|
|
177
|
+
epic: Schema.String,
|
|
178
|
+
run: Schema.optional(Schema.String)
|
|
179
|
+
}),
|
|
180
|
+
success: StartedRun,
|
|
181
|
+
failure: startFailures
|
|
182
|
+
}).annotate(Tool.Destructive, false)
|
|
183
|
+
|
|
184
|
+
export const approveTool = Tool.make("approve", {
|
|
185
|
+
description:
|
|
186
|
+
"Approve a retro report: ticks its approval marker so the next epic run applies its fixes.",
|
|
187
|
+
parameters: Schema.Struct({
|
|
188
|
+
repo: repoParam,
|
|
189
|
+
kind: Schema.Literals(["retro"]),
|
|
190
|
+
epic: Schema.String,
|
|
191
|
+
id: Schema.String
|
|
192
|
+
}),
|
|
193
|
+
success: Schema.Struct({ path: Schema.String, approved: Schema.Boolean }),
|
|
194
|
+
failure: Schema.Union([
|
|
195
|
+
NotAllowed,
|
|
196
|
+
UnknownRepo,
|
|
197
|
+
UnknownEpic,
|
|
198
|
+
UnknownRun,
|
|
199
|
+
ReadFailed,
|
|
200
|
+
PersistenceError
|
|
201
|
+
])
|
|
202
|
+
}).annotate(Tool.Destructive, false)
|
|
203
|
+
|
|
204
|
+
export const actToolkit = Toolkit.make(startRunTool, stopRunTool, retroStartTool, approveTool)
|
|
205
|
+
|
|
206
|
+
const knobsOf = (params: typeof StartRunParams.Type): OperatorKnobs => ({
|
|
207
|
+
...(params.task === undefined ? {} : { task: params.task }),
|
|
208
|
+
...(params.epic === undefined ? {} : { epic: params.epic }),
|
|
209
|
+
...(params.pack === undefined ? {} : { pack: params.pack }),
|
|
210
|
+
...(params.executors === undefined ? {} : { executors: params.executors }),
|
|
211
|
+
...(params.coder === undefined ? {} : { coder: params.coder }),
|
|
212
|
+
...(params.judgeRounds === undefined ? {} : { judgeRounds: params.judgeRounds }),
|
|
213
|
+
...(params.mergeRevisions === undefined ? {} : { mergeRevisions: params.mergeRevisions }),
|
|
214
|
+
...(params.land === undefined ? {} : { land: params.land }),
|
|
215
|
+
...(params.flags === undefined ? {} : { flags: params.flags }),
|
|
216
|
+
...(params.requestId === undefined ? {} : { requestId: params.requestId })
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
const namePattern = /^[A-Za-z0-9][A-Za-z0-9._-]*$/u
|
|
220
|
+
const packPattern = /^[\w.-]+(\/[\w.-]+)?$/u
|
|
221
|
+
const branchPattern = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/u
|
|
222
|
+
|
|
223
|
+
const flagPattern = /^--[a-z][a-z0-9-]*(=[A-Za-z0-9][A-Za-z0-9._/-]*)?$/u
|
|
224
|
+
const flagValuePattern = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/u
|
|
225
|
+
/**
|
|
226
|
+
* Flags a client may never pass through `flags`, with or without `=value`.
|
|
227
|
+
* `-C` already fails the shape pattern; it is named so a looser pattern
|
|
228
|
+
* cannot reopen it.
|
|
229
|
+
*/
|
|
230
|
+
const reservedFlags: ReadonlyArray<string> = [
|
|
231
|
+
// Points the flow at another directory (`FlowArgs.ts`).
|
|
232
|
+
"--repo",
|
|
233
|
+
// Alias of --repo in the flow argument parser.
|
|
234
|
+
"-C",
|
|
235
|
+
// Reads the prompt from any file on the server.
|
|
236
|
+
"--prompt-file",
|
|
237
|
+
// Typed knob: epic.
|
|
238
|
+
"--epic",
|
|
239
|
+
// Typed knob: land.
|
|
240
|
+
"--land",
|
|
241
|
+
// Typed knob: judgeRounds.
|
|
242
|
+
"--judge-rounds",
|
|
243
|
+
// Typed knob: mergeRevisions.
|
|
244
|
+
"--merge-revisions",
|
|
245
|
+
// Lets soap flows send mutating calls to a real ESB.
|
|
246
|
+
"--allow-mutating",
|
|
247
|
+
// Auto-confirms prompts a human is meant to answer.
|
|
248
|
+
"--yes",
|
|
249
|
+
// Selects which environment's credentials a flow uses.
|
|
250
|
+
"--env",
|
|
251
|
+
// A filesystem path in epic-design.
|
|
252
|
+
"--target"
|
|
253
|
+
]
|
|
254
|
+
|
|
255
|
+
const reservedFlagOf = (entry: string): string | undefined =>
|
|
256
|
+
reservedFlags.find((flag) => entry === flag || entry.startsWith(`${flag}=`))
|
|
257
|
+
|
|
258
|
+
/** Why a flags list is refused, or undefined when every entry has a safe shape. */
|
|
259
|
+
const flagsProblem = (flags: ReadonlyArray<string>): string | undefined => {
|
|
260
|
+
let previousWasFlag = false
|
|
261
|
+
for (const entry of flags) {
|
|
262
|
+
const reserved = reservedFlagOf(entry)
|
|
263
|
+
if (reserved !== undefined) return `${reserved} may not be passed through flags`
|
|
264
|
+
const flag = flagPattern.exec(entry)
|
|
265
|
+
if (flag !== null) {
|
|
266
|
+
previousWasFlag = flag[1] === undefined
|
|
267
|
+
continue
|
|
268
|
+
}
|
|
269
|
+
if (!previousWasFlag || !flagValuePattern.test(entry)) {
|
|
270
|
+
return "every entry must be a --flag[=value] or a plain value right after a flag"
|
|
271
|
+
}
|
|
272
|
+
previousWasFlag = false
|
|
273
|
+
}
|
|
274
|
+
return undefined
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const invalid = (knob: string, message: string): Effect.Effect<never, InvalidKnob> =>
|
|
278
|
+
Effect.fail(InvalidKnob.make({ knob, message }))
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Every client value that reaches the flow's argv or environment, checked
|
|
282
|
+
* before the supervisor sees it. Names stay names, never paths or flags:
|
|
283
|
+
* `pack` is a kit or kit/pack name (no `.`/`..` segment), `epic`, `coder` and
|
|
284
|
+
* executors are ids, `land` is a branch name. `task` is the flow's first
|
|
285
|
+
* positional argument, so it may not start with `-` (it would parse as a
|
|
286
|
+
* flag) or `@` (the flow parser reads `@<path>` as a prompt file on the
|
|
287
|
+
* server). `flags` is an allowlist of shapes: `--flag[=value]`, or a plain
|
|
288
|
+
* value right after a flag, never a reserved flag (`reservedFlags`).
|
|
289
|
+
*/
|
|
290
|
+
export const validateKnobs = (knobs: OperatorKnobs): Effect.Effect<OperatorKnobs, InvalidKnob> => {
|
|
291
|
+
if (
|
|
292
|
+
knobs.pack !== undefined &&
|
|
293
|
+
(!packPattern.test(knobs.pack) ||
|
|
294
|
+
knobs.pack.split("/").some((segment) => segment === "." || segment === ".."))
|
|
295
|
+
) {
|
|
296
|
+
return invalid("pack", "pack must be a kit or kit/pack name, not a path")
|
|
297
|
+
}
|
|
298
|
+
if (knobs.epic !== undefined && !namePattern.test(knobs.epic)) {
|
|
299
|
+
return invalid("epic", "epic must be an epic id")
|
|
300
|
+
}
|
|
301
|
+
if (knobs.coder !== undefined && !namePattern.test(knobs.coder)) {
|
|
302
|
+
return invalid("coder", "coder must be an executor or connector id")
|
|
303
|
+
}
|
|
304
|
+
if (knobs.executors?.some((id) => !namePattern.test(id)) === true) {
|
|
305
|
+
return invalid("executors", "every executor must be an executor id")
|
|
306
|
+
}
|
|
307
|
+
if (knobs.land !== undefined && !branchPattern.test(knobs.land)) {
|
|
308
|
+
return invalid("land", "land must be a branch name")
|
|
309
|
+
}
|
|
310
|
+
const nonNegative = (value: number | undefined) =>
|
|
311
|
+
value === undefined || (Number.isInteger(value) && value >= 0)
|
|
312
|
+
if (!nonNegative(knobs.judgeRounds)) {
|
|
313
|
+
return invalid("judgeRounds", "judgeRounds must be an integer of 0 or more")
|
|
314
|
+
}
|
|
315
|
+
if (!nonNegative(knobs.mergeRevisions)) {
|
|
316
|
+
return invalid("mergeRevisions", "mergeRevisions must be an integer of 0 or more")
|
|
317
|
+
}
|
|
318
|
+
if (knobs.task !== undefined && (knobs.task.startsWith("-") || knobs.task.startsWith("@"))) {
|
|
319
|
+
return invalid("task", "task must not start with '-' or '@'")
|
|
320
|
+
}
|
|
321
|
+
const problem = knobs.flags === undefined ? undefined : flagsProblem(knobs.flags)
|
|
322
|
+
if (problem !== undefined) return invalid("flags", problem)
|
|
323
|
+
return Effect.succeed(knobs)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** A run or retro id names one file, never a path. */
|
|
327
|
+
const isPlainId = (id: string): boolean => /^[\w.-]+$/u.test(id) && id !== "." && id !== ".."
|
|
328
|
+
|
|
329
|
+
export const actHandlers = (
|
|
330
|
+
deps: OperatorDeps
|
|
331
|
+
): Toolkit.HandlersFrom<Toolkit.Tools<typeof actToolkit>> => {
|
|
332
|
+
const requireRun = deps.config.allowRun
|
|
333
|
+
? Effect.void
|
|
334
|
+
: Effect.fail(NotAllowed.make({ message: "this server was not started with --allow-run" }))
|
|
335
|
+
const requireApprove = deps.config.allowApprove
|
|
336
|
+
? Effect.void
|
|
337
|
+
: Effect.fail(NotAllowed.make({ message: "this server was not started with --allow-approve" }))
|
|
338
|
+
|
|
339
|
+
const startFlow = (repo: Repo, name: string, knobs: OperatorKnobs) =>
|
|
340
|
+
Effect.gen(function* () {
|
|
341
|
+
const flows = yield* deps.flows(repo.path).pipe(Effect.mapError(readFailed))
|
|
342
|
+
const flow = flows.find((candidate) => candidate.name === name)
|
|
343
|
+
if (flow === undefined) {
|
|
344
|
+
return yield* UnknownFlow.make({
|
|
345
|
+
message: `unknown flow '${name}' in ${repo.id}: list_flows names the flows it can start`
|
|
346
|
+
})
|
|
347
|
+
}
|
|
348
|
+
const run = yield* deps.supervisor.start({
|
|
349
|
+
repo,
|
|
350
|
+
flow: { name: flow.name, path: flow.path },
|
|
351
|
+
knobs,
|
|
352
|
+
baseEnvironment: deps.environment
|
|
353
|
+
})
|
|
354
|
+
return {
|
|
355
|
+
runId: run.runId,
|
|
356
|
+
...(run.tracePath === undefined ? {} : { tracePath: run.tracePath }),
|
|
357
|
+
...(run.pid === undefined ? {} : { pid: run.pid })
|
|
358
|
+
}
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
return actToolkit.of({
|
|
362
|
+
start_run: (params) =>
|
|
363
|
+
Effect.gen(function* () {
|
|
364
|
+
yield* requireRun
|
|
365
|
+
const repo = yield* resolveRepo(deps.config, params.repo)
|
|
366
|
+
const knobs = yield* validateKnobs(knobsOf(params))
|
|
367
|
+
return yield* startFlow(repo, params.flow, knobs)
|
|
368
|
+
}),
|
|
369
|
+
|
|
370
|
+
stop_run: ({ runId }) => Effect.andThen(requireRun, deps.supervisor.stop(runId)),
|
|
371
|
+
|
|
372
|
+
retro_start: ({ repo: repoId, epic, run }) =>
|
|
373
|
+
Effect.gen(function* () {
|
|
374
|
+
yield* requireRun
|
|
375
|
+
const repo = yield* resolveRepo(deps.config, repoId)
|
|
376
|
+
const dir = yield* epicDirOf(deps, repo, epic)
|
|
377
|
+
if (run !== undefined) {
|
|
378
|
+
const recorded = yield* readEpicRunsStrict(deps.files, dir)
|
|
379
|
+
if (!recorded.some((entry) => entry.runId === run)) {
|
|
380
|
+
return yield* UnknownRun.make({
|
|
381
|
+
message: `epic '${epic}' has no recorded run '${run}'`
|
|
382
|
+
})
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
// The client's values, checked like start_run's; the --epic flag itself is ours.
|
|
386
|
+
yield* validateKnobs({ epic, ...(run === undefined ? {} : { flags: ["--run", run] }) })
|
|
387
|
+
return yield* startFlow(repo, "epic-retro", {
|
|
388
|
+
flags: ["--epic", epic, ...(run === undefined ? [] : ["--run", run])]
|
|
389
|
+
})
|
|
390
|
+
}),
|
|
391
|
+
|
|
392
|
+
approve: ({ repo: repoId, epic, id }) =>
|
|
393
|
+
Effect.gen(function* () {
|
|
394
|
+
yield* requireApprove
|
|
395
|
+
const repo = yield* resolveRepo(deps.config, repoId)
|
|
396
|
+
const dir = yield* epicDirOf(deps, repo, epic)
|
|
397
|
+
const unknown = UnknownRun.make({ message: `no retro '${id}' for epic '${epic}'` })
|
|
398
|
+
if (!isPlainId(id)) return yield* unknown
|
|
399
|
+
const path = retroPaths(dir, id).report
|
|
400
|
+
const markdown = yield* deps.files.read(path).pipe(Effect.mapError(readFailed))
|
|
401
|
+
if (markdown === undefined) return yield* unknown
|
|
402
|
+
const approved = approveMarkdown(markdown)
|
|
403
|
+
if (approved !== markdown) yield* deps.files.writeAtomic(path, approved)
|
|
404
|
+
return { path, approved: retroApprovalOf(approved).approved }
|
|
405
|
+
})
|
|
406
|
+
})
|
|
407
|
+
}
|