@defrex/autobuild 0.9.0 → 0.10.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/README.md +1 -1
- package/SPEC.md +10 -0
- package/docs/architecture.md +109 -0
- package/docs/configuration.md +24 -3
- package/docs/remote-store-protocol.md +35 -0
- package/docs/website.md +4 -0
- package/package.json +6 -4
- package/packages/core/src/cli/dashboard/facts.ts +215 -0
- package/packages/core/src/cli/dashboard/frame-parse.ts +223 -0
- package/packages/core/src/cli/dashboard/model.ts +85 -147
- package/packages/core/src/cli/dashboard/poll.ts +7 -2
- package/packages/core/src/cli/dispatch-process.ts +1 -13
- package/packages/core/src/cli/dispatch.ts +80 -37
- package/packages/core/src/cli/help.ts +15 -1
- package/packages/core/src/cli/init-validation.ts +18 -0
- package/packages/core/src/cli/installation.ts +50 -8
- package/packages/core/src/cli/self-update.ts +77 -2
- package/packages/core/src/cli/status.ts +1 -1
- package/packages/core/src/cli/ticket.ts +146 -1
- package/packages/core/src/cli/wait.ts +10 -2
- package/packages/core/src/cli/watch.ts +10 -2
- package/packages/core/src/config/live.ts +1 -0
- package/packages/core/src/config/pipeline-source.ts +17 -5
- package/packages/core/src/config/schema.ts +21 -0
- package/packages/core/src/integration/harness.ts +113 -1
- package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +1369 -0
- package/packages/core/src/kernel/auto-merge-default.ts +43 -9
- package/packages/core/src/kernel/auto-merge.ts +99 -22
- package/packages/core/src/kernel/dispatch-settings.ts +42 -17
- package/packages/core/src/kernel/dispatch-status.ts +23 -0
- package/packages/core/src/kernel/engine.ts +9 -438
- package/packages/core/src/kernel/generators/build-log.ts +259 -0
- package/packages/core/src/kernel/generators/repository-journals.ts +206 -0
- package/packages/core/src/kernel/generators/session-log.ts +72 -0
- package/packages/core/src/kernel/harvest.ts +62 -18
- package/packages/core/src/kernel/incremental-contract.ts +75 -0
- package/packages/core/src/kernel/incremental-fixtures.ts +211 -0
- package/packages/core/src/kernel/incremental.ts +53 -0
- package/packages/core/src/kernel/log-index.ts +620 -0
- package/packages/core/src/kernel/pr-attachments.ts +164 -46
- package/packages/core/src/kernel/reducer-registry.ts +164 -0
- package/packages/core/src/kernel/reducer.ts +216 -74
- package/packages/core/src/operator/query.ts +68 -28
- package/packages/core/src/operator/read-view.ts +98 -0
- package/packages/core/src/ports/tickets/fake.ts +5 -1
- package/packages/core/src/ports/tickets/file.ts +169 -48
- package/packages/core/src/ports/tickets/linear.ts +33 -1
- package/packages/core/src/ports/tickets/operations.ts +1 -1
- package/packages/core/src/ports/tickets/remote.ts +8 -1
- package/packages/core/src/ports/types.ts +32 -1
- package/packages/core/src/processes/build-execution-state.ts +31 -15
- package/packages/core/src/processes/build-runner.ts +184 -90
- package/packages/core/src/processes/dispatcher-selectors.ts +123 -0
- package/packages/core/src/processes/dispatcher.ts +270 -176
- package/packages/core/src/processes/execution-settlement.ts +72 -39
- package/packages/core/src/processes/harvest-execution-state.ts +41 -29
- package/packages/core/src/processes/infrastructure-failure-budget.ts +31 -18
- package/packages/core/src/processes/orchestrator-tick.ts +44 -3
- package/packages/core/src/processes/publication-state.ts +164 -52
- package/packages/core/src/processes/repo-view.ts +1436 -0
- package/packages/core/src/processes/sandbox-state.ts +26 -2
- package/packages/core/src/processes/tick-state.ts +33 -0
- package/packages/core/src/store/adapter.ts +10 -1
- package/packages/core/src/store/build-scope.ts +26 -0
- package/packages/core/src/store/contract.ts +337 -0
- package/packages/core/src/store/digest.ts +71 -18
- package/packages/core/src/store/local/schema.ts +17 -0
- package/packages/core/src/store/local/store.ts +111 -4
- package/packages/core/src/store/memory.ts +68 -1
- package/packages/core/src/store/phase-session.ts +23 -0
- package/packages/core/src/store/remote/client.ts +50 -1
- package/packages/core/src/store/remote/index.ts +2 -0
- package/packages/core/src/store/remote/protocol.ts +15 -0
- package/packages/core/src/store/repo-state-events.ts +72 -7
- package/packages/core/src/store/session-handle.ts +30 -0
- package/packages/core/src/store/session-reducer.ts +52 -11
- package/packages/core/src/store/snapshots.ts +88 -0
- package/packages/core/src/store/ticket-assets.ts +41 -8
- package/packages/core/src/store/types.ts +38 -0
- package/skills/guide/SKILL.md +26 -7
- package/skills/guide/references/remote-store-protocol.md +35 -0
- package/skills/tickets/SKILL.md +57 -5
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* File-based TicketSource (SPEC §3.2, §13): the default tracker. A directory
|
|
3
|
-
* of state directories — `triage/ ready/ doing/ done/`
|
|
3
|
+
* of state directories — the lifecycle four `triage/ ready/ doing/ done/`
|
|
4
|
+
* plus any the repository adds, such as `icebox/` — each holding
|
|
4
5
|
* `<id>.md` files: TOML frontmatter between `+++` fences, then the ticket
|
|
5
6
|
* body, where the spec lives pre-build (§6.3). This adapter is the policy's
|
|
6
7
|
* proof: a source with nowhere to put blobs must be fully workable, because
|
|
@@ -19,6 +20,7 @@
|
|
|
19
20
|
* human edits are explicitly last-write-wins. State renames never cross a
|
|
20
21
|
* filesystem: `<dir>/ready` → `<dir>/doing` is one mount by construction.
|
|
21
22
|
*/
|
|
23
|
+
import { readdirSync, statSync } from 'node:fs'
|
|
22
24
|
import { mkdir, readdir, readFile, rename, stat, writeFile } from 'node:fs/promises'
|
|
23
25
|
import { join } from 'node:path'
|
|
24
26
|
import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'
|
|
@@ -31,40 +33,64 @@ import type {
|
|
|
31
33
|
TicketDraft,
|
|
32
34
|
TicketListing,
|
|
33
35
|
TicketSource,
|
|
36
|
+
TicketStateInfo,
|
|
34
37
|
TicketUpdate,
|
|
35
38
|
} from '../types'
|
|
36
39
|
import { validateTicketUpdate } from './update'
|
|
37
40
|
|
|
41
|
+
/** Repo-relative fallback when no selected local-state root is supplied. */
|
|
42
|
+
export const DEFAULT_TICKETS_DIR = '.autobuild/tickets'
|
|
43
|
+
|
|
38
44
|
/**
|
|
39
|
-
* The
|
|
40
|
-
*
|
|
41
|
-
* let config invent new ones.
|
|
45
|
+
* The directories the dispatcher's roles (create, ready, claim, triage, done)
|
|
46
|
+
* are played by. Always valid state names, because `ensureLayout` creates them.
|
|
42
47
|
*/
|
|
43
|
-
|
|
44
|
-
export type TicketState = (typeof TICKET_STATES)[number]
|
|
48
|
+
const LIFECYCLE_DIRS = ['triage', 'ready', 'doing', 'done'] as const
|
|
45
49
|
|
|
46
|
-
/**
|
|
47
|
-
|
|
50
|
+
/** Dotfile inside a state directory holding its one-line purpose. Dot entries
|
|
51
|
+
* and non-`.md` files are never tickets, so it can never be mistaken for one. */
|
|
52
|
+
const ABOUT_FILE = '.about'
|
|
53
|
+
|
|
54
|
+
const STATE_NAME_RULE =
|
|
55
|
+
'a state name is one to forty characters of lowercase letters, digits, and hyphens, starting with a letter'
|
|
56
|
+
const STATE_NAME_RE = /^[a-z][a-z0-9-]{0,39}$/
|
|
57
|
+
|
|
58
|
+
/** Throws, stating the rule, unless `name` may be used to create a state. */
|
|
59
|
+
export function validateStateName(name: string): void {
|
|
60
|
+
if (!STATE_NAME_RE.test(name)) {
|
|
61
|
+
throw new Error(`file ticket source: invalid state name "${name}" — ${STATE_NAME_RULE}`)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The state names a root listing yields: the lifecycle four, then every other
|
|
66
|
+
* non-dot directory alphabetically. Dot entries and files are never states. */
|
|
67
|
+
function statesIn(directories: string[]): string[] {
|
|
68
|
+
const lifecycle: string[] = [...LIFECYCLE_DIRS]
|
|
69
|
+
const extra = directories
|
|
70
|
+
.filter((name) => !name.startsWith('.') && !lifecycle.includes(name))
|
|
71
|
+
.sort()
|
|
72
|
+
return [...lifecycle, ...extra]
|
|
73
|
+
}
|
|
48
74
|
|
|
49
75
|
/**
|
|
50
|
-
*
|
|
51
|
-
* `readyState = "ready"` means the `ready/` directory),
|
|
52
|
-
*
|
|
53
|
-
* create a
|
|
76
|
+
* Resolve a state name to its directory. Case-insensitive in (a config's
|
|
77
|
+
* `readyState = "ready"` means the `ready/` directory), the directory's real
|
|
78
|
+
* name out — and a name matching no directory is a loud error, because a
|
|
79
|
+
* typo'd state must not silently create a directory no `ls` would show.
|
|
54
80
|
*/
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
81
|
+
function resolveState(name: string, existing: string[]): string {
|
|
82
|
+
if (existing.includes(name)) return name
|
|
83
|
+
const matches = existing.filter((s) => s.toLowerCase() === name.toLowerCase())
|
|
84
|
+
if (matches.length === 1) return matches[0] as string
|
|
85
|
+
if (matches.length > 1) {
|
|
58
86
|
throw new Error(
|
|
59
|
-
`file ticket source:
|
|
60
|
-
`the directories: ${TICKET_STATES.join(', ')}`,
|
|
87
|
+
`file ticket source: ambiguous state "${name}" — it matches the directories ${matches.join(', ')}`,
|
|
61
88
|
)
|
|
62
89
|
}
|
|
63
|
-
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
return state.toLowerCase()
|
|
90
|
+
throw new Error(
|
|
91
|
+
`file ticket source: unknown state "${name}" — this tracker's states are ` +
|
|
92
|
+
`the directories: ${existing.join(', ')}`,
|
|
93
|
+
)
|
|
68
94
|
}
|
|
69
95
|
|
|
70
96
|
const frontmatterSchema = z.strictObject({
|
|
@@ -138,7 +164,7 @@ function serializeTicketFile(front: Frontmatter, body: string): string {
|
|
|
138
164
|
/** A ticket file found on disk: its id, its state (= its directory), its path. */
|
|
139
165
|
interface Located {
|
|
140
166
|
id: string
|
|
141
|
-
state:
|
|
167
|
+
state: string
|
|
142
168
|
path: string
|
|
143
169
|
}
|
|
144
170
|
|
|
@@ -147,8 +173,8 @@ export class FileTicketSource implements TicketSource {
|
|
|
147
173
|
|
|
148
174
|
private readonly dir: string
|
|
149
175
|
private readonly clock: Clock
|
|
150
|
-
private readonly createState:
|
|
151
|
-
private readonly doneState:
|
|
176
|
+
private readonly createState: string
|
|
177
|
+
private readonly doneState: string
|
|
152
178
|
private readonly selfIgnore: boolean
|
|
153
179
|
|
|
154
180
|
constructor(opts: {
|
|
@@ -174,14 +200,20 @@ export class FileTicketSource implements TicketSource {
|
|
|
174
200
|
}) {
|
|
175
201
|
this.dir = opts.dir
|
|
176
202
|
this.clock = opts.clock ?? systemClock
|
|
177
|
-
|
|
178
|
-
this.
|
|
203
|
+
const existing = this.statesSync()
|
|
204
|
+
this.createState = resolveState(opts.createState ?? 'Triage', existing)
|
|
205
|
+
this.doneState = resolveState(opts.doneState ?? 'Done', existing)
|
|
179
206
|
this.selfIgnore = opts.selfIgnore ?? false
|
|
180
207
|
}
|
|
181
208
|
|
|
182
|
-
async listReady(criteria: {
|
|
209
|
+
async listReady(criteria: {
|
|
210
|
+
labels?: string[]
|
|
211
|
+
state?: string
|
|
212
|
+
assignee?: string
|
|
213
|
+
}): Promise<TicketListing> {
|
|
183
214
|
const labels = criteria.labels ?? []
|
|
184
|
-
const state =
|
|
215
|
+
const state =
|
|
216
|
+
criteria.state === undefined ? undefined : resolveState(criteria.state, await this.states())
|
|
185
217
|
// Through listAll, not a single readdir: scan completes its layout and
|
|
186
218
|
// duplicate-id checks before any per-record validation is contained. A
|
|
187
219
|
// malformed record can never weaken the invariant preventing a `cp`
|
|
@@ -209,8 +241,8 @@ export class FileTicketSource implements TicketSource {
|
|
|
209
241
|
* the claim record — no `claimedBy` field, which is what makes "claiming
|
|
210
242
|
* visibly removes it from ready/" true.
|
|
211
243
|
*
|
|
212
|
-
* The guard refuses a ticket already in
|
|
213
|
-
* terminal state rather than requiring one in
|
|
244
|
+
* The guard refuses a ticket already in doing or in this source's configured
|
|
245
|
+
* terminal state rather than requiring one in ready. That inversion is
|
|
214
246
|
* deliberate: a legal `[tickets] readyState = "Triage"` would otherwise
|
|
215
247
|
* stall forever. Other nonterminal states remain claimable after an explicit
|
|
216
248
|
* handback, while terminal work can never be reopened by claim.
|
|
@@ -224,8 +256,8 @@ export class FileTicketSource implements TicketSource {
|
|
|
224
256
|
// this closes the list→claim window where a human could corrupt a record
|
|
225
257
|
// after it was listed but before the dispatcher claims it.
|
|
226
258
|
await this.loadAt(found)
|
|
227
|
-
if (found.state === '
|
|
228
|
-
await rename(found.path, this.pathIn('
|
|
259
|
+
if (found.state === 'doing' || found.state === this.doneState) return false
|
|
260
|
+
await rename(found.path, this.pathIn('doing', id))
|
|
229
261
|
return true
|
|
230
262
|
}
|
|
231
263
|
|
|
@@ -250,7 +282,7 @@ export class FileTicketSource implements TicketSource {
|
|
|
250
282
|
/** A move, never a rewrite — which is why the body survives byte-exactly. */
|
|
251
283
|
async transition(id: string, state: string): Promise<void> {
|
|
252
284
|
await this.ensureLayout()
|
|
253
|
-
const target =
|
|
285
|
+
const target = resolveState(state, await this.states())
|
|
254
286
|
const found = await this.locate(id)
|
|
255
287
|
if (found === null) {
|
|
256
288
|
throw new Error(`file ticket source: transition on unknown ticket "${id}"`)
|
|
@@ -270,9 +302,16 @@ export class FileTicketSource implements TicketSource {
|
|
|
270
302
|
* or adopts the ticket carrying the same idempotency key. */
|
|
271
303
|
async create(draft: TicketDraft, opts: TicketCreateOptions = {}): Promise<Ticket> {
|
|
272
304
|
await this.ensureLayout()
|
|
305
|
+
// Resolve before anything is read or written: an unknown state touches no file.
|
|
306
|
+
const targetState =
|
|
307
|
+
opts.state === undefined ? this.createState : resolveState(opts.state, await this.states())
|
|
273
308
|
const located = await this.scan()
|
|
274
309
|
if (opts.idempotencyKey !== undefined) {
|
|
275
|
-
const matches: Array<{
|
|
310
|
+
const matches: Array<{
|
|
311
|
+
found: Located
|
|
312
|
+
front: Frontmatter
|
|
313
|
+
body: string
|
|
314
|
+
}> = []
|
|
276
315
|
for (const found of located) {
|
|
277
316
|
const loaded = await this.loadAt(found)
|
|
278
317
|
if (loaded.front.idempotencyKey === opts.idempotencyKey) {
|
|
@@ -301,7 +340,6 @@ export class FileTicketSource implements TicketSource {
|
|
|
301
340
|
: {}),
|
|
302
341
|
...(opts.idempotencyKey !== undefined ? { idempotencyKey: opts.idempotencyKey } : {}),
|
|
303
342
|
}
|
|
304
|
-
const targetState = opts.state === undefined ? this.createState : stateDir(opts.state)
|
|
305
343
|
await writeFile(this.pathIn(targetState, front.id), serializeTicketFile(front, draft.body))
|
|
306
344
|
return this.toTicket(front, draft.body, targetState)
|
|
307
345
|
}
|
|
@@ -397,22 +435,105 @@ export class FileTicketSource implements TicketSource {
|
|
|
397
435
|
return states
|
|
398
436
|
}
|
|
399
437
|
|
|
438
|
+
/** Every state with its ticket count and purpose. Reads directories only —
|
|
439
|
+
* never parses a ticket — so a malformed record cannot hide the states. */
|
|
440
|
+
async listStates(): Promise<TicketStateInfo[]> {
|
|
441
|
+
const infos: TicketStateInfo[] = []
|
|
442
|
+
for (const name of await this.states()) infos.push(await this.stateInfo(name))
|
|
443
|
+
return infos
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
async addState(
|
|
447
|
+
name: string,
|
|
448
|
+
opts: { about?: string } = {},
|
|
449
|
+
): Promise<{ state: TicketStateInfo; created: boolean }> {
|
|
450
|
+
validateStateName(name)
|
|
451
|
+
const about = opts.about === undefined ? undefined : opts.about.trim()
|
|
452
|
+
if (about !== undefined && (about === '' || /[\r\n]/.test(about))) {
|
|
453
|
+
throw new Error('file ticket source: a state purpose must be one nonblank line')
|
|
454
|
+
}
|
|
455
|
+
const existing = await this.states()
|
|
456
|
+
const clash = existing.find((s) => s !== name && s.toLowerCase() === name.toLowerCase())
|
|
457
|
+
if (clash !== undefined) {
|
|
458
|
+
throw new Error(
|
|
459
|
+
`file ticket source: state "${name}" collides with the existing state "${clash}" — ${STATE_NAME_RULE}, ` +
|
|
460
|
+
'and state names are compared case-insensitively',
|
|
461
|
+
)
|
|
462
|
+
}
|
|
463
|
+
const created = !existing.includes(name)
|
|
464
|
+
await this.ensureLayout()
|
|
465
|
+
if (created) await mkdir(join(this.dir, name))
|
|
466
|
+
if (about !== undefined) await writeFile(join(this.dir, name, ABOUT_FILE), `${about}\n`)
|
|
467
|
+
return { state: await this.stateInfo(name), created }
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
private async stateInfo(name: string): Promise<TicketStateInfo> {
|
|
471
|
+
const tickets = (await this.readMarkdown(join(this.dir, name))).length
|
|
472
|
+
let about: string | undefined
|
|
473
|
+
try {
|
|
474
|
+
about = (await readFile(join(this.dir, name, ABOUT_FILE), 'utf8')).trim()
|
|
475
|
+
} catch (error) {
|
|
476
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
|
477
|
+
}
|
|
478
|
+
return { name, tickets, ...(about ? { about } : {}) }
|
|
479
|
+
}
|
|
480
|
+
|
|
400
481
|
/**
|
|
401
|
-
* The four
|
|
482
|
+
* The four lifecycle dirs (never a custom state: `addState` is the
|
|
483
|
+
* explicit act), plus — for the defaulted backlog only — a
|
|
402
484
|
* self-excluding `.gitignore`. Same move `src/cli/context.ts` makes for
|
|
403
485
|
* `.ab/`: the dir hides itself from git in ANY repo, so the local backlog is
|
|
404
486
|
* never staged, without mutating the repo's own tracked `.gitignore` as an
|
|
405
487
|
* unreviewed side effect. Idempotent; called at the head of every write.
|
|
406
488
|
*/
|
|
407
489
|
private async ensureLayout(): Promise<void> {
|
|
408
|
-
for (const state of
|
|
409
|
-
await mkdir(join(this.dir,
|
|
490
|
+
for (const state of LIFECYCLE_DIRS) {
|
|
491
|
+
await mkdir(join(this.dir, state), { recursive: true })
|
|
410
492
|
}
|
|
411
493
|
if (this.selfIgnore) await writeFile(join(this.dir, '.gitignore'), '*\n')
|
|
412
494
|
}
|
|
413
495
|
|
|
414
|
-
|
|
415
|
-
|
|
496
|
+
/** The state names that exist now. Re-read per operation so a directory
|
|
497
|
+
* created while a long-running dispatcher is up is seen. */
|
|
498
|
+
private async states(): Promise<string[]> {
|
|
499
|
+
try {
|
|
500
|
+
const entries = await readdir(this.dir, { withFileTypes: true })
|
|
501
|
+
const directories: string[] = []
|
|
502
|
+
for (const entry of entries) {
|
|
503
|
+
if (entry.isDirectory()) directories.push(entry.name)
|
|
504
|
+
else if (entry.isSymbolicLink()) {
|
|
505
|
+
const target = await stat(join(this.dir, entry.name)).catch(() => null)
|
|
506
|
+
if (target?.isDirectory()) directories.push(entry.name)
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
return statesIn(directories)
|
|
510
|
+
} catch (error) {
|
|
511
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return statesIn([])
|
|
512
|
+
throw error
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** Constructor-time twin of `states()`: the only sync IO in this adapter. */
|
|
517
|
+
private statesSync(): string[] {
|
|
518
|
+
try {
|
|
519
|
+
const entries = readdirSync(this.dir, { withFileTypes: true })
|
|
520
|
+
const directories = entries
|
|
521
|
+
.filter(
|
|
522
|
+
(e) =>
|
|
523
|
+
e.isDirectory() ||
|
|
524
|
+
(e.isSymbolicLink() &&
|
|
525
|
+
statSync(join(this.dir, e.name), { throwIfNoEntry: false })?.isDirectory()),
|
|
526
|
+
)
|
|
527
|
+
.map((e) => e.name)
|
|
528
|
+
return statesIn(directories)
|
|
529
|
+
} catch (error) {
|
|
530
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return statesIn([])
|
|
531
|
+
throw error
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
private pathIn(state: string, id: string): string {
|
|
536
|
+
return join(this.dir, state, `${this.safeId(id)}.md`)
|
|
416
537
|
}
|
|
417
538
|
|
|
418
539
|
private safeId(id: string): string {
|
|
@@ -426,7 +547,7 @@ export class FileTicketSource implements TicketSource {
|
|
|
426
547
|
private async locate(id: string): Promise<Located | null> {
|
|
427
548
|
this.safeId(id)
|
|
428
549
|
const hits: Located[] = []
|
|
429
|
-
for (const state of
|
|
550
|
+
for (const state of await this.states()) {
|
|
430
551
|
const path = this.pathIn(state, id)
|
|
431
552
|
try {
|
|
432
553
|
await stat(path)
|
|
@@ -443,14 +564,14 @@ export class FileTicketSource implements TicketSource {
|
|
|
443
564
|
return hits[0] ?? null
|
|
444
565
|
}
|
|
445
566
|
|
|
446
|
-
/** Every ticket file across
|
|
567
|
+
/** Every ticket file across every state dir, sorted, duplicates rejected. */
|
|
447
568
|
private async scan(): Promise<Located[]> {
|
|
448
569
|
await this.assertNoLooseTickets()
|
|
449
570
|
const byId = new Map<string, Located[]>()
|
|
450
|
-
for (const state of
|
|
451
|
-
for (const entry of await this.readMarkdown(join(this.dir,
|
|
571
|
+
for (const state of await this.states()) {
|
|
572
|
+
for (const entry of await this.readMarkdown(join(this.dir, state))) {
|
|
452
573
|
const id = entry.slice(0, -'.md'.length)
|
|
453
|
-
const located = { id, state, path: join(this.dir,
|
|
574
|
+
const located = { id, state, path: join(this.dir, state, entry) }
|
|
454
575
|
byId.set(id, [...(byId.get(id) ?? []), located])
|
|
455
576
|
}
|
|
456
577
|
}
|
|
@@ -473,7 +594,7 @@ export class FileTicketSource implements TicketSource {
|
|
|
473
594
|
throw new Error(
|
|
474
595
|
`${join(this.dir, entry)}: ticket file outside a state directory — a ticket's ` +
|
|
475
596
|
`state is the directory it sits in; move it into one of: ` +
|
|
476
|
-
`${
|
|
597
|
+
`${(await this.states()).join('/, ')}/`,
|
|
477
598
|
)
|
|
478
599
|
}
|
|
479
600
|
}
|
|
@@ -535,7 +656,7 @@ export class FileTicketSource implements TicketSource {
|
|
|
535
656
|
return { tickets, diagnostics }
|
|
536
657
|
}
|
|
537
658
|
|
|
538
|
-
private toTicket(front: Frontmatter, body: string, state:
|
|
659
|
+
private toTicket(front: Frontmatter, body: string, state: string): Ticket {
|
|
539
660
|
return {
|
|
540
661
|
ref: { source: this.name, id: front.id, title: front.title },
|
|
541
662
|
...(front.idempotencyKey !== undefined ? { creationKey: front.idempotencyKey } : {}),
|
|
@@ -238,10 +238,42 @@ export class LinearTicketSource implements TicketSource {
|
|
|
238
238
|
return [...(await this.getTeamInfo('workflow states')).stateIds.keys()]
|
|
239
239
|
}
|
|
240
240
|
|
|
241
|
-
|
|
241
|
+
/** The Linear user a `readyAssignee` value names: `me` is the API key's
|
|
242
|
+
* owner, anything else is an email. Null when no user matches. */
|
|
243
|
+
async resolveAssignee(
|
|
244
|
+
assignee: string,
|
|
245
|
+
): Promise<{ id: string; name: string; email: string } | null> {
|
|
246
|
+
if (assignee === 'me') {
|
|
247
|
+
const data = await this.gql<{ viewer: { id: string; name: string; email: string } | null }>(
|
|
248
|
+
'resolveAssignee',
|
|
249
|
+
'query Viewer { viewer { id name email } }',
|
|
250
|
+
{},
|
|
251
|
+
)
|
|
252
|
+
return data.viewer ?? null
|
|
253
|
+
}
|
|
254
|
+
const data = await this.gql<{
|
|
255
|
+
users: { nodes: { id: string; name: string; email: string }[] }
|
|
256
|
+
}>(
|
|
257
|
+
'resolveAssignee',
|
|
258
|
+
'query UserByEmail($email: String!) { users(filter: { email: { eq: $email } }) { nodes { id name email } } }',
|
|
259
|
+
{ email: assignee },
|
|
260
|
+
)
|
|
261
|
+
return data.users.nodes[0] ?? null
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async listReady(criteria: {
|
|
265
|
+
labels?: string[]
|
|
266
|
+
state?: string
|
|
267
|
+
assignee?: string
|
|
268
|
+
}): Promise<TicketListing> {
|
|
242
269
|
const filter: Record<string, unknown> = {
|
|
243
270
|
team: { key: { eq: this.teamKey } },
|
|
244
271
|
}
|
|
272
|
+
if (criteria.assignee !== undefined) {
|
|
273
|
+
// An unassigned issue matches neither predicate, so it is never ready.
|
|
274
|
+
filter.assignee =
|
|
275
|
+
criteria.assignee === 'me' ? { isMe: { eq: true } } : { email: { eq: criteria.assignee } }
|
|
276
|
+
}
|
|
245
277
|
if (criteria.state !== undefined) {
|
|
246
278
|
filter.state = { name: { eq: criteria.state } }
|
|
247
279
|
}
|
|
@@ -44,7 +44,7 @@ export async function requirePostMutationTicket(
|
|
|
44
44
|
export function ticketListCriteria(
|
|
45
45
|
config: Config,
|
|
46
46
|
filters: { state?: string; labels?: string[] },
|
|
47
|
-
): { state?: string; labels?: string[] } {
|
|
47
|
+
): { state?: string; labels?: string[]; assignee?: string } {
|
|
48
48
|
return filters.state === undefined && filters.labels === undefined
|
|
49
49
|
? readyCriteria(config)
|
|
50
50
|
: filters
|
|
@@ -86,7 +86,14 @@ export class HostedTicketSource implements TicketSource {
|
|
|
86
86
|
return schema.parse(await response.json())
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
async listReady(criteria: {
|
|
89
|
+
async listReady(criteria: {
|
|
90
|
+
labels?: string[]
|
|
91
|
+
state?: string
|
|
92
|
+
assignee?: string
|
|
93
|
+
}): Promise<TicketListing> {
|
|
94
|
+
if (criteria.assignee !== undefined) {
|
|
95
|
+
throw new Error('the hosted ticket source does not support an assignee gate')
|
|
96
|
+
}
|
|
90
97
|
const listing = await this.request('list-ready', criteria, ticketListingWireSchema)
|
|
91
98
|
return { ...listing, tickets: listing.tickets.map((ticket) => this.project(ticket)) }
|
|
92
99
|
}
|
|
@@ -122,12 +122,27 @@ export interface TicketUpdate {
|
|
|
122
122
|
labels?: string[]
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
/** One workflow state of a source that can enumerate them. */
|
|
126
|
+
export interface TicketStateInfo {
|
|
127
|
+
name: string
|
|
128
|
+
/** Tickets currently in the state. */
|
|
129
|
+
tickets: number
|
|
130
|
+
/** One-line purpose recorded when the state was created, if any. */
|
|
131
|
+
about?: string
|
|
132
|
+
}
|
|
133
|
+
|
|
125
134
|
export interface TicketSource {
|
|
126
135
|
readonly name: string
|
|
127
136
|
/** Ready tickets matching the dispatch criteria (label/state — §3.3), plus
|
|
128
137
|
* actionable diagnostics for individual source records excluded as invalid.
|
|
129
138
|
* Fatal source failures and invariants still reject the call. */
|
|
130
|
-
listReady(criteria: {
|
|
139
|
+
listReady(criteria: {
|
|
140
|
+
labels?: string[]
|
|
141
|
+
state?: string
|
|
142
|
+
/** `me` (the API key's owner) or a user's email; only sources that can honour it
|
|
143
|
+
* accept it — others reject `[tickets].readyAssignee` at config parse. */
|
|
144
|
+
assignee?: string
|
|
145
|
+
}): Promise<TicketListing>
|
|
131
146
|
get(id: string): Promise<Ticket | null>
|
|
132
147
|
/** Claim-before-launch (§12): acquires nonterminal work and returns true.
|
|
133
148
|
* Returns false for an unknown, already-claimed/contested, or terminal ticket.
|
|
@@ -154,6 +169,22 @@ export interface TicketSource {
|
|
|
154
169
|
* are initiation, so §13's "never consulted mid-build" is untouched.
|
|
155
170
|
*/
|
|
156
171
|
dependencyStates(ids: string[]): Promise<DependencyState[]>
|
|
172
|
+
/**
|
|
173
|
+
* Optional state discovery (`ab ticket states`): every state the source has,
|
|
174
|
+
* lifecycle states first in workflow order, then the rest alphabetically.
|
|
175
|
+
* Sources that cannot enumerate their states omit it.
|
|
176
|
+
*/
|
|
177
|
+
listStates?(): Promise<TicketStateInfo[]>
|
|
178
|
+
/**
|
|
179
|
+
* Optional state creation (`ab ticket state create`). Idempotent: an existing
|
|
180
|
+
* state is reported with `created: false`, and a supplied `about` replaces its
|
|
181
|
+
* purpose. Rejects a malformed or case-colliding name without creating anything.
|
|
182
|
+
* Named `addState` because adapters already hold a `createState` option field.
|
|
183
|
+
*/
|
|
184
|
+
addState?(
|
|
185
|
+
name: string,
|
|
186
|
+
opts?: { about?: string },
|
|
187
|
+
): Promise<{ state: TicketStateInfo; created: boolean }>
|
|
157
188
|
}
|
|
158
189
|
|
|
159
190
|
// ── Workspace (SPEC §3.2, §7) ────────────────────────────────────────────────
|
|
@@ -3,6 +3,7 @@ import { configSchema } from '../config/schema'
|
|
|
3
3
|
import { BUILD_OWNED_CONFIG_PATHS } from '../config/live'
|
|
4
4
|
import { isPipelineSourceRef, type PipelineSourceMeta } from '../config/pipeline-source'
|
|
5
5
|
import type { AbEvent } from '../events/catalog'
|
|
6
|
+
import { defineReducer } from '../kernel/incremental'
|
|
6
7
|
import type { Artifact, ArtifactInput } from '../store/types'
|
|
7
8
|
|
|
8
9
|
export const BUILD_EFFECTIVE_CONFIG_ARTIFACT = 'build-runner-effective-config'
|
|
@@ -83,26 +84,41 @@ export function buildOwnedSectionsDiffer(a: Config, b: Config): boolean {
|
|
|
83
84
|
)
|
|
84
85
|
}
|
|
85
86
|
|
|
86
|
-
|
|
87
|
-
* the provider ref as the path compatibility fallback. */
|
|
88
|
-
export function selectOpenWorkspace(events: readonly AbEvent[]): {
|
|
87
|
+
export interface OpenWorkspaceLocation {
|
|
89
88
|
ref: string
|
|
90
89
|
path: string
|
|
91
90
|
branch: string
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export const OPEN_WORKSPACE_REDUCER_VERSION = 1
|
|
94
|
+
|
|
95
|
+
/** Latest workspace location not followed by release. Historical events use
|
|
96
|
+
* the provider ref as the path compatibility fallback. */
|
|
97
|
+
export const openWorkspaceReducer = defineReducer<
|
|
98
|
+
{ open: OpenWorkspaceLocation | null },
|
|
99
|
+
AbEvent,
|
|
100
|
+
OpenWorkspaceLocation | null
|
|
101
|
+
>({
|
|
102
|
+
version: OPEN_WORKSPACE_REDUCER_VERSION,
|
|
103
|
+
initial: () => ({ open: null }),
|
|
104
|
+
fold(acc, events) {
|
|
105
|
+
for (const event of events) {
|
|
106
|
+
if (event.type === 'workspace.provisioned') {
|
|
107
|
+
acc.open = {
|
|
108
|
+
ref: event.payload.ref,
|
|
109
|
+
path: event.payload.path ?? event.payload.ref,
|
|
110
|
+
branch: event.payload.branch,
|
|
111
|
+
}
|
|
112
|
+
} else if (event.type === 'workspace.released') {
|
|
113
|
+
acc.open = null
|
|
100
114
|
}
|
|
101
|
-
} else if (event.type === 'workspace.released') {
|
|
102
|
-
open = null
|
|
103
115
|
}
|
|
104
|
-
}
|
|
105
|
-
|
|
116
|
+
},
|
|
117
|
+
finish: (acc) => (acc.open === null ? null : { ...acc.open }),
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
export function selectOpenWorkspace(events: readonly AbEvent[]): OpenWorkspaceLocation | null {
|
|
121
|
+
return openWorkspaceReducer.reduce(events)
|
|
106
122
|
}
|
|
107
123
|
|
|
108
124
|
export function diagnosticArtifact(diagnostic: BuildRunnerDiagnostic): ArtifactInput {
|