@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,148 @@
|
|
|
1
|
+
import { homedir } from "node:os"
|
|
2
|
+
import { basename, join, resolve } from "node:path"
|
|
3
|
+
import * as Effect from "effect/Effect"
|
|
4
|
+
import * as Option from "effect/Option"
|
|
5
|
+
import * as Schema from "effect/Schema"
|
|
6
|
+
import { ShellUsageError } from "./ShellErrors.ts"
|
|
7
|
+
|
|
8
|
+
export interface McpFlags {
|
|
9
|
+
readonly stdio: boolean
|
|
10
|
+
readonly http: boolean
|
|
11
|
+
readonly host: Option.Option<string>
|
|
12
|
+
readonly port: Option.Option<number>
|
|
13
|
+
readonly tokenFile: Option.Option<string>
|
|
14
|
+
readonly repo: ReadonlyArray<string>
|
|
15
|
+
readonly allowRun: boolean
|
|
16
|
+
readonly allowApprove: boolean
|
|
17
|
+
readonly parallelRuns: Option.Option<number>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface OperatorConfig {
|
|
21
|
+
readonly transport:
|
|
22
|
+
| { readonly _tag: "stdio" }
|
|
23
|
+
| {
|
|
24
|
+
readonly _tag: "http"
|
|
25
|
+
readonly host: string
|
|
26
|
+
readonly port: number
|
|
27
|
+
readonly token: string
|
|
28
|
+
}
|
|
29
|
+
readonly repos: ReadonlyArray<{ readonly id: string; readonly path: string }>
|
|
30
|
+
readonly allowRun: boolean
|
|
31
|
+
readonly allowApprove: boolean
|
|
32
|
+
readonly parallelRuns: number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const McpConfigFile = Schema.Struct({
|
|
36
|
+
repos: Schema.Array(Schema.Struct({ id: Schema.String, path: Schema.String }))
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
/** The same name shape as an epic id: it names the repository's state directory. */
|
|
40
|
+
const repoIdPattern = /^[A-Za-z0-9][A-Za-z0-9._-]*$/u
|
|
41
|
+
|
|
42
|
+
const usage = (message: string): ShellUsageError => new ShellUsageError({ message })
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Turns the `mcp` flags into the operator configuration. The token is read from the
|
|
46
|
+
* environment or a file and is never echoed in an error message.
|
|
47
|
+
*/
|
|
48
|
+
export const mcpOptionsFrom = (
|
|
49
|
+
flags: McpFlags,
|
|
50
|
+
environment: Readonly<Record<string, string | undefined>>,
|
|
51
|
+
cwd: string,
|
|
52
|
+
readFile: (path: string) => Effect.Effect<string | undefined>
|
|
53
|
+
): Effect.Effect<OperatorConfig, ShellUsageError> =>
|
|
54
|
+
Effect.gen(function* () {
|
|
55
|
+
if (flags.stdio && flags.http) {
|
|
56
|
+
return yield* usage("mcp takes either --stdio or --http, not both")
|
|
57
|
+
}
|
|
58
|
+
const parallelRuns = Option.getOrElse(flags.parallelRuns, () => 1)
|
|
59
|
+
if (!Number.isInteger(parallelRuns) || parallelRuns < 1) {
|
|
60
|
+
return yield* usage("--parallel-runs must be a positive integer")
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const transport = flags.http ? yield* httpTransport(flags, environment, cwd, readFile) : stdio
|
|
64
|
+
|
|
65
|
+
const repos = yield* reposFrom(flags, environment, cwd, readFile)
|
|
66
|
+
const seen = new Set<string>()
|
|
67
|
+
for (const repo of repos) {
|
|
68
|
+
if (seen.has(repo.id)) {
|
|
69
|
+
return yield* usage(`mcp repository id "${repo.id}" is used twice`)
|
|
70
|
+
}
|
|
71
|
+
seen.add(repo.id)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
transport,
|
|
76
|
+
repos,
|
|
77
|
+
allowRun: flags.allowRun,
|
|
78
|
+
allowApprove: flags.allowApprove,
|
|
79
|
+
parallelRuns
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
const stdio: OperatorConfig["transport"] = { _tag: "stdio" }
|
|
84
|
+
|
|
85
|
+
const httpTransport = (
|
|
86
|
+
flags: McpFlags,
|
|
87
|
+
environment: Readonly<Record<string, string | undefined>>,
|
|
88
|
+
cwd: string,
|
|
89
|
+
readFile: (path: string) => Effect.Effect<string | undefined>
|
|
90
|
+
): Effect.Effect<OperatorConfig["transport"], ShellUsageError> =>
|
|
91
|
+
Effect.gen(function* () {
|
|
92
|
+
const fromEnvironment = environment.LLM4TS_MCP_TOKEN?.trim() ?? ""
|
|
93
|
+
const fromFile =
|
|
94
|
+
fromEnvironment.length === 0 && Option.isSome(flags.tokenFile)
|
|
95
|
+
? ((yield* readFile(resolve(cwd, flags.tokenFile.value)))?.trim() ?? "")
|
|
96
|
+
: ""
|
|
97
|
+
const token = fromEnvironment.length > 0 ? fromEnvironment : fromFile
|
|
98
|
+
if (token.length === 0) {
|
|
99
|
+
return yield* usage("--http needs a token: LLM4TS_MCP_TOKEN or --token-file")
|
|
100
|
+
}
|
|
101
|
+
const port = Option.getOrElse(flags.port, () => 4321)
|
|
102
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
|
103
|
+
return yield* usage("--port must be between 0 and 65535")
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
_tag: "http" as const,
|
|
107
|
+
host: Option.getOrElse(flags.host, () => "127.0.0.1"),
|
|
108
|
+
port,
|
|
109
|
+
token
|
|
110
|
+
}
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
const reposFrom = (
|
|
114
|
+
flags: McpFlags,
|
|
115
|
+
environment: Readonly<Record<string, string | undefined>>,
|
|
116
|
+
cwd: string,
|
|
117
|
+
readFile: (path: string) => Effect.Effect<string | undefined>
|
|
118
|
+
): Effect.Effect<OperatorConfig["repos"], ShellUsageError> =>
|
|
119
|
+
Effect.gen(function* () {
|
|
120
|
+
if (flags.repo.length > 0) {
|
|
121
|
+
return flags.repo.map((path) => {
|
|
122
|
+
const absolute = resolve(cwd, path)
|
|
123
|
+
return { id: basename(absolute), path: absolute }
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
const configHome =
|
|
127
|
+
environment.XDG_CONFIG_HOME !== undefined && environment.XDG_CONFIG_HOME.length > 0
|
|
128
|
+
? environment.XDG_CONFIG_HOME
|
|
129
|
+
: join(environment.HOME ?? homedir(), ".config")
|
|
130
|
+
const configPath = join(configHome, "llm4ts", "mcp.json")
|
|
131
|
+
const raw = yield* readFile(configPath)
|
|
132
|
+
if (raw === undefined) {
|
|
133
|
+
return yield* usage("mcp needs --repo <path> or ~/.config/llm4ts/mcp.json")
|
|
134
|
+
}
|
|
135
|
+
const file = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(McpConfigFile))(raw).pipe(
|
|
136
|
+
Effect.mapError(() => usage(`${configPath} is not a valid mcp config`))
|
|
137
|
+
)
|
|
138
|
+
if (file.repos.length === 0) {
|
|
139
|
+
return yield* usage("mcp needs --repo <path> or ~/.config/llm4ts/mcp.json")
|
|
140
|
+
}
|
|
141
|
+
const badId = file.repos.find(({ id }) => !repoIdPattern.test(id))
|
|
142
|
+
if (badId !== undefined) {
|
|
143
|
+
return yield* usage(
|
|
144
|
+
`${configPath}: repository id "${badId.id}" must match ${repoIdPattern.source}`
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
return file.repos.map(({ id, path }) => ({ id, path: resolve(cwd, path) }))
|
|
148
|
+
})
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { readFileSync, readdirSync } from "node:fs"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"
|
|
4
|
+
import type * as Cause from "effect/Cause"
|
|
5
|
+
import * as Clock from "effect/Clock"
|
|
6
|
+
import * as Effect from "effect/Effect"
|
|
7
|
+
import * as Fiber from "effect/Fiber"
|
|
8
|
+
import type { ServeError } from "effect/http/HttpServerError"
|
|
9
|
+
import type { PersistenceError } from "@llm4ts/flow/FlowError"
|
|
10
|
+
import { nodeListTraces } from "@llm4ts/runner/Costs"
|
|
11
|
+
import { collectDoctorReport } from "@llm4ts/runner/Doctor"
|
|
12
|
+
import { httpProbe, rosterReport } from "@llm4ts/runner/ExecutorRoster"
|
|
13
|
+
import { interruptOnSignal, type SignalSource } from "@llm4ts/runner/FlowRunner"
|
|
14
|
+
import { nodePlainFileStore } from "@llm4ts/runner/NodePlainFileStore"
|
|
15
|
+
import { nodeTranscriptFiles } from "@llm4ts/runner/Transcripts"
|
|
16
|
+
import { nodeListEpics } from "@llm4ts/runner/Watch"
|
|
17
|
+
import { discoverFlows, type FlowTierPaths } from "./FlowCatalog.ts"
|
|
18
|
+
import { nodeFlowProcess, type FlowProcessShape } from "./FlowProcess.ts"
|
|
19
|
+
import type { OperatorConfig } from "./OperatorConfig.ts"
|
|
20
|
+
import { runOperatorServer } from "./OperatorServer.ts"
|
|
21
|
+
import type { OperatorDeps } from "./OperatorTools.ts"
|
|
22
|
+
import { makeRunSupervisor } from "./RunSupervisor.ts"
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The Node side of `llm4ts mcp` (ADR 0041): the operator's dependencies over
|
|
26
|
+
* the real file system and processes, and `runMcp`, which serves until the
|
|
27
|
+
* server fails or a SIGINT/SIGTERM interrupts it, then stops the flows it
|
|
28
|
+
* started before returning.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** Everything `runMcp` needs besides the run supervisor, which it builds itself. */
|
|
32
|
+
export interface McpRuntime<E> {
|
|
33
|
+
readonly operator: Omit<OperatorDeps, "supervisor">
|
|
34
|
+
readonly flowProcess: FlowProcessShape
|
|
35
|
+
/** The server; `runOperatorServer` on Node, a stand-in in tests. */
|
|
36
|
+
readonly serve: (config: OperatorConfig, deps: OperatorDeps) => Effect.Effect<never, E>
|
|
37
|
+
/** How long stopping a run waits after SIGINT before SIGTERM; the supervisor's default when absent. */
|
|
38
|
+
readonly graceMs?: number
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Serves the operator until the server ends or a signal arrives. The first
|
|
43
|
+
* SIGINT or SIGTERM interrupts the server; closing the scope then runs the
|
|
44
|
+
* supervisor's finalizer, which stops every flow it started. A second signal
|
|
45
|
+
* exits at once (130 or 143). The handlers are removed on the way out.
|
|
46
|
+
*/
|
|
47
|
+
export const runMcp = <E>(
|
|
48
|
+
config: OperatorConfig,
|
|
49
|
+
runtime: McpRuntime<E>,
|
|
50
|
+
signals: SignalSource
|
|
51
|
+
): Effect.Effect<void, E | PersistenceError> => {
|
|
52
|
+
const disposers: Array<() => void> = []
|
|
53
|
+
return Effect.scoped(
|
|
54
|
+
Effect.gen(function* () {
|
|
55
|
+
const supervisor = yield* makeRunSupervisor({
|
|
56
|
+
repos: config.repos,
|
|
57
|
+
environment: runtime.operator.environment,
|
|
58
|
+
files: runtime.operator.files,
|
|
59
|
+
process: runtime.flowProcess,
|
|
60
|
+
parallelRuns: config.parallelRuns,
|
|
61
|
+
...(runtime.graceMs === undefined ? {} : { graceMs: runtime.graceMs })
|
|
62
|
+
})
|
|
63
|
+
const fiber = yield* Effect.forkScoped(
|
|
64
|
+
runtime.serve(config, { ...runtime.operator, supervisor })
|
|
65
|
+
)
|
|
66
|
+
disposers.push(interruptOnSignal(fiber, signals, "SIGINT"))
|
|
67
|
+
disposers.push(interruptOnSignal(fiber, signals, "SIGTERM"))
|
|
68
|
+
return yield* Fiber.join(fiber)
|
|
69
|
+
})
|
|
70
|
+
).pipe(
|
|
71
|
+
Effect.ensuring(
|
|
72
|
+
Effect.sync(() => {
|
|
73
|
+
for (const dispose of disposers) dispose()
|
|
74
|
+
})
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A file's text, or `undefined` when it cannot be read (the `mcp` config and token file). */
|
|
80
|
+
export const nodeRead = (path: string): Effect.Effect<string | undefined> =>
|
|
81
|
+
Effect.sync(() => {
|
|
82
|
+
try {
|
|
83
|
+
return readFileSync(path, "utf8")
|
|
84
|
+
} catch {
|
|
85
|
+
return undefined
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Relative paths of every regular file under a directory, recursively; never
|
|
91
|
+
* directory names, and none when the directory is absent or unreadable.
|
|
92
|
+
*/
|
|
93
|
+
export const nodeListFiles = (directory: string): ReadonlyArray<string> => {
|
|
94
|
+
const found: Array<string> = []
|
|
95
|
+
const walk = (relative: string): void => {
|
|
96
|
+
let names: ReadonlyArray<{
|
|
97
|
+
readonly name: string
|
|
98
|
+
isDirectory(): boolean
|
|
99
|
+
isFile(): boolean
|
|
100
|
+
}>
|
|
101
|
+
try {
|
|
102
|
+
names = readdirSync(join(directory, relative), { withFileTypes: true })
|
|
103
|
+
} catch {
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
for (const entry of names) {
|
|
107
|
+
const path = relative.length === 0 ? entry.name : join(relative, entry.name)
|
|
108
|
+
if (entry.isDirectory()) {
|
|
109
|
+
walk(path)
|
|
110
|
+
} else if (entry.isFile()) {
|
|
111
|
+
found.push(path)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
walk("")
|
|
116
|
+
return found.sort()
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The operator's dependencies on Node. `tierPaths` gives the flow tiers for a
|
|
121
|
+
* repository (the CLI's `shellTierPaths`, passed in to keep this module free
|
|
122
|
+
* of the CLI).
|
|
123
|
+
*/
|
|
124
|
+
export const nodeOperatorRuntime = (
|
|
125
|
+
config: OperatorConfig,
|
|
126
|
+
tierPaths: (repoPath: string) => FlowTierPaths,
|
|
127
|
+
environment: Readonly<Record<string, string | undefined>> = process.env
|
|
128
|
+
): McpRuntime<ServeError | Cause.IllegalArgumentError> => ({
|
|
129
|
+
operator: {
|
|
130
|
+
config,
|
|
131
|
+
files: nodePlainFileStore,
|
|
132
|
+
listTraces: nodeListTraces,
|
|
133
|
+
listEpics: nodeListEpics,
|
|
134
|
+
listFiles: nodeListFiles,
|
|
135
|
+
transcripts: nodeTranscriptFiles,
|
|
136
|
+
doctor: collectDoctorReport(),
|
|
137
|
+
roster: (repoPath) =>
|
|
138
|
+
rosterReport({ files: nodePlainFileStore, environment, workDir: repoPath }),
|
|
139
|
+
probe: httpProbe,
|
|
140
|
+
flows: (repoPath) =>
|
|
141
|
+
discoverFlows(tierPaths(repoPath)).pipe(Effect.provide(NodeFileSystem.layer)),
|
|
142
|
+
now: Clock.currentTimeMillis,
|
|
143
|
+
environment
|
|
144
|
+
},
|
|
145
|
+
flowProcess: nodeFlowProcess,
|
|
146
|
+
serve: runOperatorServer
|
|
147
|
+
})
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { join } from "node:path"
|
|
2
|
+
import * as Effect from "effect/Effect"
|
|
3
|
+
import * as Option from "effect/Option"
|
|
4
|
+
import * as Schema from "effect/Schema"
|
|
5
|
+
import { EpicRun, epicRunsPath } from "@llm4ts/flow/EpicRuns"
|
|
6
|
+
import { describeFlowError } from "@llm4ts/flow/FlowError"
|
|
7
|
+
import { TraceLine } from "@llm4ts/flow/FlowRecorder"
|
|
8
|
+
import {
|
|
9
|
+
OperatorRun,
|
|
10
|
+
ReadFailed,
|
|
11
|
+
UnknownEpic,
|
|
12
|
+
UnknownRun,
|
|
13
|
+
type OperatorRunState
|
|
14
|
+
} from "@llm4ts/flow/OperatorSchemas"
|
|
15
|
+
import type { PlainFileStoreShape } from "@llm4ts/flow/Persistence"
|
|
16
|
+
import { readTrace } from "@llm4ts/flow/Replay"
|
|
17
|
+
import { TraceUnreadable } from "@llm4ts/runner/RunReaders"
|
|
18
|
+
import type { OperatorConfig } from "./OperatorConfig.ts"
|
|
19
|
+
import type { RunSupervisorShape } from "./RunSupervisor.ts"
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Where the operator finds a run: the supervisor's registry first, then each
|
|
23
|
+
* served repository's epic `runs.jsonl` files and `.llm4ts/trace-*.jsonl`
|
|
24
|
+
* traces. A client never names a path; epics and runs are ids found on disk.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** What run lookup reads; the operator's full dependency record extends it. */
|
|
28
|
+
export interface RunLookupDeps {
|
|
29
|
+
readonly config: OperatorConfig
|
|
30
|
+
readonly files: PlainFileStoreShape
|
|
31
|
+
/** Absolute trace paths under one `.llm4ts/` directory. */
|
|
32
|
+
readonly listTraces: (directory: string) => ReadonlyArray<string>
|
|
33
|
+
/** Folder names under `.llm4ts/epics/`. */
|
|
34
|
+
readonly listEpics: (directory: string) => ReadonlyArray<string>
|
|
35
|
+
readonly supervisor: RunSupervisorShape
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface Repo {
|
|
39
|
+
readonly id: string
|
|
40
|
+
readonly path: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const stateRoot = (repo: Repo): string => join(repo.path, ".llm4ts")
|
|
44
|
+
export const epicsDir = (repo: Repo): string => join(stateRoot(repo), "epics")
|
|
45
|
+
export const transcriptsDir = (repo: Repo, runId: string): string =>
|
|
46
|
+
join(stateRoot(repo), "transcripts", runId)
|
|
47
|
+
|
|
48
|
+
export const readFailed = (error: unknown): ReadFailed =>
|
|
49
|
+
ReadFailed.make({ message: describeFlowError(error) })
|
|
50
|
+
|
|
51
|
+
/** The epic's state folder, only for an id found on disk, so no client text becomes a path. */
|
|
52
|
+
export const epicDirOf = (
|
|
53
|
+
deps: RunLookupDeps,
|
|
54
|
+
repo: Repo,
|
|
55
|
+
epic: string
|
|
56
|
+
): Effect.Effect<string, UnknownEpic> =>
|
|
57
|
+
deps.listEpics(epicsDir(repo)).includes(epic)
|
|
58
|
+
? Effect.succeed(join(epicsDir(repo), epic))
|
|
59
|
+
: Effect.fail(UnknownEpic.make({ message: `unknown epic '${epic}' in ${repo.id}` }))
|
|
60
|
+
|
|
61
|
+
const decodeEpicRun = Schema.decodeUnknownOption(Schema.fromJsonString(EpicRun))
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* An epic's runs, oldest first. A missing file is no runs and a torn last line
|
|
65
|
+
* (a write in progress) is skipped, but any other line that does not decode
|
|
66
|
+
* fails `ReadFailed` naming the file.
|
|
67
|
+
*/
|
|
68
|
+
export const readEpicRunsStrict = (
|
|
69
|
+
files: PlainFileStoreShape,
|
|
70
|
+
stateDir: string
|
|
71
|
+
): Effect.Effect<ReadonlyArray<EpicRun>, ReadFailed> => {
|
|
72
|
+
const path = epicRunsPath(stateDir)
|
|
73
|
+
return files.read(path).pipe(
|
|
74
|
+
Effect.mapError(readFailed),
|
|
75
|
+
Effect.flatMap((contents) => {
|
|
76
|
+
const rows = (contents ?? "").split("\n").filter((row) => row.trim().length > 0)
|
|
77
|
+
const runs: Array<EpicRun> = []
|
|
78
|
+
for (const [index, row] of rows.entries()) {
|
|
79
|
+
const decoded = Option.getOrUndefined(decodeEpicRun(row))
|
|
80
|
+
if (decoded !== undefined) {
|
|
81
|
+
runs.push(decoded)
|
|
82
|
+
} else if (index < rows.length - 1) {
|
|
83
|
+
return Effect.fail(
|
|
84
|
+
ReadFailed.make({ message: `malformed ${path}:${index + 1}: not an epic run record` })
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return Effect.succeed(runs)
|
|
89
|
+
})
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface EpicRunEntry {
|
|
94
|
+
readonly epic: string
|
|
95
|
+
readonly dir: string
|
|
96
|
+
readonly run: EpicRun
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export const epicRunsOf = (
|
|
100
|
+
deps: RunLookupDeps,
|
|
101
|
+
repo: Repo
|
|
102
|
+
): Effect.Effect<ReadonlyArray<EpicRunEntry>, ReadFailed> =>
|
|
103
|
+
Effect.forEach(deps.listEpics(epicsDir(repo)), (epic) => {
|
|
104
|
+
const dir = join(epicsDir(repo), epic)
|
|
105
|
+
return readEpicRunsStrict(deps.files, dir).pipe(
|
|
106
|
+
Effect.map((runs) => runs.map((run) => ({ epic, dir, run })))
|
|
107
|
+
)
|
|
108
|
+
}).pipe(Effect.map((all) => all.flat()))
|
|
109
|
+
|
|
110
|
+
const decodeTraceLine = Schema.decodeUnknownOption(Schema.fromJsonString(TraceLine))
|
|
111
|
+
|
|
112
|
+
export interface TraceHead {
|
|
113
|
+
readonly runId: string
|
|
114
|
+
readonly startedAt: number
|
|
115
|
+
readonly ended: boolean
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The run a trace belongs to and whether it ended, from its first and last lines. */
|
|
119
|
+
export const traceHead = (
|
|
120
|
+
files: PlainFileStoreShape,
|
|
121
|
+
path: string
|
|
122
|
+
): Effect.Effect<TraceHead | undefined> =>
|
|
123
|
+
files.read(path).pipe(
|
|
124
|
+
Effect.catch(() => Effect.succeed(undefined)),
|
|
125
|
+
Effect.map((contents) => {
|
|
126
|
+
const rows = (contents ?? "").split(/\r?\n/u).filter((row) => row.trim().length > 0)
|
|
127
|
+
const first = Option.getOrUndefined(decodeTraceLine(rows[0] ?? ""))
|
|
128
|
+
if (first === undefined) return undefined
|
|
129
|
+
const last = Option.getOrUndefined(decodeTraceLine(rows[rows.length - 1] ?? ""))
|
|
130
|
+
return { runId: first.runId, startedAt: first.timestamp, ended: last?.kind === "RunEnded" }
|
|
131
|
+
})
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
export const traceState = (head: TraceHead | undefined): OperatorRunState =>
|
|
135
|
+
head === undefined ? "lost" : head.ended ? "exited" : "running"
|
|
136
|
+
|
|
137
|
+
export interface ResolvedRun {
|
|
138
|
+
readonly runId: string
|
|
139
|
+
readonly repo: Repo
|
|
140
|
+
readonly flow: string
|
|
141
|
+
readonly state: OperatorRunState
|
|
142
|
+
readonly tracePath: string
|
|
143
|
+
readonly supervised?: OperatorRun
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** A run id through the supervisor first, then each repository's epic runs and traces. */
|
|
147
|
+
export const resolveRun = (
|
|
148
|
+
deps: RunLookupDeps,
|
|
149
|
+
runId: string
|
|
150
|
+
): Effect.Effect<ResolvedRun, UnknownRun | ReadFailed> =>
|
|
151
|
+
Effect.gen(function* () {
|
|
152
|
+
const supervised = yield* deps.supervisor.get(runId)
|
|
153
|
+
if (supervised !== undefined) {
|
|
154
|
+
const repo = deps.config.repos.find((candidate) => candidate.id === supervised.repo)
|
|
155
|
+
if (repo === undefined) {
|
|
156
|
+
return yield* UnknownRun.make({
|
|
157
|
+
message: `run ${runId} belongs to repo '${supervised.repo}', which this server does not serve`
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
if (supervised.tracePath === undefined) {
|
|
161
|
+
return yield* UnknownRun.make({ message: `run ${runId} has no trace yet` })
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
runId,
|
|
165
|
+
repo,
|
|
166
|
+
flow: supervised.flow,
|
|
167
|
+
state: supervised.state,
|
|
168
|
+
tracePath: supervised.tracePath,
|
|
169
|
+
supervised
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
for (const repo of deps.config.repos) {
|
|
173
|
+
for (const entry of yield* epicRunsOf(deps, repo)) {
|
|
174
|
+
if (entry.run.runId === runId) {
|
|
175
|
+
const head = yield* traceHead(deps.files, entry.run.tracePath)
|
|
176
|
+
return {
|
|
177
|
+
runId,
|
|
178
|
+
repo,
|
|
179
|
+
flow: "?",
|
|
180
|
+
state: traceState(head),
|
|
181
|
+
tracePath: entry.run.tracePath
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
for (const path of deps.listTraces(stateRoot(repo))) {
|
|
186
|
+
const head = yield* traceHead(deps.files, path)
|
|
187
|
+
if (head?.runId === runId) {
|
|
188
|
+
return { runId, repo, flow: "?", state: traceState(head), tracePath: path }
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return yield* UnknownRun.make({ message: `unknown run '${runId}'` })
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
/** The epic a run worked on: the epic whose `runs.jsonl` lists it. */
|
|
196
|
+
export const epicOfRun = (
|
|
197
|
+
deps: RunLookupDeps,
|
|
198
|
+
run: ResolvedRun
|
|
199
|
+
): Effect.Effect<EpicRunEntry | undefined, ReadFailed> =>
|
|
200
|
+
Effect.map(epicRunsOf(deps, run.repo), (entries) =>
|
|
201
|
+
entries.find((entry) => entry.run.runId === run.runId || entry.run.tracePath === run.tracePath)
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
/** Runs found on disk (epic run lists and traces) that the supervisor does not hold. */
|
|
205
|
+
export const discoveredRuns = (
|
|
206
|
+
deps: RunLookupDeps,
|
|
207
|
+
repo: Repo,
|
|
208
|
+
known: ReadonlySet<string>
|
|
209
|
+
): Effect.Effect<ReadonlyArray<OperatorRun>, ReadFailed> =>
|
|
210
|
+
Effect.gen(function* () {
|
|
211
|
+
const found = new Map<string, OperatorRun>()
|
|
212
|
+
const add = (runId: string, tracePath: string, startedAt: number, state: OperatorRunState) => {
|
|
213
|
+
if (known.has(runId) || found.has(runId)) return
|
|
214
|
+
found.set(
|
|
215
|
+
runId,
|
|
216
|
+
new OperatorRun({
|
|
217
|
+
runId,
|
|
218
|
+
repo: repo.id,
|
|
219
|
+
flow: "?",
|
|
220
|
+
args: [],
|
|
221
|
+
knobs: {},
|
|
222
|
+
startedAt,
|
|
223
|
+
tracePath,
|
|
224
|
+
logDir: "",
|
|
225
|
+
state
|
|
226
|
+
})
|
|
227
|
+
)
|
|
228
|
+
}
|
|
229
|
+
for (const entry of yield* epicRunsOf(deps, repo)) {
|
|
230
|
+
const head = yield* traceHead(deps.files, entry.run.tracePath)
|
|
231
|
+
add(entry.run.runId, entry.run.tracePath, entry.run.startedAt, traceState(head))
|
|
232
|
+
}
|
|
233
|
+
for (const path of deps.listTraces(stateRoot(repo))) {
|
|
234
|
+
const head = yield* traceHead(deps.files, path)
|
|
235
|
+
if (head !== undefined) add(head.runId, path, head.startedAt, traceState(head))
|
|
236
|
+
}
|
|
237
|
+
return [...found.values()]
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* A run's trace lines. A supervised run whose trace file does not exist yet
|
|
242
|
+
* (the flow has not written its first line) reads as an empty trace; any other
|
|
243
|
+
* missing or unreadable trace fails `TraceUnreadable`.
|
|
244
|
+
*/
|
|
245
|
+
export const runTraceLines = (
|
|
246
|
+
files: PlainFileStoreShape,
|
|
247
|
+
run: ResolvedRun
|
|
248
|
+
): Effect.Effect<ReadonlyArray<TraceLine>, TraceUnreadable> =>
|
|
249
|
+
Effect.gen(function* () {
|
|
250
|
+
if (yield* tracePending(files, run)) return []
|
|
251
|
+
return yield* readTrace(files, run.tracePath).pipe(
|
|
252
|
+
Effect.mapError((error) =>
|
|
253
|
+
TraceUnreadable.make({ path: run.tracePath, message: describeFlowError(error) })
|
|
254
|
+
)
|
|
255
|
+
)
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
/** Whether the run is supervised and its flow has not written a trace yet. */
|
|
259
|
+
export const tracePending = (
|
|
260
|
+
files: PlainFileStoreShape,
|
|
261
|
+
run: ResolvedRun
|
|
262
|
+
): Effect.Effect<boolean, TraceUnreadable> =>
|
|
263
|
+
run.supervised === undefined
|
|
264
|
+
? Effect.succeed(false)
|
|
265
|
+
: files.read(run.tracePath).pipe(
|
|
266
|
+
Effect.map((contents) => contents === undefined),
|
|
267
|
+
Effect.mapError((error) =>
|
|
268
|
+
TraceUnreadable.make({ path: run.tracePath, message: describeFlowError(error) })
|
|
269
|
+
)
|
|
270
|
+
)
|