@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
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
ticketListCriteria,
|
|
20
20
|
updateTicket,
|
|
21
21
|
} from '../ports/tickets/operations'
|
|
22
|
-
import type { Ticket, TicketSource, TicketUpdate } from '../ports/types'
|
|
22
|
+
import type { Ticket, TicketSource, TicketStateInfo, TicketUpdate } from '../ports/types'
|
|
23
23
|
import { reduceBuild } from '../kernel/reducer'
|
|
24
24
|
import type { BuildStore } from '../store/types'
|
|
25
25
|
import { spawnExec, type Exec } from '../ports/workspace/git-worktree'
|
|
@@ -140,7 +140,20 @@ export interface TicketMoveOpts extends TicketCommandOpts {
|
|
|
140
140
|
json?: boolean
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
+
export interface TicketStatesOpts extends TicketCommandOpts {
|
|
144
|
+
json?: boolean
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface TicketStateCreateOpts extends TicketCommandOpts {
|
|
148
|
+
name: string
|
|
149
|
+
/** One-line purpose; absent leaves an existing purpose untouched. */
|
|
150
|
+
about?: string
|
|
151
|
+
json?: boolean
|
|
152
|
+
}
|
|
153
|
+
|
|
143
154
|
type TicketCommandName =
|
|
155
|
+
| 'states'
|
|
156
|
+
| 'state create'
|
|
144
157
|
| 'create'
|
|
145
158
|
| 'update'
|
|
146
159
|
| 'block'
|
|
@@ -525,6 +538,108 @@ export async function abTicketMove(opts: TicketMoveOpts): Promise<void> {
|
|
|
525
538
|
opts.stdout(`ticket moved: ${ticketSummary(moved)}`)
|
|
526
539
|
}
|
|
527
540
|
|
|
541
|
+
export type TicketStateRole = 'create' | 'ready' | 'claimed' | 'triage' | 'proposal' | 'done'
|
|
542
|
+
|
|
543
|
+
export interface TicketStateRow extends TicketStateInfo {
|
|
544
|
+
roles: TicketStateRole[]
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* The lifecycle roles each state plays under `config`, for a source whose
|
|
549
|
+
* states are directories (names match case-insensitively, as `ab ticket move`
|
|
550
|
+
* does). The file adapter always claims into `doing`, so that is shown as
|
|
551
|
+
* claimed whatever `[tickets].claimedState` says. Roles naming no existing
|
|
552
|
+
* state are omitted.
|
|
553
|
+
*/
|
|
554
|
+
export function stateRoles(
|
|
555
|
+
tickets: TicketsConfig,
|
|
556
|
+
names: string[],
|
|
557
|
+
): Map<string, TicketStateRole[]> {
|
|
558
|
+
const triage = tickets.triageState ?? 'triage'
|
|
559
|
+
const named: Array<[TicketStateRole, string]> = [
|
|
560
|
+
['create', tickets.createState ?? 'triage'],
|
|
561
|
+
['ready', tickets.readyState],
|
|
562
|
+
['claimed', 'doing'],
|
|
563
|
+
['triage', triage],
|
|
564
|
+
['proposal', tickets.proposalState ?? triage],
|
|
565
|
+
['done', 'done'],
|
|
566
|
+
]
|
|
567
|
+
const roles = new Map<string, TicketStateRole[]>(names.map((name) => [name, []]))
|
|
568
|
+
for (const [role, target] of named) {
|
|
569
|
+
const match =
|
|
570
|
+
names.find((name) => name === target) ??
|
|
571
|
+
names.find((name) => name.toLowerCase() === target.toLowerCase())
|
|
572
|
+
if (match !== undefined) roles.get(match)?.push(role)
|
|
573
|
+
}
|
|
574
|
+
return roles
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function stateRow(info: TicketStateInfo, roles: Map<string, TicketStateRole[]>): TicketStateRow {
|
|
578
|
+
return {
|
|
579
|
+
name: info.name,
|
|
580
|
+
roles: roles.get(info.name) ?? [],
|
|
581
|
+
tickets: info.tickets,
|
|
582
|
+
...(info.about !== undefined ? { about: info.about } : {}),
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function unsupported(source: TicketSource, operation: string): Error {
|
|
587
|
+
return new Error(`ticket source "${source.name}" does not support ${operation}`)
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** `ab ticket states` — every state of the source, its roles, counts, purpose. */
|
|
591
|
+
export async function abTicketStates(opts: TicketStatesOpts): Promise<void> {
|
|
592
|
+
const { config, source } = await resolveTicketCommand(opts, 'states')
|
|
593
|
+
if (typeof source.listStates !== 'function') {
|
|
594
|
+
throw unsupported(source, 'state discovery (ab ticket states)')
|
|
595
|
+
}
|
|
596
|
+
const infos = await source.listStates()
|
|
597
|
+
const roles = stateRoles(
|
|
598
|
+
config.tickets,
|
|
599
|
+
infos.map((info) => info.name),
|
|
600
|
+
)
|
|
601
|
+
const rows = infos.map((info) => stateRow(info, roles))
|
|
602
|
+
if (opts.json === true) {
|
|
603
|
+
opts.stdout(JSON.stringify(rows, null, 2))
|
|
604
|
+
return
|
|
605
|
+
}
|
|
606
|
+
const nameWidth = Math.max(...rows.map((row) => row.name.length))
|
|
607
|
+
const roleText = rows.map((row) => (row.roles.length > 0 ? row.roles.join(',') : 'none'))
|
|
608
|
+
const roleWidth = Math.max(...roleText.map((text) => text.length))
|
|
609
|
+
rows.forEach((row, index) => {
|
|
610
|
+
const count = `${row.tickets} ${row.tickets === 1 ? 'ticket' : 'tickets'}`
|
|
611
|
+
const line = `${row.name.padEnd(nameWidth)} ${(roleText[index] as string).padEnd(roleWidth)} ${count}`
|
|
612
|
+
opts.stdout(row.about === undefined ? line : `${line} ${row.about}`)
|
|
613
|
+
})
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/** `ab ticket state create <name> [--about <text>]` — idempotent. */
|
|
617
|
+
export async function abTicketStateCreate(opts: TicketStateCreateOpts): Promise<void> {
|
|
618
|
+
const { config, source } = await resolveTicketCommand(opts, 'state create')
|
|
619
|
+
if (typeof source.addState !== 'function') {
|
|
620
|
+
throw unsupported(source, 'state creation (ab ticket state create)')
|
|
621
|
+
}
|
|
622
|
+
const aboutBefore =
|
|
623
|
+
opts.about === undefined
|
|
624
|
+
? undefined
|
|
625
|
+
: (await source.listStates?.())?.find((info) => info.name === opts.name)?.about
|
|
626
|
+
const { state, created } = await source.addState(opts.name, {
|
|
627
|
+
...(opts.about !== undefined ? { about: opts.about } : {}),
|
|
628
|
+
})
|
|
629
|
+
const names = (await source.listStates?.())?.map((info) => info.name) ?? [state.name]
|
|
630
|
+
const row = stateRow(state, stateRoles(config.tickets, names))
|
|
631
|
+
if (opts.json === true) {
|
|
632
|
+
opts.stdout(JSON.stringify({ ...row, created }, null, 2))
|
|
633
|
+
return
|
|
634
|
+
}
|
|
635
|
+
const purposeChanged = !created && opts.about !== undefined && state.about !== aboutBefore
|
|
636
|
+
opts.stdout(
|
|
637
|
+
created
|
|
638
|
+
? `ticket state created: ${state.name}`
|
|
639
|
+
: `ticket state already exists: ${state.name}${purposeChanged ? ' (purpose updated)' : ''}`,
|
|
640
|
+
)
|
|
641
|
+
}
|
|
642
|
+
|
|
528
643
|
const CREATE_USAGE =
|
|
529
644
|
'usage: ab ticket create <title> --body <file> [--state <state>] [--labels a,b] [--blocked-by id,id] [--json] (§8.8)'
|
|
530
645
|
const UPDATE_USAGE =
|
|
@@ -540,6 +655,8 @@ const ASSET_GET_USAGE =
|
|
|
540
655
|
const ASSET_RM_USAGE =
|
|
541
656
|
'usage: ab ticket asset rm <id> <kind> <name> [--store <ref>] [--json] (§8.8)'
|
|
542
657
|
const MOVE_USAGE = 'usage: ab ticket move <id> <state> [--json] (§8.8)'
|
|
658
|
+
const STATES_USAGE = 'usage: ab ticket states [--json] (§8.8)'
|
|
659
|
+
const STATE_CREATE_USAGE = 'usage: ab ticket state create <name> [--about <text>] [--json] (§8.8)'
|
|
543
660
|
export const TICKET_USAGE = [
|
|
544
661
|
CREATE_USAGE,
|
|
545
662
|
UPDATE_USAGE,
|
|
@@ -548,6 +665,8 @@ export const TICKET_USAGE = [
|
|
|
548
665
|
LIST_USAGE,
|
|
549
666
|
SHOW_USAGE,
|
|
550
667
|
MOVE_USAGE,
|
|
668
|
+
STATES_USAGE,
|
|
669
|
+
STATE_CREATE_USAGE,
|
|
551
670
|
ATTACH_USAGE,
|
|
552
671
|
ASSET_GET_USAGE,
|
|
553
672
|
ASSET_RM_USAGE,
|
|
@@ -757,6 +876,32 @@ export async function abTicket(argv: string[], opts: TicketCliOpts): Promise<voi
|
|
|
757
876
|
throw new Error(TICKET_USAGE)
|
|
758
877
|
}
|
|
759
878
|
|
|
879
|
+
case 'states': {
|
|
880
|
+
const parsed = parseArgs(args, { json: 'boolean' }, TICKET_USAGE)
|
|
881
|
+
if (parsed.positionals.length !== 0) throw new Error(STATES_USAGE)
|
|
882
|
+
await abTicketStates({ ...opts, json: parsed.flags.has('json') })
|
|
883
|
+
return
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
case 'state': {
|
|
887
|
+
const [action, ...stateArgs] = args
|
|
888
|
+
if (action !== 'create') throw new Error(STATE_CREATE_USAGE)
|
|
889
|
+
const parsed = parseArgs(stateArgs, { about: 'value', json: 'boolean' }, TICKET_USAGE)
|
|
890
|
+
const [name, ...extra] = parsed.positionals
|
|
891
|
+
if (name === undefined || name.trim() === '' || extra.length > 0) {
|
|
892
|
+
throw new Error(STATE_CREATE_USAGE)
|
|
893
|
+
}
|
|
894
|
+
const about = stringFlag(parsed, 'about')
|
|
895
|
+
if (about !== undefined && about.trim() === '') throw new Error(STATE_CREATE_USAGE)
|
|
896
|
+
await abTicketStateCreate({
|
|
897
|
+
...opts,
|
|
898
|
+
name,
|
|
899
|
+
...(about !== undefined ? { about } : {}),
|
|
900
|
+
json: parsed.flags.has('json'),
|
|
901
|
+
})
|
|
902
|
+
return
|
|
903
|
+
}
|
|
904
|
+
|
|
760
905
|
case 'move': {
|
|
761
906
|
const parsed = parseArgs(args, { json: 'boolean' }, TICKET_USAGE)
|
|
762
907
|
const [id, state, ...extra] = parsed.positionals
|
|
@@ -416,6 +416,9 @@ export async function abWait(opts: AbWaitOpts): Promise<number> {
|
|
|
416
416
|
if (satisfied === null) checkRegistrationState(stream)
|
|
417
417
|
}
|
|
418
418
|
|
|
419
|
+
/** Builds discovery read in full and found terminal; never re-read. */
|
|
420
|
+
const retired = new Set<string>()
|
|
421
|
+
|
|
419
422
|
/**
|
|
420
423
|
* No-slug membership: every nonterminal build of this repository joins
|
|
421
424
|
* the wait, baselined at its current maximum so no history replays, and
|
|
@@ -440,12 +443,17 @@ export async function abWait(opts: AbWaitOpts): Promise<number> {
|
|
|
440
443
|
// diverge from reduceBuild.
|
|
441
444
|
const digests = await store.getRepoBuildDigests(repo)
|
|
442
445
|
for (const record of (await store.listBuilds()).filter(mine)) {
|
|
443
|
-
if (streams.has(record.slug)) continue
|
|
446
|
+
if (streams.has(record.slug) || retired.has(record.slug)) continue
|
|
444
447
|
const digest = digests.get(record.slug)
|
|
445
448
|
if (digest !== undefined && digest.terminal !== undefined) continue
|
|
446
449
|
const events = await store.getEvents(record.slug)
|
|
447
450
|
const status = reduceBuild(events).status
|
|
448
|
-
if (!NONTERMINAL_STATUSES.includes(status))
|
|
451
|
+
if (!NONTERMINAL_STATUSES.includes(status)) {
|
|
452
|
+
// Terminal is permanent (reduceBuild never clears it): a legacy
|
|
453
|
+
// record the digest cannot retire is read once, not every pass.
|
|
454
|
+
retired.add(record.slug)
|
|
455
|
+
continue
|
|
456
|
+
}
|
|
449
457
|
const stream = registerStream(record.slug, events, false)
|
|
450
458
|
if (satisfied === null) checkRegistrationState(stream)
|
|
451
459
|
}
|
|
@@ -597,6 +597,9 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
|
|
|
597
597
|
return true
|
|
598
598
|
}
|
|
599
599
|
|
|
600
|
+
/** Builds discovery read in full and found terminal; never re-read. */
|
|
601
|
+
const retired = new Set<string>()
|
|
602
|
+
|
|
600
603
|
/**
|
|
601
604
|
* No-slug membership: every nonterminal build of this repository joins
|
|
602
605
|
* the watch, baselined at its current maximum so no history replays. A
|
|
@@ -624,12 +627,17 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
|
|
|
624
627
|
// diverge from reduceBuild. Mirrors cli/wait.ts discoverBuilds.
|
|
625
628
|
const digests = await store.getRepoBuildDigests(repo)
|
|
626
629
|
for (const record of (await store.listBuilds()).filter(mine)) {
|
|
627
|
-
if (streams.has(record.slug)) continue
|
|
630
|
+
if (streams.has(record.slug) || retired.has(record.slug)) continue
|
|
628
631
|
const digest = digests.get(record.slug)
|
|
629
632
|
if (digest !== undefined && digest.terminal !== undefined) continue
|
|
630
633
|
const events = await store.getEvents(record.slug)
|
|
631
634
|
const status = reduceBuild(events).status
|
|
632
|
-
if (!NONTERMINAL_STATUSES.includes(status))
|
|
635
|
+
if (!NONTERMINAL_STATUSES.includes(status)) {
|
|
636
|
+
// Terminal is permanent (reduceBuild never clears it): a legacy
|
|
637
|
+
// record the digest cannot retire is read once, not every pass.
|
|
638
|
+
retired.add(record.slug)
|
|
639
|
+
continue
|
|
640
|
+
}
|
|
633
641
|
const stream: BuildStream = {
|
|
634
642
|
kind: 'build',
|
|
635
643
|
slug: record.slug,
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { readFile } from 'node:fs/promises'
|
|
11
11
|
import { join } from 'node:path'
|
|
12
12
|
import type { AbEvent } from '../events/catalog'
|
|
13
|
+
import { defineReducer } from '../kernel/incremental'
|
|
13
14
|
import type { Forge } from '../ports/types'
|
|
14
15
|
import type { Exec } from '../ports/workspace/git-worktree'
|
|
15
16
|
import type { BuildRecord } from '../store/types'
|
|
@@ -101,15 +102,26 @@ export function isActionablePipelineFailure(failure: PipelineSourceFailure): boo
|
|
|
101
102
|
)
|
|
102
103
|
}
|
|
103
104
|
|
|
105
|
+
export const RECORDED_BASE_SHA_REDUCER_VERSION = 1
|
|
106
|
+
|
|
104
107
|
/** The recorded base commit of the build's latest workspace. The newest
|
|
105
108
|
* `workspace.provisioned` wins: a re-provisioned workspace is what the build's
|
|
106
109
|
* current environment was cut from. */
|
|
110
|
+
export const recordedBaseShaReducer = defineReducer<{ base?: string }, AbEvent, string | undefined>(
|
|
111
|
+
{
|
|
112
|
+
version: RECORDED_BASE_SHA_REDUCER_VERSION,
|
|
113
|
+
initial: () => ({}),
|
|
114
|
+
fold(acc, events) {
|
|
115
|
+
for (const event of events) {
|
|
116
|
+
if (event.type === 'workspace.provisioned') acc.base = event.payload.base.sha
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
finish: (acc) => acc.base,
|
|
120
|
+
},
|
|
121
|
+
)
|
|
122
|
+
|
|
107
123
|
export function recordedBaseSha(events: readonly AbEvent[]): string | undefined {
|
|
108
|
-
|
|
109
|
-
for (const event of events) {
|
|
110
|
-
if (event.type === 'workspace.provisioned') base = event.payload.base.sha
|
|
111
|
-
}
|
|
112
|
-
return base
|
|
124
|
+
return recordedBaseShaReducer.reduce(events)
|
|
113
125
|
}
|
|
114
126
|
|
|
115
127
|
function buildBranch(record: Pick<BuildRecord, 'branch'>, slug: string): string {
|
|
@@ -556,6 +556,20 @@ export const ticketsSchema = z.strictObject({
|
|
|
556
556
|
(s) => s.trim().length > 0,
|
|
557
557
|
'[tickets].readyState must not be blank — name the one workflow state a ticket must sit in to be dispatched (e.g. "ready").',
|
|
558
558
|
),
|
|
559
|
+
/**
|
|
560
|
+
* Narrows readiness to tickets assigned to one Linear user — a third
|
|
561
|
+
* conjunctive gate beside readyState and readyLabels. `"me"` is the owner of
|
|
562
|
+
* the dispatcher's LINEAR_API_KEY; an email names a specific user (for a
|
|
563
|
+
* shared or bot key). Unassigned tickets are never ready while it is set.
|
|
564
|
+
* Linear only; absent leaves readiness unchanged.
|
|
565
|
+
*/
|
|
566
|
+
readyAssignee: z
|
|
567
|
+
.string()
|
|
568
|
+
.refine(
|
|
569
|
+
(s) => s === 'me' || (s === s.trim() && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s)),
|
|
570
|
+
'[tickets].readyAssignee must be "me" (the owner of the API key) or a user email address (e.g. "person@example.com")',
|
|
571
|
+
)
|
|
572
|
+
.optional(),
|
|
559
573
|
/** Backend team key (e.g. "ENG") — required for linear and hosted. */
|
|
560
574
|
teamKey: z.string().min(1).optional(),
|
|
561
575
|
/** Workflow state claim() moves an issue to (§12); Linear and hosted. */
|
|
@@ -923,6 +937,13 @@ export const configSchema = configRootSchema.superRefine((config, ctx) => {
|
|
|
923
937
|
}
|
|
924
938
|
}
|
|
925
939
|
}
|
|
940
|
+
if (tickets.source !== 'linear' && tickets.readyAssignee !== undefined) {
|
|
941
|
+
ctx.addIssue({
|
|
942
|
+
code: 'custom',
|
|
943
|
+
path: ['tickets', 'readyAssignee'],
|
|
944
|
+
message: `[tickets].readyAssignee applies only to source = "linear" — remove it or select that source (selected source: "${tickets.source}")`,
|
|
945
|
+
})
|
|
946
|
+
}
|
|
926
947
|
// Plugin sources receive the existing ticket lifecycle/configuration fields
|
|
927
948
|
// unchanged; adapter-specific validation belongs to their factory.
|
|
928
949
|
|
|
@@ -59,6 +59,13 @@ import { InProcessBuildExecution } from '../ports/workspace/in-process-build-exe
|
|
|
59
59
|
import { BuildRunner, LeaseHeldError, SetupFailureError } from '../processes/build-runner'
|
|
60
60
|
import { diagnosticArtifact } from '../processes/build-execution-state'
|
|
61
61
|
import { Dispatcher, type LaunchRunnerResult } from '../processes/dispatcher'
|
|
62
|
+
import {
|
|
63
|
+
getHarvestStatus,
|
|
64
|
+
getOperatorDashboard,
|
|
65
|
+
getRepositoryStatus,
|
|
66
|
+
listOperatorBuilds,
|
|
67
|
+
} from '../operator/query'
|
|
68
|
+
import { RepoViewStore } from '../processes/repo-view'
|
|
62
69
|
import { MemoryBuildStore } from '../store/memory'
|
|
63
70
|
import type { BuildStore } from '../store/types'
|
|
64
71
|
import { steppingClock } from '../testing/fixed'
|
|
@@ -279,6 +286,106 @@ export interface E2eHarness {
|
|
|
279
286
|
cleanup(): Promise<void>
|
|
280
287
|
}
|
|
281
288
|
|
|
289
|
+
/** The store with every reducer snapshot deleted: reads miss, writes no-op. */
|
|
290
|
+
export function withoutSnapshots<T extends BuildStore>(store: T): T {
|
|
291
|
+
return new Proxy(store, {
|
|
292
|
+
get(target, key) {
|
|
293
|
+
if (key === 'getReducerSnapshot') return async () => null
|
|
294
|
+
if (key === 'putReducerSnapshot') return async () => false
|
|
295
|
+
const value = Reflect.get(target, key, target)
|
|
296
|
+
return typeof value === 'function' ? value.bind(target) : value
|
|
297
|
+
},
|
|
298
|
+
})
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** A build's facts with the publication view (closures) reduced to its answers. */
|
|
302
|
+
async function plainFacts(view: RepoViewStore, slug: string) {
|
|
303
|
+
const { publication, ...rest } = await view.buildFacts(slug)
|
|
304
|
+
return {
|
|
305
|
+
...rest,
|
|
306
|
+
publication: {
|
|
307
|
+
pending: publication.pending(),
|
|
308
|
+
abandoned: publication.abandonedPending(),
|
|
309
|
+
latest: publication.latestUncompletedRequest(),
|
|
310
|
+
},
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** JSON with sorted keys, so two structurally equal values compare equal. */
|
|
315
|
+
function canonical(value: unknown): string {
|
|
316
|
+
return JSON.stringify(value, (_key, item) =>
|
|
317
|
+
item !== null && typeof item === 'object' && !Array.isArray(item)
|
|
318
|
+
? Object.fromEntries(Object.entries(item).sort(([a], [b]) => a.localeCompare(b)))
|
|
319
|
+
: item,
|
|
320
|
+
)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function repositoryReading(store: BuildStore, repo: string) {
|
|
324
|
+
const view = new RepoViewStore(store, { repo })
|
|
325
|
+
await view.refresh()
|
|
326
|
+
const slugs = (await view.listBuilds()).map((record) => record.slug).sort()
|
|
327
|
+
const builds: Record<string, unknown> = {}
|
|
328
|
+
for (const slug of slugs) {
|
|
329
|
+
builds[slug] = { facts: await plainFacts(view, slug), state: await view.buildState(slug) }
|
|
330
|
+
}
|
|
331
|
+
return {
|
|
332
|
+
journal: view.recordedJournal(),
|
|
333
|
+
digests: [...(await view.getRepoBuildDigests(repo)).entries()].sort(([a], [b]) =>
|
|
334
|
+
a.localeCompare(b),
|
|
335
|
+
),
|
|
336
|
+
builds,
|
|
337
|
+
view,
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Reducer snapshots are a cache the log regenerates (SPEC §7): whatever a
|
|
342
|
+
* scenario's dispatcher left behind, a cold view restored from it must read
|
|
343
|
+
* exactly what a cold view over a store with every snapshot deleted reads — and
|
|
344
|
+
* so must one restored from snapshots written at the final cursor. Run for
|
|
345
|
+
* every scenario at cleanup. */
|
|
346
|
+
export async function assertSnapshotEquivalence(store: BuildStore, repo: string): Promise<void> {
|
|
347
|
+
const replayed = await repositoryReading(withoutSnapshots(store), repo)
|
|
348
|
+
for (const round of ['as left by the scenario', 'written at the final cursor']) {
|
|
349
|
+
const restored = await repositoryReading(store, repo)
|
|
350
|
+
const { view: restoredView, ...got } = restored
|
|
351
|
+
const { view: _replayedView, ...want } = replayed
|
|
352
|
+
if (canonical(got) !== canonical(want)) {
|
|
353
|
+
throw new Error(`snapshot-restored reading differs from full replay (${round})`)
|
|
354
|
+
}
|
|
355
|
+
await restoredView.persistSnapshots({ force: true })
|
|
356
|
+
}
|
|
357
|
+
// The operator routes derive from the same view: each response with
|
|
358
|
+
// snapshots present equals the response with every snapshot deleted.
|
|
359
|
+
const at = new Date('2026-01-01T00:00:00.000Z')
|
|
360
|
+
const routes = async (target: BuildStore) => {
|
|
361
|
+
const attempt = async (read: () => Promise<unknown>): Promise<unknown> => {
|
|
362
|
+
try {
|
|
363
|
+
return await read()
|
|
364
|
+
} catch (error) {
|
|
365
|
+
return { error: error instanceof Error ? error.message : String(error) }
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return {
|
|
369
|
+
dashboard: await attempt(() =>
|
|
370
|
+
getOperatorDashboard({ store: target, repo, clock: () => at }),
|
|
371
|
+
),
|
|
372
|
+
status: await attempt(() => getRepositoryStatus(target, repo)),
|
|
373
|
+
harvest: await attempt(() => getHarvestStatus(target, repo)),
|
|
374
|
+
lists: await Promise.all(
|
|
375
|
+
(['active', 'queued', 'all'] as const).map((scope) =>
|
|
376
|
+
attempt(() => listOperatorBuilds({ store: target, repo, scope, now: at })),
|
|
377
|
+
),
|
|
378
|
+
),
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
const replayedRoutes = canonical(await routes(withoutSnapshots(store)))
|
|
382
|
+
for (const round of ['as left', 'warmed by a first poll']) {
|
|
383
|
+
if (canonical(await routes(store)) !== replayedRoutes) {
|
|
384
|
+
throw new Error(`snapshot-backed operator routes differ from full replay (${round})`)
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
282
389
|
export async function makeHarness(opts: {
|
|
283
390
|
handlers: SkillHandlers
|
|
284
391
|
tickets?: Ticket[]
|
|
@@ -336,7 +443,11 @@ export async function makeHarness(opts: {
|
|
|
336
443
|
const clock = steppingClock()
|
|
337
444
|
const ids = sequentialIds()
|
|
338
445
|
const uuids = sequentialUuids()
|
|
339
|
-
|
|
446
|
+
// `AB_E2E_SNAPSHOTS=off` runs every scenario with every snapshot deleted, the
|
|
447
|
+
// other arm of the "snapshots change no observable output" proof.
|
|
448
|
+
const snapshotsOff = process.env.AB_E2E_SNAPSHOTS === 'off'
|
|
449
|
+
const memory = new MemoryBuildStore({ clock })
|
|
450
|
+
const store = snapshotsOff ? withoutSnapshots(memory) : memory
|
|
340
451
|
const adapted = await opts.storeAdapter?.(store)
|
|
341
452
|
const activeStore = adapted?.store ?? store
|
|
342
453
|
const config = parseConfig(configToml, 'e2e autobuild.toml')
|
|
@@ -612,6 +723,7 @@ export async function makeHarness(opts: {
|
|
|
612
723
|
},
|
|
613
724
|
events: (slug) => store.getEvents(slug),
|
|
614
725
|
cleanup: async () => {
|
|
726
|
+
if (!snapshotsOff) await assertSnapshotEquivalence(store, origin)
|
|
615
727
|
await activeStore.close()
|
|
616
728
|
if (activeStore !== store) await store.close()
|
|
617
729
|
await adapted?.cleanup?.()
|