@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.
Files changed (45) hide show
  1. package/README.md +1 -1
  2. package/SPEC.md +15 -1
  3. package/bin/ab-dispatch-kernel.ts +1 -1
  4. package/docs/configuration.md +32 -3
  5. package/docs/operator-api.md +17 -0
  6. package/docs/remote-ticket-protocol.md +5 -0
  7. package/package.json +3 -3
  8. package/packages/core/src/cli/binary.ts +3 -3
  9. package/packages/core/src/cli/dashboard/model.ts +4 -0
  10. package/packages/core/src/cli/dashboard/render.ts +37 -7
  11. package/packages/core/src/cli/dispatch-frontend.ts +16 -1
  12. package/packages/core/src/cli/dispatch-process.ts +25 -7
  13. package/packages/core/src/cli/dispatch.ts +64 -6
  14. package/packages/core/src/cli/dispatcher-liveness.ts +91 -0
  15. package/packages/core/src/cli/help.ts +8 -5
  16. package/packages/core/src/cli/repository-status.ts +45 -5
  17. package/packages/core/src/cli/watch.ts +159 -14
  18. package/packages/core/src/config/live.ts +1 -0
  19. package/packages/core/src/config/schema.ts +9 -2
  20. package/packages/core/src/events/globs.ts +8 -3
  21. package/packages/core/src/events/repository.ts +51 -2
  22. package/packages/core/src/integration/harness.ts +2 -1
  23. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +18 -0
  24. package/packages/core/src/kernel/dispatch-status.ts +9 -1
  25. package/packages/core/src/kernel/dispatcher-liveness.ts +70 -0
  26. package/packages/core/src/kernel/reducer-registry.ts +4 -0
  27. package/packages/core/src/kernel/ticket-source-health.ts +30 -0
  28. package/packages/core/src/operator/query.ts +6 -3
  29. package/packages/core/src/ports/tickets/contract.ts +25 -0
  30. package/packages/core/src/ports/tickets/fake.ts +10 -8
  31. package/packages/core/src/ports/tickets/file.ts +1 -0
  32. package/packages/core/src/ports/tickets/linear.ts +241 -49
  33. package/packages/core/src/ports/tickets/remote-protocol.ts +2 -0
  34. package/packages/core/src/ports/types.ts +17 -0
  35. package/packages/core/src/processes/dispatcher.ts +277 -73
  36. package/packages/core/src/processes/repo-view.ts +2 -1
  37. package/packages/core/src/store/adapter.ts +1 -0
  38. package/packages/core/src/store/contract.ts +109 -0
  39. package/packages/core/src/store/digest.ts +14 -2
  40. package/packages/core/src/store/local/store.ts +18 -5
  41. package/packages/core/src/store/remote/protocol.ts +1 -0
  42. package/packages/core/src/store/repo-state-events.ts +49 -59
  43. package/packages/core/src/store/types.ts +5 -1
  44. package/skills/guide/SKILL.md +31 -0
  45. 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
- projectRepositoryStatus(repo, view.recordedJournal()),
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] }