@defrex/autobuild 0.10.1 → 0.11.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 +15 -1
- package/bin/ab-dispatch-kernel.ts +1 -1
- package/docs/configuration.md +32 -3
- package/docs/operator-api.md +17 -0
- package/docs/remote-ticket-protocol.md +5 -0
- package/package.json +3 -3
- package/packages/core/src/cli/binary.ts +3 -3
- package/packages/core/src/cli/dashboard/model.ts +4 -0
- package/packages/core/src/cli/dashboard/render.ts +37 -7
- package/packages/core/src/cli/dispatch-frontend.ts +16 -1
- package/packages/core/src/cli/dispatch-process.ts +25 -7
- package/packages/core/src/cli/dispatch.ts +64 -6
- package/packages/core/src/cli/dispatcher-liveness.ts +91 -0
- package/packages/core/src/cli/help.ts +8 -5
- package/packages/core/src/cli/repository-status.ts +45 -5
- package/packages/core/src/cli/watch.ts +159 -14
- package/packages/core/src/config/live.ts +1 -0
- package/packages/core/src/config/schema.ts +9 -2
- package/packages/core/src/events/globs.ts +8 -3
- package/packages/core/src/events/repository.ts +51 -2
- package/packages/core/src/integration/harness.ts +2 -1
- package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +18 -0
- package/packages/core/src/kernel/dispatch-status.ts +9 -1
- package/packages/core/src/kernel/dispatcher-liveness.ts +70 -0
- package/packages/core/src/kernel/reducer-registry.ts +4 -0
- package/packages/core/src/kernel/ticket-source-health.ts +30 -0
- package/packages/core/src/operator/query.ts +6 -3
- package/packages/core/src/ports/tickets/contract.ts +25 -0
- package/packages/core/src/ports/tickets/fake.ts +10 -8
- package/packages/core/src/ports/tickets/file.ts +1 -0
- package/packages/core/src/ports/tickets/linear.ts +241 -49
- package/packages/core/src/ports/tickets/remote-protocol.ts +2 -0
- package/packages/core/src/ports/types.ts +17 -0
- package/packages/core/src/processes/dispatcher.ts +277 -73
- package/packages/core/src/processes/repo-view.ts +2 -1
- package/packages/core/src/store/adapter.ts +1 -0
- package/packages/core/src/store/contract.ts +109 -0
- package/packages/core/src/store/digest.ts +14 -2
- package/packages/core/src/store/local/store.ts +18 -5
- package/packages/core/src/store/remote/protocol.ts +1 -0
- package/packages/core/src/store/repo-state-events.ts +49 -59
- package/packages/core/src/store/types.ts +5 -1
- package/skills/guide/SKILL.md +31 -0
- package/skills/operate/SKILL.md +39 -4
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { RepositoryEvent } from '../events/repository'
|
|
2
|
+
import type { RepositoryRecord } from '../store/types'
|
|
3
|
+
|
|
4
|
+
export function projectDispatcherLiveness(
|
|
5
|
+
record: RepositoryRecord | null,
|
|
6
|
+
events: RepositoryEvent[],
|
|
7
|
+
now: Date,
|
|
8
|
+
) {
|
|
9
|
+
const ordered = [...events].sort((a, b) => a.seq - b.seq)
|
|
10
|
+
const age = (at: string) => Math.max(0, now.getTime() - Date.parse(at))
|
|
11
|
+
const run = (id: string) => {
|
|
12
|
+
const start = ordered.find((e) => e.type === 'dispatcher.run-started' && e.payload.run === id)
|
|
13
|
+
const stop = ordered.findLast(
|
|
14
|
+
(e) => e.type === 'dispatcher.run-stopped' && e.payload.run === id,
|
|
15
|
+
)
|
|
16
|
+
const tick = ordered.findLast(
|
|
17
|
+
(e) =>
|
|
18
|
+
(e.type === 'dispatcher.tick-completed' || e.type === 'dispatcher.tick-failed') &&
|
|
19
|
+
e.payload.run === id,
|
|
20
|
+
)
|
|
21
|
+
const heartbeatAt =
|
|
22
|
+
record?.lease?.holder === id && start && record.heartbeatAt && record.heartbeatAt >= start.ts
|
|
23
|
+
? record.heartbeatAt
|
|
24
|
+
: null
|
|
25
|
+
return {
|
|
26
|
+
run: id,
|
|
27
|
+
pid: start?.type === 'dispatcher.run-started' ? start.payload.pid : null,
|
|
28
|
+
host: start?.type === 'dispatcher.run-started' ? (start.payload.host ?? null) : null,
|
|
29
|
+
startedAt: start?.ts ?? null,
|
|
30
|
+
heartbeatAt,
|
|
31
|
+
heartbeatAgeMs: heartbeatAt === null ? null : age(heartbeatAt),
|
|
32
|
+
leaseExpiresAt: record?.lease?.holder === id ? record.lease.expiresAt : null,
|
|
33
|
+
lastTickAt: tick?.ts ?? null,
|
|
34
|
+
lastTickAgeMs: tick ? age(tick.ts) : null,
|
|
35
|
+
lastTick:
|
|
36
|
+
tick?.type === 'dispatcher.tick-completed'
|
|
37
|
+
? { outcome: 'completed' as const, counters: tick.payload.counters }
|
|
38
|
+
: tick?.type === 'dispatcher.tick-failed'
|
|
39
|
+
? { outcome: 'failed' as const, error: tick.payload.error }
|
|
40
|
+
: null,
|
|
41
|
+
stoppedAt: stop?.ts ?? null,
|
|
42
|
+
stop: stop?.type === 'dispatcher.run-stopped' ? stop.payload : null,
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const latest = ordered.findLast((e) => e.type === 'dispatcher.run-started')
|
|
46
|
+
const latestStop = ordered.findLast((e) => e.type === 'dispatcher.run-stopped')
|
|
47
|
+
const owner = record?.lease ? run(record.lease.holder) : null
|
|
48
|
+
const held =
|
|
49
|
+
owner !== null && owner.stop === null && Date.parse(record!.lease!.expiresAt) > now.getTime()
|
|
50
|
+
return {
|
|
51
|
+
state: held
|
|
52
|
+
? ('held' as const)
|
|
53
|
+
: owner && !owner.stop
|
|
54
|
+
? ('lost' as const)
|
|
55
|
+
: latestStop
|
|
56
|
+
? ('stopped' as const)
|
|
57
|
+
: ('absent' as const),
|
|
58
|
+
holder: held ? owner : null,
|
|
59
|
+
expiredHolder: !held && owner && !owner.stop ? owner : null,
|
|
60
|
+
latestRun: latest?.type === 'dispatcher.run-started' ? run(latest.payload.run) : null,
|
|
61
|
+
latestStoppedRun:
|
|
62
|
+
latestStop?.type === 'dispatcher.run-stopped' ? run(latestStop.payload.run) : null,
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
export type DispatcherLiveness = ReturnType<typeof projectDispatcherLiveness>
|
|
66
|
+
export function formatDispatcherIdentity(
|
|
67
|
+
run: NonNullable<DispatcherLiveness['latestRun']>,
|
|
68
|
+
): string {
|
|
69
|
+
return `${run.run}; pid ${run.pid ?? 'unknown'}; host ${run.host ?? 'unknown'}; started ${run.startedAt ?? 'unknown'}; ${run.heartbeatAgeMs === null ? 'heartbeat unknown (no renewal attributable to this run)' : `heartbeat ${run.heartbeatAgeMs}ms ago`}`
|
|
70
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ticketSourceHealthReducer } from './ticket-source-health'
|
|
1
2
|
/**
|
|
2
3
|
* Every incremental reducer, with its published version and the whole-array
|
|
3
4
|
* functions it backs. `reducer-registry.test.ts` snapshots the versions and
|
|
@@ -57,6 +58,7 @@ export interface RegisteredReducer {
|
|
|
57
58
|
/** Parameterized reducers are registered with representative arguments; the
|
|
58
59
|
* accumulator shape does not depend on them. */
|
|
59
60
|
export const REDUCERS: Record<string, RegisteredReducer> = {
|
|
61
|
+
ticketSourceHealth: { reducer: ticketSourceHealthReducer, covers: ['reduceTicketSourceHealth'] },
|
|
60
62
|
build: { reducer: buildReducer, covers: ['reduceBuild'] },
|
|
61
63
|
harvest: { reducer: harvestReducer, covers: ['reduceHarvest'] },
|
|
62
64
|
dispatchSettings: { reducer: dispatchSettingsReducer, covers: ['reduceDispatchSettings'] },
|
|
@@ -134,6 +136,8 @@ export const REDUCERS: Record<string, RegisteredReducer> = {
|
|
|
134
136
|
/** Event-array functions that are deliberately not reducers, each with the
|
|
135
137
|
* concrete reason. */
|
|
136
138
|
export const EXCLUDED: Record<string, string> = {
|
|
139
|
+
projectDispatcherLiveness:
|
|
140
|
+
'combines mutable repository lease metadata and an explicit clock with correlated facts; cannot be restored from an event-only snapshot',
|
|
137
141
|
decideNext: 'a decision function over config; returns a Decision, not a state',
|
|
138
142
|
harvestCreationsDuringReadyScan:
|
|
139
143
|
'a derived query over two reduceHarvest states (before/after), which is already incremental',
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { RepositoryEvent, RepositoryEventPayload } from '../events/repository'
|
|
2
|
+
import { defineReducer } from './incremental'
|
|
3
|
+
|
|
4
|
+
export type TicketSourceDegradation = RepositoryEventPayload<'dispatcher.ticket-source-degraded'>
|
|
5
|
+
export const ticketSourceHealthReducer = defineReducer<
|
|
6
|
+
{ active?: TicketSourceDegradation },
|
|
7
|
+
RepositoryEvent,
|
|
8
|
+
TicketSourceDegradation | undefined
|
|
9
|
+
>({
|
|
10
|
+
version: 2,
|
|
11
|
+
initial: () => ({}),
|
|
12
|
+
fold(acc, events) {
|
|
13
|
+
for (const event of events) {
|
|
14
|
+
if (event.type === 'dispatcher.ticket-source-degraded') acc.active = event.payload
|
|
15
|
+
if (
|
|
16
|
+
event.type === 'dispatcher.ticket-source-dependency-limited' &&
|
|
17
|
+
acc.active?.cause === 'rate-limited' &&
|
|
18
|
+
acc.active.source === event.payload.source
|
|
19
|
+
)
|
|
20
|
+
acc.active = { ...acc.active, operation: 'dependency-check' }
|
|
21
|
+
if (event.type === 'dispatcher.ticket-source-recovered') delete acc.active
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
finish: (acc) => acc.active,
|
|
25
|
+
})
|
|
26
|
+
export function reduceTicketSourceHealth(
|
|
27
|
+
events: readonly RepositoryEvent[],
|
|
28
|
+
): TicketSourceDegradation | undefined {
|
|
29
|
+
return ticketSourceHealthReducer.reduce(events)
|
|
30
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readDispatcherLiveness } from '../cli/dispatcher-liveness'
|
|
1
2
|
import { composeBuildConfig } from '../config/live'
|
|
2
3
|
import type { PipelineSourceMeta } from '../config/pipeline-source'
|
|
3
4
|
import { configSchema, type Config } from '../config/schema'
|
|
@@ -255,10 +256,12 @@ export async function getOperatorBuild(opts: {
|
|
|
255
256
|
export async function getRepositoryStatus(
|
|
256
257
|
store: BuildStore,
|
|
257
258
|
repo: string,
|
|
259
|
+
now = new Date(),
|
|
258
260
|
): Promise<RepositoryStatus> {
|
|
259
|
-
return withOperatorView(store, repo, 'journal', async (view) =>
|
|
260
|
-
|
|
261
|
-
|
|
261
|
+
return withOperatorView(store, repo, 'journal', async (view) => {
|
|
262
|
+
const { dispatcher } = await readDispatcherLiveness(store, repo, now, view.recordedJournal())
|
|
263
|
+
return projectRepositoryStatus(repo, view.recordedJournal(), dispatcher)
|
|
264
|
+
})
|
|
262
265
|
}
|
|
263
266
|
|
|
264
267
|
export async function getHarvestStatus(
|
|
@@ -116,6 +116,29 @@ export function describeTicketSourceContract(
|
|
|
116
116
|
factory: TicketSourceContractFactory,
|
|
117
117
|
): void {
|
|
118
118
|
describe(`TicketSource contract: ${name}`, () => {
|
|
119
|
+
test('completion projections agree across create, get, listing, and transitions', async () => {
|
|
120
|
+
await withTicketSource(factory, async (harness) => {
|
|
121
|
+
const created = await createTracked(
|
|
122
|
+
harness,
|
|
123
|
+
{
|
|
124
|
+
title: contractTicketTitle('completion facts'),
|
|
125
|
+
body: CONTRACT_TICKET_BODY,
|
|
126
|
+
},
|
|
127
|
+
{ state: harness.states.ready },
|
|
128
|
+
)
|
|
129
|
+
expect(created.resolved).toBe(false)
|
|
130
|
+
for (const state of [harness.states.completed, harness.states.ready]) {
|
|
131
|
+
await harness.source.transition(created.ref.id, state)
|
|
132
|
+
const [dependency] = await harness.source.dependencyStates([created.ref.id])
|
|
133
|
+
expect((await harness.source.get(created.ref.id))?.resolved).toBe(dependency?.resolved)
|
|
134
|
+
const listing = await harness.source.listReady({ state })
|
|
135
|
+
expect(listing.tickets.find((ticket) => ticket.ref.id === created.ref.id)?.resolved).toBe(
|
|
136
|
+
dependency?.resolved,
|
|
137
|
+
)
|
|
138
|
+
expect(dependency?.resolved).toBe(state === harness.states.completed)
|
|
139
|
+
}
|
|
140
|
+
})
|
|
141
|
+
})
|
|
119
142
|
test('create/get round-trips common fields and honors an explicit state', async () => {
|
|
120
143
|
await withTicketSource(factory, async (harness) => {
|
|
121
144
|
const title = contractTicketTitle('round trip')
|
|
@@ -302,6 +325,8 @@ export function describeTicketSourceContract(
|
|
|
302
325
|
},
|
|
303
326
|
{ state: harness.states.completed },
|
|
304
327
|
)
|
|
328
|
+
expect(completed.resolved).toBe(true)
|
|
329
|
+
expect(unresolved.resolved).toBe(false)
|
|
305
330
|
const dependent = await createTracked(
|
|
306
331
|
harness,
|
|
307
332
|
{
|
|
@@ -15,13 +15,14 @@ import type {
|
|
|
15
15
|
} from '../types'
|
|
16
16
|
import { validateTicketUpdate } from './update'
|
|
17
17
|
|
|
18
|
-
function cloneTicket(ticket: Ticket): Ticket {
|
|
18
|
+
function cloneTicket(ticket: Ticket, doneState: string): Ticket {
|
|
19
19
|
return {
|
|
20
20
|
ref: { ...ticket.ref },
|
|
21
21
|
...(ticket.creationKey !== undefined ? { creationKey: ticket.creationKey } : {}),
|
|
22
22
|
title: ticket.title,
|
|
23
23
|
body: ticket.body,
|
|
24
24
|
state: ticket.state,
|
|
25
|
+
resolved: ticket.state === doneState,
|
|
25
26
|
labels: [...ticket.labels],
|
|
26
27
|
...(ticket.blockedBy !== undefined ? { blockedBy: [...ticket.blockedBy] } : {}),
|
|
27
28
|
}
|
|
@@ -61,14 +62,14 @@ export class FakeTicketSource implements TicketSource {
|
|
|
61
62
|
doneState?: string
|
|
62
63
|
} = {},
|
|
63
64
|
) {
|
|
65
|
+
this.doneState = opts.doneState ?? 'Done'
|
|
64
66
|
for (const ticket of seed) {
|
|
65
|
-
this.tickets.set(ticket.ref.id, cloneTicket(ticket))
|
|
67
|
+
this.tickets.set(ticket.ref.id, cloneTicket(ticket, this.doneState))
|
|
66
68
|
if (ticket.creationKey !== undefined) {
|
|
67
69
|
this.idempotency.set(ticket.creationKey, ticket.ref.id)
|
|
68
70
|
}
|
|
69
71
|
}
|
|
70
72
|
this.createState = opts.createState ?? 'Triage'
|
|
71
|
-
this.doneState = opts.doneState ?? 'Done'
|
|
72
73
|
}
|
|
73
74
|
|
|
74
75
|
/** Seed a ticket after construction — lets a test make one become Ready
|
|
@@ -76,7 +77,7 @@ export class FakeTicketSource implements TicketSource {
|
|
|
76
77
|
* after its initial selection" (§12) gets a real oracle rather than a
|
|
77
78
|
* vacuous one. */
|
|
78
79
|
add(ticket: Ticket): void {
|
|
79
|
-
this.tickets.set(ticket.ref.id, cloneTicket(ticket))
|
|
80
|
+
this.tickets.set(ticket.ref.id, cloneTicket(ticket, this.doneState))
|
|
80
81
|
}
|
|
81
82
|
|
|
82
83
|
async listReady(criteria: {
|
|
@@ -92,14 +93,14 @@ export class FakeTicketSource implements TicketSource {
|
|
|
92
93
|
(criteria.state === undefined || ticket.state === criteria.state) &&
|
|
93
94
|
labels.every((label) => ticket.labels.includes(label)),
|
|
94
95
|
)
|
|
95
|
-
.map(cloneTicket),
|
|
96
|
+
.map((ticket) => cloneTicket(ticket, this.doneState)),
|
|
96
97
|
diagnostics: [],
|
|
97
98
|
}
|
|
98
99
|
}
|
|
99
100
|
|
|
100
101
|
async get(id: string): Promise<Ticket | null> {
|
|
101
102
|
const ticket = this.tickets.get(id)
|
|
102
|
-
return ticket ? cloneTicket(ticket) : null
|
|
103
|
+
return ticket ? cloneTicket(ticket, this.doneState) : null
|
|
103
104
|
}
|
|
104
105
|
|
|
105
106
|
/** Claim-before-launch: true once per nonterminal lifecycle stay. A later
|
|
@@ -128,7 +129,7 @@ export class FakeTicketSource implements TicketSource {
|
|
|
128
129
|
async create(draft: TicketDraft, opts: TicketCreateOptions = {}): Promise<Ticket> {
|
|
129
130
|
if (opts.idempotencyKey !== undefined) {
|
|
130
131
|
const adopted = this.idempotency.get(opts.idempotencyKey)
|
|
131
|
-
if (adopted !== undefined) return cloneTicket(this.require(adopted, 'create'))
|
|
132
|
+
if (adopted !== undefined) return cloneTicket(this.require(adopted, 'create'), this.doneState)
|
|
132
133
|
}
|
|
133
134
|
const id = `fake-${this.nextId++}`
|
|
134
135
|
const ticket: Ticket = {
|
|
@@ -137,6 +138,7 @@ export class FakeTicketSource implements TicketSource {
|
|
|
137
138
|
title: draft.title,
|
|
138
139
|
body: draft.body,
|
|
139
140
|
state: opts.state ?? this.createState,
|
|
141
|
+
resolved: (opts.state ?? this.createState) === this.doneState,
|
|
140
142
|
labels: [...(draft.labels ?? [])],
|
|
141
143
|
// Recorded verbatim, never discarded — the same contract the real
|
|
142
144
|
// adapters owe (§13).
|
|
@@ -148,7 +150,7 @@ export class FakeTicketSource implements TicketSource {
|
|
|
148
150
|
if (opts.idempotencyKey !== undefined) {
|
|
149
151
|
this.idempotency.set(opts.idempotencyKey, id)
|
|
150
152
|
}
|
|
151
|
-
return cloneTicket(ticket)
|
|
153
|
+
return cloneTicket(ticket, this.doneState)
|
|
152
154
|
}
|
|
153
155
|
|
|
154
156
|
async update(id: string, patch: TicketUpdate): Promise<void> {
|
|
@@ -663,6 +663,7 @@ export class FileTicketSource implements TicketSource {
|
|
|
663
663
|
title: front.title,
|
|
664
664
|
body,
|
|
665
665
|
state,
|
|
666
|
+
resolved: state === this.doneState,
|
|
666
667
|
labels: [...front.labels],
|
|
667
668
|
...(front.blockedBy !== undefined && front.blockedBy.length > 0
|
|
668
669
|
? { blockedBy: [...front.blockedBy] }
|