@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
@@ -0,0 +1,220 @@
1
+ import type { Ctx, FilingConfig, Job, WorkItem } from "../types"
2
+ import { type Kind, oneOf, unknownKey } from "./validate"
3
+ import { issues, prs, unblocked } from "./shared"
4
+ import { renderBrief } from "../brief"
5
+ import { filingBudget } from "../filing"
6
+ import { repoOf } from "../engine/item"
7
+
8
+ interface Options {
9
+ identity: string
10
+ headRef: string
11
+ rounds: number
12
+ commentPrefix: string
13
+ mergeMode: string
14
+ passLabel: string
15
+ filing?: Record<string, unknown>
16
+ deleteRemote: boolean
17
+ copyIntoWorktree: string[]
18
+ journal: boolean
19
+ subagents: boolean
20
+ screenshots: boolean
21
+ }
22
+
23
+ const IDENTITIES = ["pr", "closing-issue", "head-ref-issue"]
24
+ const MERGE_MODES = ["merge", "squash", "none"]
25
+ const FILING_KEYS = ["queue", "maxOpen", "perRound", "dedupeBy"]
26
+
27
+ // "build/b12" and "build/b12-a-title-slug" both name issue 12. Anchored on the
28
+ // separator so "b4" cannot be read out of "b48".
29
+ const HEAD_ISSUE = /(?:^|\/)b(\d+)(?:-|$)/
30
+
31
+ export const reviewer: Kind = {
32
+ name: "reviewer",
33
+ workload: "reviewer",
34
+ fields: [
35
+ { name: "identity", type: "string", default: "pr", doc: "what a review is keyed on: pr, closing-issue, or head-ref-issue" },
36
+ { name: "headRef", type: "string", default: "", doc: "only pull requests whose head branch starts with this; empty for every one" },
37
+ { name: "rounds", type: "number", default: 0, doc: "advisory cap on review rounds, passed to the brief, 0 for none" },
38
+ { name: "commentPrefix", type: "string", default: "Review round", doc: "the prefix a round comment starts with" },
39
+ { name: "mergeMode", type: "string", default: "", doc: "merge, squash, none, or empty for the workspace's mergeMethod" },
40
+ { name: "passLabel", type: "string", default: "", doc: "a label that releases the slot while the pull request stays open" },
41
+ { name: "filing", type: "object", doc: "backpressure: queue, maxOpen, perRound, dedupeBy" },
42
+ { name: "deleteRemote", type: "boolean", default: false, doc: "delete the branch on sweep; a review branch is never pushed" },
43
+ { name: "copyIntoWorktree", type: "string[]", default: [], doc: "files copied from the repository into a fresh worktree" },
44
+ { name: "journal", type: "boolean", default: false, doc: "ask the worker to append one line to the journal" },
45
+ { name: "subagents", type: "boolean", default: false, doc: "allow parallel review subagents" },
46
+ { name: "screenshots", type: "boolean", default: false, doc: "allow screenshots on an orphan asset branch" },
47
+ ],
48
+
49
+ // Three reviewer variants in production differ in six substantive ways, and
50
+ // every one of them is a state-machine switch rather than a cosmetic. A typo
51
+ // in one of these parses clean and changes what the loop does with a merge,
52
+ // so none of them may be validated by shape alone.
53
+ check(spec) {
54
+ const o = spec.options as unknown as Options
55
+ const errs = [...oneOf("identity", String(o.identity ?? ""), IDENTITIES)]
56
+ if (o.mergeMode && !MERGE_MODES.includes(o.mergeMode)) {
57
+ errs.push("options.mergeMode must be one of merge, squash, none, or empty for the workspace default")
58
+ }
59
+ // Nothing merges and nothing releases the claim, so the item is claimed
60
+ // forever and the slot never comes back.
61
+ if (o.mergeMode === "none" && !o.passLabel) {
62
+ errs.push("options.mergeMode none requires a passLabel, which is the only thing left that releases the item")
63
+ }
64
+ if (o.filing) {
65
+ for (const key of Object.keys(o.filing)) {
66
+ if (!FILING_KEYS.includes(key)) errs.push(`options.filing: ${unknownKey(key, FILING_KEYS)}`)
67
+ }
68
+ if (typeof o.filing.queue !== "string" || !o.filing.queue) {
69
+ errs.push("options.filing.queue is required and must name the consumer job")
70
+ }
71
+ for (const key of ["maxOpen", "perRound"]) {
72
+ if (typeof o.filing[key] !== "number") errs.push(`options.filing.${key} is required and must be a number`)
73
+ }
74
+ if (o.filing.dedupeBy !== undefined && typeof o.filing.dedupeBy !== "string") {
75
+ errs.push("options.filing.dedupeBy must be a string")
76
+ }
77
+ }
78
+ return errs
79
+ },
80
+
81
+ build(spec) {
82
+ const o = spec.options as unknown as Options
83
+ const optional = [
84
+ o.journal ? "journal" : "",
85
+ o.subagents ? "subagents" : "",
86
+ o.screenshots ? "screenshots" : "",
87
+ ].filter(Boolean)
88
+ // The loader does not look inside an object option, so the one filing
89
+ // value with a default gets it here, beside the check that validates it.
90
+ const filing = o.filing
91
+ ? ({ dedupeBy: "path", ...o.filing } as unknown as FilingConfig)
92
+ : undefined
93
+
94
+ const numberOf = (rawKey: string): number => Number.parseInt(rawKey.replace(/^r/, ""), 10)
95
+
96
+ // A repository can hold work this job has no business reviewing, and a
97
+ // reviewer that claims one takes a human's pull request hostage behind its
98
+ // claim label. Two reviewers over one repository split it the same way.
99
+ const inScope = (items: WorkItem[]) =>
100
+ o.headRef ? items.filter((i) => (i.headRef ?? "").startsWith(o.headRef)) : items
101
+
102
+ const job: Job = {
103
+ name: spec.name,
104
+ dir: spec.dir,
105
+ repo: spec.repo,
106
+ workload: "reviewer",
107
+ deleteRemote: o.deleteRemote,
108
+ copyIntoWorktree: o.copyIntoWorktree,
109
+ filing,
110
+
111
+ async discover(ctx) {
112
+ const open = unblocked(ctx, inScope(await prs(ctx, job, "open")), o.passLabel ? [o.passLabel] : [])
113
+ // First in, first out. The engine takes the first eligible candidate and
114
+ // never re-sorts, so this ordering is an external contract.
115
+ return [...open].sort((a, b) => a.number - b.number)
116
+ },
117
+
118
+ async discoverClaimed(ctx) {
119
+ const claim = ctx.workspace.naming.labels.claim
120
+ return inScope(await prs(ctx, job, "all")).filter((i) => i.labels.includes(claim))
121
+ },
122
+
123
+ async key(ctx, item) {
124
+ if (o.identity === "pr") return `r${item.number}`
125
+ if (o.identity === "head-ref-issue") {
126
+ const m = item.headRef?.match(HEAD_ISSUE)
127
+ if (m) return `r${m[1]}`
128
+ ctx.log({ pass: "warn", job: job.name, reason: `no issue in head "${item.headRef ?? ""}" for ${item.id}, keying on the pull request` })
129
+ return `r${item.number}`
130
+ }
131
+ const view = await ctx.cache(`job:closing:${item.id}`, () =>
132
+ ctx.gh.prView(repoOf(item), String(item.number), ["closingIssuesReferences"]))
133
+ const closing = view?.closingIssuesReferences?.[0]?.number
134
+ if (typeof closing === "number") return `r${closing}`
135
+ ctx.log({ pass: "warn", job: job.name, reason: `${item.id} closes no issue, keying on the pull request` })
136
+ return `r${item.number}`
137
+ },
138
+
139
+ // Counted from the pull request's own comments, with startsWith and never
140
+ // includes: a transcript tail is posted in a fenced block and contains the
141
+ // prefix, which would make every failure look like another round.
142
+ async attempt(ctx, item) {
143
+ if (!o.rounds) return 1
144
+ const view = await ctx.cache(`job:comments:${item.id}`, () =>
145
+ ctx.gh.prView(repoOf(item), String(item.number), ["comments"]))
146
+ const rounds = (view?.comments ?? []).filter((c: any) =>
147
+ String(c?.body ?? "").startsWith(o.commentPrefix)).length
148
+ return rounds + 1
149
+ },
150
+
151
+ // The head branch itself is checked out in the builder's worktree and git
152
+ // forbids a second checkout, so a review works a throwaway local branch
153
+ // based on the remote head.
154
+ base: async (_ctx, item) => `origin/${item.headRef ?? ""}`,
155
+
156
+ // A pass label is a terminal state that releases the slot while the pull
157
+ // request is still open (continuous integration owns the merge). Merged
158
+ // and closed are handled by the monitor's own state check.
159
+ async done(_ctx, item) {
160
+ return o.passLabel ? item.labels.includes(o.passLabel) : false
161
+ },
162
+
163
+ // Frequently a different predicate from done(): done releases the slot,
164
+ // sweepOk tears the worktree down, and tearing it down at the pass label
165
+ // would destroy a run that is still reconciling.
166
+ //
167
+ // A lookup that finds nothing falls through to the pull request branch
168
+ // rather than answering false. key() falls back to the pull request's own
169
+ // number whenever the identity cannot be resolved, and sweepOk cannot know
170
+ // that happened: a key matching no issue is exactly what that fallback
171
+ // produces, so reading it as a pull request number is what the fallback
172
+ // means. Answering false instead leaks the worktree, the branch and the
173
+ // tab forever.
174
+ async sweepOk(ctx, rawKey) {
175
+ const number = numberOf(rawKey)
176
+ if (o.identity === "closing-issue") {
177
+ const issue = (await issues(ctx, job, "all")).find((i) => i.number === number)
178
+ if (issue) return issue.state !== "OPEN"
179
+ }
180
+ if (o.identity === "head-ref-issue") {
181
+ const all = await prs(ctx, job, "all")
182
+ const mine = all.filter((p) => p.headRef?.match(HEAD_ISSUE)?.[1] === String(number))
183
+ const newest = mine.length ? mine.reduce((a, b) => (b.number > a.number ? b : a)) : null
184
+ if (newest) return newest.state !== "OPEN"
185
+ }
186
+ const pr = (await prs(ctx, job, "all")).find((p) => p.number === number)
187
+ return pr !== undefined && pr.state !== "OPEN"
188
+ },
189
+
190
+ async brief(ctx, item) {
191
+ const budget = await filingBudget(ctx, job)
192
+ // The brief has no conditionals, so the kind writes the whole verdict
193
+ // step. Under "none" the merge is not the worker's to make, and a brief
194
+ // that said "merge with none" would hand an unattended worker the forge
195
+ // default: the merge the operator set this option to prevent.
196
+ const method = o.mergeMode === "none" ? "" : o.mergeMode || ctx.workspace.naming.mergeMethod
197
+ const mergeInstruction = method
198
+ ? `Merge with \`${method}\`, then poll the merge state until it is definitive. A state of UNKNOWN is not a merge; keep polling.`
199
+ : `The merge is not yours to make. Apply the \`${o.passLabel}\` label instead, and do not merge. Continuous integration owns the merge from there.`
200
+ return renderBrief(ctx, job, item, await job.key(ctx, item), {
201
+ extends: spec.brief?.extends ?? "default/review",
202
+ optional,
203
+ append: spec.brief?.append,
204
+ }, {
205
+ attemptCap: o.rounds,
206
+ filingBudget: budget.filingBudget,
207
+ openQueue: budget.openQueue,
208
+ mergeInstruction,
209
+ // Never the literal "none": that names an absence of a merge, not a
210
+ // method, and prose elsewhere reads this as a method.
211
+ mergeMethod: method || ctx.workspace.naming.mergeMethod,
212
+ commentPrefix: o.commentPrefix,
213
+ passLabel: o.passLabel,
214
+ dedupeBy: filing?.dedupeBy ?? "path",
215
+ })
216
+ },
217
+ }
218
+ return job
219
+ },
220
+ }
@@ -0,0 +1,174 @@
1
+ import { existsSync } from "node:fs"
2
+ import { isAbsolute, resolve } from "node:path"
3
+ import type { Ctx, Job, WorkItem } from "../types"
4
+ import type { Kind } from "./validate"
5
+ import { renderBrief } from "../brief"
6
+ import { owns, keyOf } from "../engine/naming"
7
+ import { expandHome } from "../paths"
8
+ import { appendJournal } from "../journal"
9
+
10
+ interface Options {
11
+ at: string[]
12
+ days: string[]
13
+ doneWhen: string
14
+ base: string
15
+ deleteRemote: boolean
16
+ copyIntoWorktree: string[]
17
+ journal: boolean
18
+ }
19
+
20
+ const TIME = /^([01]\d|2[0-3]):([0-5]\d)$/
21
+ const DAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"]
22
+
23
+ const pad = (n: number) => String(n).padStart(2, "0")
24
+ const stamp = (d: Date) => `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`
25
+
26
+ // Deliberately not a cron expression. A real routine is due across a window,
27
+ // from its slot until the next one begins and then never, which is what makes a
28
+ // slot missed to a reboot fire once on the first tick back inside its window and
29
+ // never fire stale afterwards. A cron instant either misses slots after every
30
+ // reboot or double-fires them.
31
+ export function occurrenceKey(now: Date, at: string[]): string | null {
32
+ const slots = [...at].filter((t) => TIME.test(t)).sort()
33
+ if (!slots.length) return null
34
+ const minutes = now.getHours() * 60 + now.getMinutes()
35
+ const asMinutes = (t: string) => Number(t.slice(0, 2)) * 60 + Number(t.slice(3, 5))
36
+ const today = slots.filter((t) => asMinutes(t) <= minutes)
37
+ if (today.length) {
38
+ const slot = today[today.length - 1]!
39
+ return `${stamp(now)}-${slot.replace(":", "")}`
40
+ }
41
+ // Before the first slot of the day: the occurrence still running is
42
+ // yesterday's last one.
43
+ const yesterday = new Date(now)
44
+ yesterday.setDate(yesterday.getDate() - 1)
45
+ return `${stamp(yesterday)}-${slots[slots.length - 1]!.replace(":", "")}`
46
+ }
47
+
48
+ // The weekday of the occurrence, not of the clock: before the first slot of the
49
+ // day the occurrence still running is yesterday's, and it is yesterday's
50
+ // weekday that decides whether it was ever due.
51
+ function onDay(key: string, days: string[]): boolean {
52
+ if (!days.length) return true
53
+ const [y, m, d] = [key.slice(0, 4), key.slice(4, 6), key.slice(6, 8)].map(Number)
54
+ return days.includes(DAYS[new Date(y!, m! - 1, d!).getDay()]!)
55
+ }
56
+
57
+ // The occurrence's own completion marker: the artifact the run exists to
58
+ // produce. Without one the only signal a routine has is its worktree
59
+ // disappearing, and that does not happen until the occurrence rolls, so a run
60
+ // that finished at 09:38 would be nudged and then failed for hours.
61
+ function donePath(spec: { dir: string }, pattern: string, key: string): string {
62
+ const filled = expandHome(pattern.replaceAll("{{key}}", key))
63
+ return isAbsolute(filled) ? filled : resolve(spec.dir, filled)
64
+ }
65
+
66
+ const itemFor = (key: string, name: string): WorkItem => ({
67
+ // No url, so nothing labels it and nothing comments on it: the loop's own
68
+ // mark and the worktree on disk are the whole record of this run.
69
+ id: `key:${key}`,
70
+ number: 0,
71
+ title: name,
72
+ state: "OPEN",
73
+ labels: [],
74
+ })
75
+
76
+ export const routine: Kind = {
77
+ name: "routine",
78
+ workload: "routine",
79
+ fields: [
80
+ { name: "at", type: "string[]", required: true, doc: "slot times, local, like 09:10; due from the slot until the next one" },
81
+ { name: "days", type: "string[]", default: [], doc: "weekdays the run is due, like mon tue; empty for every day" },
82
+ { name: "doneWhen", type: "string", default: "", doc: "a file, {{key}} substituted, whose existence ends the occurrence" },
83
+ { name: "base", type: "string", default: "origin/main", doc: "the ref the run branches from" },
84
+ { name: "deleteRemote", type: "boolean", default: false, doc: "delete the branch on sweep if the run pushed one" },
85
+ { name: "copyIntoWorktree", type: "string[]", default: [], doc: "files copied from the repository into a fresh worktree" },
86
+ { name: "journal", type: "boolean", default: true, doc: "ask the worker to append one line to the journal" },
87
+ ],
88
+
89
+ check(spec) {
90
+ const at = (spec.options.at ?? []) as string[]
91
+ const errs = at.filter((t) => !TIME.test(t)).map((t) => `options.at: "${t}" is not a time of day like 09:10`)
92
+ if (!errs.length && at.length === 0) errs.push("options.at must name at least one time of day")
93
+ for (const d of (spec.options.days ?? []) as string[]) {
94
+ if (!DAYS.includes(d)) errs.push(`options.days: "${d}" is not a weekday like mon`)
95
+ }
96
+ return errs
97
+ },
98
+
99
+ build(spec) {
100
+ const o = spec.options as unknown as Options
101
+ const optional = o.journal ? ["journal"] : []
102
+ const keyOfItem = (item: WorkItem) => item.id.replace(/^key:/, "")
103
+
104
+ const job: Job = {
105
+ name: spec.name,
106
+ dir: spec.dir,
107
+ repo: spec.repo,
108
+ workload: "routine",
109
+ deleteRemote: o.deleteRemote,
110
+ copyIntoWorktree: o.copyIntoWorktree,
111
+
112
+ // The spawned mark is authoritative local state here, not derived: there
113
+ // is no label and no pull request to read it back from (spec 7).
114
+ async discover(ctx) {
115
+ const key = occurrenceKey(ctx.now, o.at)
116
+ if (!key || !onDay(key, o.days)) return []
117
+ return ctx.marks.has(job.name, key, "spawned") ? [] : [itemFor(key, job.name)]
118
+ },
119
+
120
+ // Derived from the world after all: a worktree this job owns is a run in
121
+ // flight, whatever the marks say.
122
+ async discoverClaimed(ctx) {
123
+ const repo = ctx.workspace.repos[job.repo ?? ""]
124
+ if (!repo) return []
125
+ // The engine's own worktree cache, not a job: prefix - the sweep pass
126
+ // earlier in this same tick already reads this exact key, so sharing
127
+ // its snapshot is the point, not an accident.
128
+ const worktrees = await ctx.cache(`engine:worktrees:${repo}`, () => ctx.git(repo).worktrees())
129
+ return worktrees
130
+ .filter((wt) => owns(job.name, ctx.workspace.worktreeBase, wt))
131
+ .map((wt) => itemFor(keyOf(job.name, wt.branch)!, job.name))
132
+ },
133
+
134
+ key: async (ctx, item) => keyOfItem(item) || occurrenceKey(ctx.now, o.at) || "",
135
+ base: async () => o.base,
136
+
137
+ // Done when the worktree is gone, which is the only external record this
138
+ // job has. While it exists the monitor supervises the worker: busy,
139
+ // nudge, then fail, like every other item.
140
+ async done(ctx, item) {
141
+ if (o.doneWhen && existsSync(donePath(spec, o.doneWhen, keyOfItem(item)))) return true
142
+ const claimed = await job.discoverClaimed(ctx)
143
+ return !claimed.some((c) => c.id === item.id)
144
+ },
145
+
146
+ // A routine must sweep occurrences that are no longer due, which is a
147
+ // different question from done(): without a completion marker the current
148
+ // occurrence's worktree has to stay until its window closes, because
149
+ // nothing else says the run is over. A doneWhen does say so, and the run
150
+ // that wrote it has no more use for its checkout or its tab: a six-hourly
151
+ // routine that finished at 09:38 would otherwise hold both until 12:10.
152
+ // The sweep checks for a working agent before it asks this, so a marker
153
+ // written mid-run still cannot pull a worktree out from under one.
154
+ async sweepOk(ctx, rawKey) {
155
+ if (o.doneWhen && existsSync(donePath(spec, o.doneWhen, rawKey))) return true
156
+ return rawKey !== occurrenceKey(ctx.now, o.at)
157
+ },
158
+
159
+ // The default failure path labels and comments, and there is nothing here
160
+ // to label. The journal is the whole post-mortem, so it gets the tail.
161
+ async onFail(ctx, item, transcriptTail) {
162
+ appendJournal(ctx, `FAIL ${job.name} ${keyOfItem(item)}: ${transcriptTail.split("\n").slice(-5).join(" ").trim()}`)
163
+ },
164
+
165
+ brief: (ctx, item) =>
166
+ renderBrief(ctx, job, item, keyOfItem(item), {
167
+ extends: spec.brief?.extends ?? "default/routine",
168
+ optional,
169
+ append: spec.brief?.append,
170
+ }),
171
+ }
172
+ return job
173
+ },
174
+ }
@@ -0,0 +1,49 @@
1
+ import type { Ctx, Job, WorkItem } from "../types"
2
+
3
+ // Every remote read a kind performs is memoized per tick under the job's own
4
+ // namespace. Without this the natural implementation puts a list call inside a
5
+ // guard and pays one network round trip per candidate: on a repository with 139
6
+ // open issues that was roughly 175 seconds in the pick loop alone.
7
+ export async function slugOf(ctx: Ctx, job: Job): Promise<string> {
8
+ const path = ctx.workspace.repos[job.repo ?? ""]
9
+ if (!path) throw new Error(`job "${job.name}" has no resolvable repo`)
10
+ return ctx.cache(`job:slug:${path}`, () => ctx.git(path).remoteSlug())
11
+ }
12
+
13
+ export async function issues(ctx: Ctx, job: Job, state: "open" | "all"): Promise<WorkItem[]> {
14
+ const slug = await slugOf(ctx, job)
15
+ return ctx.cache(`job:issues:${slug}:${state}`, () => ctx.gh.issueList({ repo: slug, state }))
16
+ }
17
+
18
+ export async function prs(ctx: Ctx, job: Job, state: "open" | "all"): Promise<WorkItem[]> {
19
+ const slug = await slugOf(ctx, job)
20
+ return ctx.cache(`job:prs:${slug}:${state}`, () => ctx.gh.prList({ repo: slug, state }))
21
+ }
22
+
23
+ // Claimed, failed, and parked items are not candidates: the claim belongs to a
24
+ // live worker, the failure is waiting for a human, and the park is a question
25
+ // nobody answered.
26
+ export function unblocked(ctx: Ctx, items: WorkItem[], extra: string[] = []): WorkItem[] {
27
+ const l = ctx.workspace.naming.labels
28
+ const blocked = new Set([l.claim, l.failed, l.park, ...extra].filter(Boolean))
29
+ return items.filter((i) => !i.labels.some((name) => blocked.has(name)))
30
+ }
31
+
32
+ // Priority labels first, in the order the workspace lists them, then oldest
33
+ // first. discover() returns candidates in priority order and the engine takes
34
+ // the first eligible without re-sorting, so this ordering is the contract.
35
+ export function byPriority(ctx: Ctx, items: WorkItem[]): WorkItem[] {
36
+ const priority = ctx.workspace.naming.labels.priority
37
+ const rank = (i: WorkItem) => {
38
+ const at = priority.findIndex((p) => i.labels.includes(p))
39
+ return at === -1 ? priority.length : at
40
+ }
41
+ return [...items].sort((a, b) => rank(a) - rank(b) || a.number - b.number)
42
+ }
43
+
44
+ // The newest match, so a retried issue's old closed pull request never decides
45
+ // anything about the live one working the same key (spec 4.4).
46
+ export function newestByHead(items: WorkItem[], head: string): WorkItem | null {
47
+ const mine = items.filter((i) => i.headRef === head)
48
+ return mine.length ? mine.reduce((a, b) => (b.number > a.number ? b : a)) : null
49
+ }
@@ -0,0 +1,187 @@
1
+ import type { Job } from "../types"
2
+
3
+ // The field table, the kind contract, and the pure validation helpers around
4
+ // it. Split out of index.ts because every kind needs these while index.ts
5
+ // imports every kind to build the registry: entering the graph at a kind then
6
+ // hit that cycle and threw before the kind's own module body ran, which made
7
+ // its test file unrunnable on its own.
8
+ export type FieldType = "string" | "number" | "boolean" | "string[]" | "object"
9
+
10
+ export interface Field {
11
+ name: string
12
+ type: FieldType
13
+ required?: boolean
14
+ default?: unknown
15
+ doc: string
16
+ }
17
+
18
+ // What the loader hands a kind: everything it validated itself, plus the
19
+ // options it did not look inside.
20
+ export interface JobSpec {
21
+ name: string
22
+ dir: string
23
+ repo?: string
24
+ // Engine-level, so the loader resolves and checks it and the kind only
25
+ // renders it. `append` is already an absolute path by the time a kind sees it.
26
+ brief?: { extends?: string; append?: string }
27
+ options: Record<string, unknown>
28
+ }
29
+
30
+ export interface Kind {
31
+ name: string
32
+ workload: string
33
+ fields: Field[]
34
+ // Validation a field table cannot express: an enum, or the shape inside an
35
+ // object option. Returns messages in validateOptions' vocabulary, which the
36
+ // loader prefixes with the job file. A typo like "reserved" for "reserve"
37
+ // parses clean and silently disarms whatever it configures, so a kind with an
38
+ // enum or an object option owes the operator this.
39
+ check?(spec: JobSpec): string[]
40
+ build(spec: JobSpec): Job
41
+ }
42
+
43
+ function levenshtein(a: string, b: string): number {
44
+ const d: number[][] = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0))
45
+ for (let i = 0; i <= a.length; i++) d[i]![0] = i
46
+ for (let j = 0; j <= b.length; j++) d[0]![j] = j
47
+ for (let i = 1; i <= a.length; i++) {
48
+ for (let j = 1; j <= b.length; j++) {
49
+ d[i]![j] = Math.min(
50
+ d[i - 1]![j]! + 1,
51
+ d[i]![j - 1]! + 1,
52
+ d[i - 1]![j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1),
53
+ )
54
+ }
55
+ }
56
+ return d[a.length]![b.length]!
57
+ }
58
+
59
+ // A typo is worth a suggestion; a different word is not. The threshold keeps
60
+ // "did you mean" from firing on names that merely start with the same letter.
61
+ export function didYouMean(word: string, candidates: string[]): string {
62
+ let best = ""
63
+ let bestD = Infinity
64
+ for (const c of candidates) {
65
+ const d = levenshtein(word, c)
66
+ if (d < bestD) { bestD = d; best = c }
67
+ }
68
+ // Two edits, not one: a transposition costs two, and "wbe" for "web" is the
69
+ // most common typo there is. A floor of one makes the suggester blind to it
70
+ // for every short word, which is most repo keys and kind names.
71
+ return bestD <= Math.max(2, Math.floor(word.length * 0.4)) ? best : ""
72
+ }
73
+
74
+ // The unknown-key message, in the same shape validateOptions produces for an
75
+ // unknown option. A typo is the whole reason these checks exist, so a message
76
+ // that lists the known keys without pointing at the near miss is half a fix.
77
+ export function unknownKey(key: string, known: string[]): string {
78
+ const near = didYouMean(key, known)
79
+ return near
80
+ ? `unknown key "${key}"; did you mean "${near}"?`
81
+ : `unknown key "${key}"; known: ${known.join(", ")}`
82
+ }
83
+
84
+ export function oneOf(field: string, value: string, allowed: string[]): string[] {
85
+ if (allowed.includes(value)) return []
86
+ const near = didYouMean(value, allowed)
87
+ return [
88
+ near
89
+ ? `options.${field} must be one of ${allowed.join(", ")}; did you mean "${near}"?`
90
+ : `options.${field} must be one of ${allowed.join(", ")}`,
91
+ ]
92
+ }
93
+
94
+ // Names the shape a value has, in the vocabulary the field table uses, so the
95
+ // error an operator reads is in the same words as the documentation.
96
+ function shapeOf(v: unknown): string {
97
+ if (Array.isArray(v)) return "list"
98
+ if (v === null) return "null"
99
+ return typeof v
100
+ }
101
+
102
+ function typeOk(v: unknown, t: FieldType): boolean {
103
+ switch (t) {
104
+ case "string": return typeof v === "string"
105
+ case "number": return typeof v === "number" && Number.isFinite(v)
106
+ case "boolean": return typeof v === "boolean"
107
+ case "string[]": return Array.isArray(v) && v.every((x) => typeof x === "string")
108
+ case "object": return typeof v === "object" && v !== null && !Array.isArray(v)
109
+ }
110
+ }
111
+
112
+ export function validateOptions(
113
+ kind: Kind,
114
+ options: Record<string, unknown>,
115
+ ): { errors: string[]; value: Record<string, unknown> } {
116
+ const errors: string[] = []
117
+ const known = kind.fields.map((f) => f.name)
118
+
119
+ for (const key of Object.keys(options)) {
120
+ if (known.includes(key)) continue
121
+ const near = didYouMean(key, known)
122
+ errors.push(
123
+ near
124
+ ? `unknown option "${key}"; did you mean "${near}"?`
125
+ : `unknown option "${key}"; known: ${known.join(", ")}`,
126
+ )
127
+ }
128
+
129
+ const value: Record<string, unknown> = {}
130
+ for (const f of kind.fields) {
131
+ const given = options[f.name]
132
+ if (given === undefined || given === null) {
133
+ if (f.required) errors.push(`options.${f.name} is required (${f.doc})`)
134
+ else if (f.default !== undefined) value[f.name] = f.default
135
+ continue
136
+ }
137
+ if (!typeOk(given, f.type)) {
138
+ errors.push(
139
+ f.type === "string[]"
140
+ ? `options.${f.name} must be a list of strings`
141
+ : `options.${f.name} must be a ${f.type}, found ${shapeOf(given)}`,
142
+ )
143
+ continue
144
+ }
145
+ value[f.name] = given
146
+ }
147
+ return { errors, value }
148
+ }
149
+
150
+ export function describeKind(k: Kind): string[] {
151
+ const notes = k.fields.map((f) => (f.required ? "required" : `= ${JSON.stringify(f.default)}`))
152
+ const width = Math.max(...k.fields.map((f) => f.name.length))
153
+ const typeWidth = Math.max(...k.fields.map((f) => f.type.length))
154
+ const noteWidth = Math.max(...notes.map((n) => n.length))
155
+ const lines = [`${k.name} (workload: ${k.workload})`]
156
+ k.fields.forEach((f, i) => {
157
+ lines.push(
158
+ ` ${f.name.padEnd(width)} ${f.type.padEnd(typeWidth)} ${notes[i]!.padEnd(noteWidth)} ${f.doc}`,
159
+ )
160
+ })
161
+ return lines
162
+ }
163
+
164
+ const JSON_TYPE: Record<FieldType, object> = {
165
+ string: { type: "string" },
166
+ number: { type: "number" },
167
+ boolean: { type: "boolean" },
168
+ "string[]": { type: "array", items: { type: "string" } },
169
+ object: { type: "object" },
170
+ }
171
+
172
+ export function kindSchema(k: Kind): object {
173
+ const properties: Record<string, object> = {}
174
+ for (const f of k.fields) {
175
+ properties[f.name] = {
176
+ ...JSON_TYPE[f.type],
177
+ ...(f.default === undefined ? {} : { default: f.default }),
178
+ description: f.doc,
179
+ }
180
+ }
181
+ return {
182
+ type: "object",
183
+ properties,
184
+ required: k.fields.filter((f) => f.required).map((f) => f.name),
185
+ additionalProperties: false,
186
+ }
187
+ }