@tonoid/agent-loop 1.0.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.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +378 -0
  3. package/briefs/default/build.md +35 -0
  4. package/briefs/default/core.md +52 -0
  5. package/briefs/default/journal.optional.md +7 -0
  6. package/briefs/default/review.md +65 -0
  7. package/briefs/default/routine.md +18 -0
  8. package/briefs/default/screenshots.optional.md +10 -0
  9. package/briefs/default/subagents.optional.md +7 -0
  10. package/docs/cutover.md +121 -0
  11. package/package.json +46 -0
  12. package/src/adapters/gh.ts +77 -0
  13. package/src/adapters/git.ts +82 -0
  14. package/src/adapters/herdr.ts +121 -0
  15. package/src/adapters/run.ts +64 -0
  16. package/src/adopt.ts +47 -0
  17. package/src/brief.ts +124 -0
  18. package/src/check.ts +126 -0
  19. package/src/cli-pause.ts +16 -0
  20. package/src/cli.ts +290 -0
  21. package/src/config.ts +211 -0
  22. package/src/ctx.ts +64 -0
  23. package/src/discover.ts +344 -0
  24. package/src/effects/monitor.ts +148 -0
  25. package/src/effects/spawn.ts +246 -0
  26. package/src/effects/sweep.ts +27 -0
  27. package/src/engine/item.ts +32 -0
  28. package/src/engine/monitor.ts +117 -0
  29. package/src/engine/naming.ts +45 -0
  30. package/src/engine/spawn.ts +101 -0
  31. package/src/engine/sweep.ts +91 -0
  32. package/src/engine/tick.ts +25 -0
  33. package/src/filing.ts +56 -0
  34. package/src/globalstate.ts +228 -0
  35. package/src/journal.ts +13 -0
  36. package/src/kinds/builder.ts +128 -0
  37. package/src/kinds/index.ts +13 -0
  38. package/src/kinds/reviewer.ts +220 -0
  39. package/src/kinds/routine.ts +174 -0
  40. package/src/kinds/shared.ts +49 -0
  41. package/src/kinds/validate.ts +187 -0
  42. package/src/lock.ts +63 -0
  43. package/src/paths.ts +21 -0
  44. package/src/render.ts +42 -0
  45. package/src/router/budget.ts +81 -0
  46. package/src/router/providers/claude.ts +177 -0
  47. package/src/router/providers/codex.ts +120 -0
  48. package/src/router/providers/grok.ts +10 -0
  49. package/src/router/rate.ts +62 -0
  50. package/src/router/route.ts +179 -0
  51. package/src/router/window.ts +39 -0
  52. package/src/runtime/worker.ts +75 -0
  53. package/src/state.ts +128 -0
  54. package/src/status.ts +41 -0
  55. package/src/types.ts +225 -0
package/src/check.ts ADDED
@@ -0,0 +1,126 @@
1
+ import { existsSync } from "node:fs"
2
+ import { dirname } from "node:path"
3
+ import { parseConfig } from "./config"
4
+ import { discover, loadWorkspace } from "./discover"
5
+ import { TESTED_PROTOCOL } from "./adapters/herdr"
6
+ import type { Kind } from "./kinds"
7
+ import type { AccountConfig } from "./types"
8
+
9
+ // Injected so the suite can check every branch without a real gh or herdr.
10
+ export interface CheckDeps {
11
+ which(bin: string): string | null
12
+ ghAuth(): Promise<boolean>
13
+ protocol(): Promise<number>
14
+ herdrWorkspaces(): Promise<string[]>
15
+ readConfig(path: string): Promise<string | null>
16
+ }
17
+
18
+ // A worker started in a path its agent has never seen opens on a trust prompt,
19
+ // reports itself idle behind it, and swallows the brief it is then sent, which
20
+ // looks from the log like an agent that simply did not start. Claude records
21
+ // trust per project directory and inherits it downwards, so one trusted
22
+ // ancestor of worktreeBase covers every worktree ever made under it, and a
23
+ // missing one costs every spawn on that account until somebody opens a session
24
+ // by hand. A warning rather than a failure: the file belongs to another tool
25
+ // and may yet record this differently.
26
+ async function trusts(deps: CheckDeps, account: AccountConfig, base: string): Promise<boolean> {
27
+ if (account.provider !== "claude") return true
28
+ const text = await deps.readConfig(`${account.configDir}/.claude.json`)
29
+ if (text === null) return false
30
+ let projects: Record<string, { hasTrustDialogAccepted?: boolean }>
31
+ try {
32
+ projects = JSON.parse(text).projects ?? {}
33
+ } catch {
34
+ return true
35
+ }
36
+ for (let dir = base; ; dir = dirname(dir)) {
37
+ if (projects[dir]?.hasTrustDialogAccepted) return true
38
+ if (dirname(dir) === dir) return false
39
+ }
40
+ }
41
+
42
+ export async function runCheck(o: {
43
+ configPath: string
44
+ workspaceDir?: string
45
+ kinds: Record<string, Kind>
46
+ deps: CheckDeps
47
+ }): Promise<{ lines: string[]; ok: boolean }> {
48
+ const lines: string[] = []
49
+ let ok = true
50
+ const fail = (line: string) => { ok = false; lines.push(line) }
51
+
52
+ // Cron's environment is nearly empty, and a setup that works in a login
53
+ // shell failing at 2am on PATH is the classic way this breaks.
54
+ for (const bin of ["gh", "git", "herdr"]) {
55
+ if (o.deps.which(bin)) lines.push(`${bin}: ok`)
56
+ else fail(`${bin}: not on PATH`)
57
+ }
58
+ if (await o.deps.ghAuth()) lines.push("gh: authenticated")
59
+ else fail("gh: not authenticated")
60
+
61
+ const protocol = await o.deps.protocol().catch(() => -1)
62
+ if (protocol !== TESTED_PROTOCOL) {
63
+ lines.push(`WARN herdr protocol ${protocol}, tested ${TESTED_PROTOCOL}`)
64
+ } else {
65
+ lines.push(`herdr protocol ${protocol}: ok`)
66
+ }
67
+
68
+ if (o.workspaceDir) {
69
+ // The one-folder form runs in a service repository's own CI, where there
70
+ // is no machine config and no accounts to check selectors against.
71
+ const { ws, errors } = loadWorkspace(o.workspaceDir, {
72
+ kinds: o.kinds,
73
+ accounts: [],
74
+ checkSelectors: false,
75
+ })
76
+ for (const e of errors) fail(e)
77
+ if (ws) lines.push(`${ws.name}: ${ws.jobs.length} job${ws.jobs.length === 1 ? "" : "s"} (${ws.jobs.map((j) => j.name).join(", ")})`)
78
+ lines.push("skipped: account selectors (no config.yml in this form)")
79
+ return { lines, ok }
80
+ }
81
+
82
+ const text = await o.deps.readConfig(o.configPath)
83
+ if (text === null) {
84
+ fail(`no config at ${o.configPath}`)
85
+ return { lines, ok }
86
+ }
87
+ const { config, errors: configErrors } = parseConfig(text)
88
+ for (const e of configErrors) fail(e)
89
+ if (configErrors.length) return { lines, ok }
90
+
91
+ // Every spawn resolves the herdr workspace by label, so a label that names
92
+ // nothing throws once per due item forever and never once at configure time.
93
+ // Unreadable is not the same as absent: a herdr that is not running fails
94
+ // every job below anyway, and guessing here would report the wrong one.
95
+ const labels = await o.deps.herdrWorkspaces().then((l) => l, () => null)
96
+ if (labels === null) lines.push("WARN could not ask herdr for its workspaces")
97
+
98
+ lines.push(`${config.accounts.length} account${config.accounts.length === 1 ? "" : "s"}: ${config.accounts.map((a) => a.id).join(", ")}`)
99
+ const { workspaces, errors } = discover(config, {
100
+ kinds: o.kinds,
101
+ accounts: config.accounts,
102
+ checkSelectors: true,
103
+ })
104
+ for (const e of errors) fail(e)
105
+ for (const ws of workspaces) {
106
+ lines.push(`${ws.name}: ${ws.jobs.length} job${ws.jobs.length === 1 ? "" : "s"} (${ws.jobs.map((j) => j.name).join(", ")})`)
107
+ // Spec 3.5: check resolves every path. A repos: entry pointing at a folder
108
+ // that was never cloned otherwise passes with exit 0 and then produces an
109
+ // ERROR ... sweep line every two minutes. Only in this form: the one-folder
110
+ // form runs in a service repository's CI, where the sibling trees may
111
+ // legitimately not be checked out.
112
+ for (const [key, path] of Object.entries(ws.repos)) {
113
+ if (!existsSync(path)) fail(`${ws.name}: repo "${key}" at ${path} does not exist`)
114
+ else if (!existsSync(`${path}/.git`)) fail(`${ws.name}: repo "${key}" at ${path} is not a git repository`)
115
+ }
116
+ if (labels !== null && !labels.includes(ws.herdrWorkspace)) {
117
+ fail(`${ws.name}: no herdr workspace labelled "${ws.herdrWorkspace}"${labels.length ? `; herdr has: ${labels.join(", ")}` : "; herdr has none"}`)
118
+ }
119
+ for (const account of config.accounts) {
120
+ if (!(await trusts(o.deps, account, ws.worktreeBase))) {
121
+ lines.push(`WARN ${ws.name}: account ${account.id} has not trusted ${ws.worktreeBase}; a worker there waits on the trust prompt and never reads its brief`)
122
+ }
123
+ }
124
+ }
125
+ return { lines, ok }
126
+ }
@@ -0,0 +1,16 @@
1
+ import { existsSync } from "node:fs"
2
+
3
+ // Pause blocks spawn only. Sweep and monitor always run: the monitor is what
4
+ // removes a claim label from a merged PR, so a pause that froze it would
5
+ // manufacture lying claims.
6
+ export function pausedJobs(stateDir: string, jobs: string[]): string[] {
7
+ // Only `pause`, which is what pauseMarker writes for the workspace-wide
8
+ // form. A `pause-all` alias would make a job named "all", a legal job name,
9
+ // pause the whole workspace.
10
+ if (existsSync(`${stateDir}/pause`)) return [...jobs]
11
+ return jobs.filter((name) => existsSync(`${stateDir}/pause-${name}`))
12
+ }
13
+
14
+ export function pauseMarker(stateDir: string, job?: string): string {
15
+ return job ? `${stateDir}/pause-${job}` : `${stateDir}/pause`
16
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,290 @@
1
+ #!/usr/bin/env bun
2
+ import { loadConfig } from "./config"
3
+ import { discover } from "./discover"
4
+ import { KINDS, describeKind, kindSchema } from "./kinds"
5
+ import { runCheck } from "./check"
6
+ import { TESTED_PROTOCOL } from "./adapters/herdr"
7
+ import { agentLoopHome } from "./paths"
8
+ import { pausedJobs, pauseMarker } from "./cli-pause"
9
+ import { openState } from "./state"
10
+ import { makeCtx } from "./ctx"
11
+ import { fileLock } from "./lock"
12
+ import { makeGh } from "./adapters/gh"
13
+ import { makeGit } from "./adapters/git"
14
+ import { makeHerdr } from "./adapters/herdr"
15
+ import { makeRunners } from "./adapters/run"
16
+ import { runTick } from "./engine/tick"
17
+ import { renderDecision } from "./render"
18
+ import { existsSync, mkdirSync, writeFileSync, unlinkSync } from "node:fs"
19
+ import { openGlobalState } from "./globalstate"
20
+ import { makeClaudeReader, liveClaudeDeps } from "./router/providers/claude"
21
+ import { makeCodexReader, liveCodexDeps } from "./router/providers/codex"
22
+ import { grokReader } from "./router/providers/grok"
23
+ import type { Provider, UsageReader, WorkspaceConfig } from "./types"
24
+ import { dirname } from "node:path"
25
+ import { renderStatus } from "./status"
26
+ import { adopt, renderMarks } from "./adopt"
27
+
28
+ function arg(name: string): string | undefined {
29
+ const i = process.argv.indexOf(`--${name}`)
30
+ return i === -1 ? undefined : process.argv[i + 1]
31
+ }
32
+
33
+ // The one positional argument (a job name), which can appear anywhere
34
+ // after the command, not just right after it: skip every flag and, for the
35
+ // flags that take one, its value too.
36
+ const FLAGS_WITH_VALUE = ["--workspace", "--config", "--state-dir", "--global-state"]
37
+ function positionals(): string[] {
38
+ const out: string[] = []
39
+ const rest = process.argv.slice(3)
40
+ for (let i = 0; i < rest.length; i++) {
41
+ const a = rest[i]!
42
+ if (FLAGS_WITH_VALUE.includes(a)) { i++; continue }
43
+ if (a.startsWith("--")) continue
44
+ out.push(a)
45
+ }
46
+ return out
47
+ }
48
+ const jobArg = (): string | undefined => positionals()[0]
49
+
50
+ const cmd = process.argv[2]
51
+ const live = process.argv.includes("--live")
52
+
53
+ if (!["tick", "check", "kinds", "pause", "resume", "status", "adopt"].includes(cmd ?? "")) {
54
+ console.error(
55
+ "usage: agent-loop <tick|check|kinds|status|pause|resume|adopt> [--workspace <name>]\n" +
56
+ " [--config <path>] [--state-dir <path>] [--global-state <path>]\n" +
57
+ " [--live] [<job>] [<key>]\n" +
58
+ " agent-loop adopt <job> [<key>] --workspace <name> record a spawned mark\n" +
59
+ " agent-loop adopt --list --workspace <name> print this workspace's marks",
60
+ )
61
+ process.exit(2)
62
+ }
63
+
64
+ const stamp = () => new Date().toISOString().replace(/\.\d+Z$/, "Z")
65
+ // The whole process, config load and discovery included. One cron line covers
66
+ // every workspace on the box, so the interval has to cover the sum: 4.2's
67
+ // cadence rule read against one workspace's share under-sizes it in
68
+ // proportion to how many workspaces are on the box.
69
+ const processStarted = Date.now()
70
+
71
+ if (cmd === "kinds") {
72
+ const names = Object.keys(KINDS)
73
+ const want = jobArg()
74
+ const chosen = want ? [KINDS[want]].filter(Boolean) : names.map((n) => KINDS[n]!)
75
+ if (want && !chosen.length) {
76
+ console.error(`unknown kind "${want}"; known: ${names.join(", ")}`)
77
+ process.exit(2)
78
+ }
79
+ if (process.argv.includes("--json")) {
80
+ console.log(JSON.stringify(Object.fromEntries(chosen.map((k) => [k!.name, kindSchema(k!)])), null, 2))
81
+ } else {
82
+ for (const k of chosen) for (const line of describeKind(k!)) console.log(line)
83
+ }
84
+ process.exit(0)
85
+ }
86
+
87
+ if (cmd === "check") {
88
+ const positional = jobArg()
89
+ const { lines, ok } = await runCheck({
90
+ configPath: arg("config") ?? `${agentLoopHome()}/config.yml`,
91
+ workspaceDir: positional,
92
+ kinds: KINDS,
93
+ deps: {
94
+ which: (b) => Bun.which(b),
95
+ ghAuth: async () => makeRunners(false).runText(["gh", "auth", "status"]).then(() => true, () => false),
96
+ protocol: () => makeHerdr(makeRunners(false).runJson).protocol(),
97
+ herdrWorkspaces: async () =>
98
+ (await makeHerdr(makeRunners(false).runJson).workspaces()).map((w) => w.label),
99
+ readConfig: async (p) => (await Bun.file(p).exists()) ? Bun.file(p).text() : null,
100
+ },
101
+ })
102
+ for (const line of lines) console.log(line)
103
+ process.exit(ok ? 0 : 1)
104
+ }
105
+
106
+ // Below the read-only commands on purpose. `kinds` and `check` perform no
107
+ // writes (spec 3.5), and the README sells `agent-loop check .` as a service
108
+ // repository's CI check, where a writable home is not a given: opening the
109
+ // store above would create ~/.agent-loop/state.db for a command that only
110
+ // reads, and die with a raw EACCES stack trace before validating anything.
111
+ const globalPath = arg("global-state") ?? `${agentLoopHome()}/state.db`
112
+ mkdirSync(dirname(globalPath), { recursive: true })
113
+ const global = openGlobalState(globalPath)
114
+
115
+ const configPath = arg("config") ?? `${agentLoopHome()}/config.yml`
116
+ const config = await loadConfig(configPath)
117
+
118
+ const { workspaces: found, errors } = discover(config, {
119
+ kinds: KINDS,
120
+ accounts: config.accounts,
121
+ checkSelectors: true,
122
+ })
123
+ // Reported every tick, never once: a workspace that stops ticking silently is
124
+ // the failure nobody notices.
125
+ for (const reason of errors) console.log(`${stamp()} ${renderDecision({ pass: "error", where: "workspace", workspace: "-", reason })}`)
126
+
127
+ const wsName = arg("workspace")
128
+ if (wsName && !found.some((w) => w.name === wsName)) {
129
+ console.error(`unknown workspace "${wsName}"`)
130
+ process.exit(2)
131
+ }
132
+ const selected = wsName ? found.filter((w) => w.name === wsName) : found
133
+
134
+ if (arg("state-dir") && selected.length !== 1) {
135
+ console.error("--state-dir applies to one workspace; pass --workspace too")
136
+ process.exit(2)
137
+ }
138
+
139
+ const stateDirFor = (name: string) => arg("state-dir") ?? `${agentLoopHome()}/${name}`
140
+
141
+ if (cmd === "pause" || cmd === "resume") {
142
+ if (selected.length !== 1) {
143
+ console.error("pause and resume apply to one workspace; pass --workspace")
144
+ process.exit(2)
145
+ }
146
+ const ws = selected[0]!
147
+ const job = jobArg()
148
+ // Pause is the cutover and maintenance control: a typo'd job name must
149
+ // not report success while leaving the loop free to keep spawning it.
150
+ if (job && !ws.jobs.some((j) => j.name === job)) {
151
+ console.error(`unknown job "${job}" in workspace "${ws.name}"`)
152
+ process.exit(2)
153
+ }
154
+ const dir = stateDirFor(ws.name)
155
+ mkdirSync(dir, { recursive: true })
156
+ const marker = pauseMarker(dir, job)
157
+ if (cmd === "pause") writeFileSync(marker, "")
158
+ else if (existsSync(marker)) unlinkSync(marker)
159
+ console.log(`${cmd}d ${job ?? "the whole workspace"}`)
160
+ process.exit(0)
161
+ }
162
+
163
+ // status is inspection-only regardless of --live: only a tick's own spawn may
164
+ // cause a token write-back.
165
+ const readers: Record<Provider, UsageReader> = {
166
+ claude: makeClaudeReader(liveClaudeDeps(cmd === "tick" && live)),
167
+ codex: makeCodexReader(liveCodexDeps()),
168
+ grok: grokReader,
169
+ }
170
+
171
+ const { runText: rt, runJson: rj } = makeRunners(live)
172
+
173
+ // Shared by the tick loop and by adopt, which needs a context only to ask a
174
+ // routine which occurrence is due right now.
175
+ const ctxFor = (ws: WorkspaceConfig, marks: ReturnType<typeof openState>) =>
176
+ makeCtx({
177
+ workspace: ws,
178
+ // The full discovered list, not `selected`: concurrency is an
179
+ // account-scoped fact across the whole box (spec 6.3 and 7), so a
180
+ // `--workspace` run ticking one workspace must still see live workers
181
+ // under every other one, or it over-admits against the account's ceiling.
182
+ workspaces: found,
183
+ config,
184
+ now: new Date(),
185
+ live,
186
+ sleep: (ms) => Bun.sleep(ms),
187
+ lock: fileLock(),
188
+ gh: makeGh(rj, rt),
189
+ gitFor: (repo) => makeGit(rt, repo),
190
+ herdr: makeHerdr(rj),
191
+ marks,
192
+ global,
193
+ usageFor: (a, at) => readers[a.provider](a, at),
194
+ memAvailableMb,
195
+ sink: (d) => console.log(`${stamp()} ${renderDecision(d, live)}`),
196
+ })
197
+
198
+ if (cmd === "status") {
199
+ // One instant for the whole render: "resets in Xm" must be relative to the
200
+ // same now as the usage read that produced it.
201
+ const now = new Date()
202
+ const lines = await renderStatus({
203
+ now,
204
+ accounts: config.accounts,
205
+ usageFor: (a) => readers[a.provider](a, now),
206
+ refreshExpiryFor: async (a) => {
207
+ if (a.provider !== "claude") return null
208
+ const creds = await liveClaudeDeps(false).readCreds(a.configDir)
209
+ return creds?.refreshTokenExpiresAt ? new Date(creds.refreshTokenExpiresAt) : null
210
+ },
211
+ spawnsToday: global.spawnsSince(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())),
212
+ workspaces: selected.map((ws) => ({
213
+ name: ws.name,
214
+ paused: pausedJobs(stateDirFor(ws.name), ws.jobs.map((j) => j.name)),
215
+ })),
216
+ })
217
+ for (const line of lines) console.log(line)
218
+ global.close()
219
+ process.exit(0)
220
+ }
221
+
222
+ // Below status and above the tick: adopt is the cutover's import path
223
+ // (docs/cutover.md phase 2), so it writes the marks database on purpose and without
224
+ // --live. It touches nothing else, and no process at all.
225
+ if (cmd === "adopt") {
226
+ if (selected.length !== 1) {
227
+ console.error("adopt applies to one workspace; pass --workspace")
228
+ process.exit(2)
229
+ }
230
+ const ws = selected[0]!
231
+ const dir = stateDirFor(ws.name)
232
+ mkdirSync(dir, { recursive: true })
233
+ const marks = openState(`${dir}/state.db`)
234
+ try {
235
+ if (process.argv.includes("--list")) {
236
+ for (const line of renderMarks(marks.all(), new Date())) console.log(line)
237
+ } else {
238
+ const [name, wanted] = positionals()
239
+ const job = ws.jobs.find((j) => j.name === name)
240
+ if (!job) throw new Error(`unknown job "${name ?? ""}" in workspace "${ws.name}"`)
241
+ const { key, already } = await adopt(ctxFor(ws, marks), marks, job, wanted)
242
+ console.log(already ? `${job.name} ${key} was already recorded` : `adopted ${job.name} ${key}`)
243
+ }
244
+ } catch (e) {
245
+ console.error(String(e).replace(/^Error: /, ""))
246
+ marks.close()
247
+ global.close()
248
+ process.exit(2)
249
+ }
250
+ marks.close()
251
+ global.close()
252
+ process.exit(0)
253
+ }
254
+
255
+ // MemAvailable is the kernel's own estimate of what a new process can get
256
+ // without swapping, which is the question being asked. Treat an unreadable
257
+ // /proc as unlimited rather than blocking the loop on a parse failure.
258
+ async function memAvailableMb(): Promise<number> {
259
+ try {
260
+ const m = (await Bun.file("/proc/meminfo").text()).match(/^MemAvailable:\s+(\d+) kB/m)
261
+ return m ? Math.round(Number(m[1]) / 1024) : Number.POSITIVE_INFINITY
262
+ } catch {
263
+ return Number.POSITIVE_INFINITY
264
+ }
265
+ }
266
+
267
+ const protocol = await makeHerdr(rj).protocol().catch(() => -1)
268
+ if (protocol !== TESTED_PROTOCOL) {
269
+ console.log(`${stamp()} WARN herdr protocol ${protocol}, tested ${TESTED_PROTOCOL}`)
270
+ }
271
+
272
+ for (const ws of selected) {
273
+ const stateDir = stateDirFor(ws.name)
274
+ mkdirSync(stateDir, { recursive: true })
275
+ const marks = openState(`${stateDir}/state.db`)
276
+ try {
277
+ const ctx = ctxFor(ws, marks)
278
+ const names = ws.jobs.map((j) => j.name)
279
+ const paused = pausedJobs(stateDir, names)
280
+ await runTick(ctx, ws.jobs, { paused: paused.length === names.length, pausedJobs: paused })
281
+ } catch (e) {
282
+ // One service mid-edit or one unreachable remote must not stop the rest of
283
+ // the box for the next two minutes.
284
+ console.log(`${stamp()} ${renderDecision({ pass: "error", where: "workspace", workspace: ws.name, reason: String(e) })}`)
285
+ } finally {
286
+ marks.close()
287
+ }
288
+ }
289
+ console.log(`${stamp()} ${renderDecision({ pass: "tick", workspace: "total", ms: Date.now() - processStarted })}`)
290
+ global.close()
package/src/config.ts ADDED
@@ -0,0 +1,211 @@
1
+ import type { AccountConfig, Config, Provider } from "./types"
2
+ import { expandHome } from "./paths"
3
+ import { unknownKey } from "./kinds"
4
+ import { isAbsolute } from "node:path"
5
+
6
+ const KNOWN_PROVIDERS: Provider[] = ["claude", "codex", "grok"]
7
+
8
+ // An unknown key is a typo, and a typo here is silent: `reserved: 40` leaves
9
+ // the reserve at 0, so the one mechanism keeping the loop out of a human's
10
+ // quota disappears with nothing said, and `maxSpawnsPerday: 5` leaves the
11
+ // runaway circuit breaker at 200. Rejecting it is what makes `check` catch
12
+ // this at the commit that broke it (spec 3.5).
13
+ const CONFIG_KEYS = [
14
+ "accounts", "workspaces", "maxConcurrentPerAccount", "minFreeMb", "usageMax",
15
+ "releaseBefore", "maxSpawnsPerDay", "blockedTimeoutMin", "workerRateSeed",
16
+ ]
17
+ const ACCOUNT_KEYS = [
18
+ "id", "provider", "configDir", "reserve", "reservePerWeekday", "weekendWeight", "soleConsumer", "maxConcurrent", "allowWhenUnreadable",
19
+ "agentKind", "startArgs", "model", "oauthClientId", "configEnv",
20
+ ]
21
+
22
+ // Clamps have defaults so a working config.yml is accounts plus workspaces.
23
+ // Every value here is the one the spec's example carries.
24
+ export const DEFAULTS = {
25
+ maxConcurrentPerAccount: 4,
26
+ minFreeMb: 3000,
27
+ usageMax: 90,
28
+ releaseBefore: 120,
29
+ maxSpawnsPerDay: 200,
30
+ blockedTimeoutMin: 180,
31
+ workerRateSeed: 0.35,
32
+ } as const
33
+
34
+ // A selector names an account directly or names every account of a provider.
35
+ export function selects(a: AccountConfig, selector: string): boolean {
36
+ return selector === a.id || selector === a.provider
37
+ }
38
+
39
+ function isRecord(v: unknown): v is Record<string, unknown> {
40
+ return typeof v === "object" && v !== null && !Array.isArray(v)
41
+ }
42
+
43
+ function num(v: unknown, name: string, dflt: number, errs: string[]): number {
44
+ if (v === undefined || v === null) return dflt
45
+ if (typeof v !== "number" || !Number.isFinite(v)) {
46
+ errs.push(`${name} must be a number`)
47
+ return dflt
48
+ }
49
+ return v
50
+ }
51
+
52
+ function account(raw: unknown, i: number, errs: string[]): AccountConfig | null {
53
+ if (!isRecord(raw)) {
54
+ errs.push(`accounts[${i}] must be a mapping`)
55
+ return null
56
+ }
57
+ const id = typeof raw.id === "string" ? raw.id : ""
58
+ if (!id) {
59
+ errs.push(`accounts[${i}]: id is required`)
60
+ return null
61
+ }
62
+ for (const key of Object.keys(raw)) {
63
+ if (!ACCOUNT_KEYS.includes(key)) errs.push(`account "${id}": ${unknownKey(key, ACCOUNT_KEYS)}`)
64
+ }
65
+
66
+ const provider = raw.provider as Provider
67
+ if (!KNOWN_PROVIDERS.includes(provider)) {
68
+ errs.push(
69
+ `account "${id}": unknown provider "${String(raw.provider)}"; known: ${KNOWN_PROVIDERS.join(", ")}`,
70
+ )
71
+ }
72
+ if (typeof raw.configDir !== "string" || !raw.configDir) {
73
+ errs.push(`account "${id}": configDir is required`)
74
+ }
75
+ const reserve = num(raw.reserve, `account "${id}": reserve`, 0, errs)
76
+ if (reserve < 0 || reserve > 100) {
77
+ errs.push(`account "${id}": reserve must be between 0 and 100`)
78
+ }
79
+ const reservePerWeekday = num(raw.reservePerWeekday, `account "${id}": reservePerWeekday`, 0, errs)
80
+ if (reservePerWeekday < 0 || reservePerWeekday > 100) {
81
+ errs.push(`account "${id}": reservePerWeekday must be between 0 and 100`)
82
+ }
83
+ const weekendWeight = num(raw.weekendWeight, `account "${id}": weekendWeight`, 0.25, errs)
84
+ if (weekendWeight < 0 || weekendWeight > 1) {
85
+ errs.push(`account "${id}": weekendWeight must be between 0 and 1`)
86
+ }
87
+
88
+ const str = (v: unknown, field: string): string | undefined => {
89
+ if (v === undefined || v === null) return undefined
90
+ if (typeof v !== "string") { errs.push(`account "${id}": ${field} must be a string`); return undefined }
91
+ return v
92
+ }
93
+
94
+ let allowWhenUnreadable: boolean | undefined
95
+ if (raw.allowWhenUnreadable !== undefined && raw.allowWhenUnreadable !== null) {
96
+ if (typeof raw.allowWhenUnreadable !== "boolean") {
97
+ errs.push(`account "${id}": allowWhenUnreadable must be true or false`)
98
+ } else {
99
+ allowWhenUnreadable = raw.allowWhenUnreadable
100
+ }
101
+ }
102
+
103
+ let startArgs: string[] | undefined
104
+ if (raw.startArgs !== undefined && raw.startArgs !== null) {
105
+ if (!Array.isArray(raw.startArgs) || !raw.startArgs.every((s) => typeof s === "string")) {
106
+ errs.push(`account "${id}": startArgs must be a list of strings`)
107
+ } else {
108
+ startArgs = raw.startArgs
109
+ }
110
+ }
111
+
112
+ // maxConcurrent stays undefined rather than defaulted: the code downstream
113
+ // distinguishes "unset" from an explicit value, including an invalid one.
114
+ const maxConcurrentNum = num(raw.maxConcurrent, `account "${id}": maxConcurrent`, Number.NaN, errs)
115
+ const maxConcurrent = Number.isNaN(maxConcurrentNum) ? undefined : maxConcurrentNum
116
+
117
+ return {
118
+ id,
119
+ provider,
120
+ configDir: expandHome(typeof raw.configDir === "string" ? raw.configDir : ""),
121
+ reserve,
122
+ reservePerWeekday,
123
+ weekendWeight,
124
+ soleConsumer: raw.soleConsumer === true ? true : undefined,
125
+ maxConcurrent,
126
+ allowWhenUnreadable,
127
+ agentKind: str(raw.agentKind, "agentKind"),
128
+ startArgs,
129
+ model: str(raw.model, "model"),
130
+ oauthClientId: str(raw.oauthClientId, "oauthClientId"),
131
+ configEnv: str(raw.configEnv, "configEnv"),
132
+ }
133
+ }
134
+
135
+ // Returns every error rather than throwing at the first, because `check` prints
136
+ // them all and an operator fixing one at a time is the slowest possible loop.
137
+ export function parseConfig(text: string): { config: Config; errors: string[] } {
138
+ const errs: string[] = []
139
+ let raw: unknown
140
+ try {
141
+ raw = Bun.YAML.parse(text)
142
+ } catch (e) {
143
+ return { config: empty(), errors: [`config is not valid YAML: ${String(e)}`] }
144
+ }
145
+ if (!isRecord(raw)) return { config: empty(), errors: ["config must be a mapping of settings"] }
146
+
147
+ for (const key of Object.keys(raw)) {
148
+ if (!CONFIG_KEYS.includes(key)) errs.push(unknownKey(key, CONFIG_KEYS))
149
+ }
150
+
151
+ const accounts: AccountConfig[] = []
152
+ if (!Array.isArray(raw.accounts) || raw.accounts.length === 0) {
153
+ errs.push("accounts must be a non-empty list")
154
+ } else {
155
+ const seen = new Set<string>()
156
+ raw.accounts.forEach((a, i) => {
157
+ const parsed = account(a, i, errs)
158
+ if (!parsed) return
159
+ if (seen.has(parsed.id)) errs.push(`duplicate account id "${parsed.id}"`)
160
+ seen.add(parsed.id)
161
+ accounts.push(parsed)
162
+ })
163
+ }
164
+
165
+ const workspaces: string[] = []
166
+ if (!Array.isArray(raw.workspaces) || raw.workspaces.length === 0) {
167
+ errs.push("workspaces must be a non-empty list of paths")
168
+ } else {
169
+ raw.workspaces.forEach((p, i) => {
170
+ if (typeof p !== "string") errs.push(`workspaces[${i}] must be a path`)
171
+ else {
172
+ const expanded = expandHome(p)
173
+ // The path is used as given, from whatever directory cron started the
174
+ // tick in, so a relative one is silently a different folder depending
175
+ // on how the binary was invoked.
176
+ if (!isAbsolute(expanded)) errs.push(`workspaces[${i}] "${p}" must be an absolute path or start with ~`)
177
+ else workspaces.push(expanded)
178
+ }
179
+ })
180
+ }
181
+
182
+ const workerRateSeed = num(raw.workerRateSeed, "workerRateSeed", DEFAULTS.workerRateSeed, errs)
183
+ if (!(workerRateSeed > 0)) errs.push("workerRateSeed must be greater than 0")
184
+
185
+ return {
186
+ config: {
187
+ accounts,
188
+ workspaces,
189
+ maxConcurrentPerAccount: num(raw.maxConcurrentPerAccount, "maxConcurrentPerAccount", DEFAULTS.maxConcurrentPerAccount, errs),
190
+ minFreeMb: num(raw.minFreeMb, "minFreeMb", DEFAULTS.minFreeMb, errs),
191
+ usageMax: num(raw.usageMax, "usageMax", DEFAULTS.usageMax, errs),
192
+ releaseBefore: num(raw.releaseBefore, "releaseBefore", DEFAULTS.releaseBefore, errs),
193
+ maxSpawnsPerDay: num(raw.maxSpawnsPerDay, "maxSpawnsPerDay", DEFAULTS.maxSpawnsPerDay, errs),
194
+ blockedTimeoutMin: num(raw.blockedTimeoutMin, "blockedTimeoutMin", DEFAULTS.blockedTimeoutMin, errs),
195
+ workerRateSeed,
196
+ },
197
+ errors: errs,
198
+ }
199
+ }
200
+
201
+ function empty(): Config {
202
+ return { accounts: [], workspaces: [], ...DEFAULTS }
203
+ }
204
+
205
+ export async function loadConfig(path: string): Promise<Config> {
206
+ const file = Bun.file(path)
207
+ if (!(await file.exists())) throw new Error(`no config at ${path}`)
208
+ const { config, errors } = parseConfig(await file.text())
209
+ if (errors.length) throw new Error(`invalid config at ${path}:\n ${errors.join("\n ")}`)
210
+ return config
211
+ }