@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.
Files changed (82) hide show
  1. package/README.md +1 -1
  2. package/SPEC.md +10 -0
  3. package/docs/architecture.md +109 -0
  4. package/docs/configuration.md +24 -3
  5. package/docs/remote-store-protocol.md +35 -0
  6. package/docs/website.md +4 -0
  7. package/package.json +6 -4
  8. package/packages/core/src/cli/dashboard/facts.ts +215 -0
  9. package/packages/core/src/cli/dashboard/frame-parse.ts +223 -0
  10. package/packages/core/src/cli/dashboard/model.ts +85 -147
  11. package/packages/core/src/cli/dashboard/poll.ts +7 -2
  12. package/packages/core/src/cli/dispatch-process.ts +1 -13
  13. package/packages/core/src/cli/dispatch.ts +80 -37
  14. package/packages/core/src/cli/help.ts +15 -1
  15. package/packages/core/src/cli/init-validation.ts +18 -0
  16. package/packages/core/src/cli/installation.ts +50 -8
  17. package/packages/core/src/cli/self-update.ts +77 -2
  18. package/packages/core/src/cli/status.ts +1 -1
  19. package/packages/core/src/cli/ticket.ts +146 -1
  20. package/packages/core/src/cli/wait.ts +10 -2
  21. package/packages/core/src/cli/watch.ts +10 -2
  22. package/packages/core/src/config/live.ts +1 -0
  23. package/packages/core/src/config/pipeline-source.ts +17 -5
  24. package/packages/core/src/config/schema.ts +21 -0
  25. package/packages/core/src/integration/harness.ts +113 -1
  26. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +1369 -0
  27. package/packages/core/src/kernel/auto-merge-default.ts +43 -9
  28. package/packages/core/src/kernel/auto-merge.ts +99 -22
  29. package/packages/core/src/kernel/dispatch-settings.ts +42 -17
  30. package/packages/core/src/kernel/dispatch-status.ts +23 -0
  31. package/packages/core/src/kernel/engine.ts +9 -438
  32. package/packages/core/src/kernel/generators/build-log.ts +259 -0
  33. package/packages/core/src/kernel/generators/repository-journals.ts +206 -0
  34. package/packages/core/src/kernel/generators/session-log.ts +72 -0
  35. package/packages/core/src/kernel/harvest.ts +62 -18
  36. package/packages/core/src/kernel/incremental-contract.ts +75 -0
  37. package/packages/core/src/kernel/incremental-fixtures.ts +211 -0
  38. package/packages/core/src/kernel/incremental.ts +53 -0
  39. package/packages/core/src/kernel/log-index.ts +620 -0
  40. package/packages/core/src/kernel/pr-attachments.ts +164 -46
  41. package/packages/core/src/kernel/reducer-registry.ts +164 -0
  42. package/packages/core/src/kernel/reducer.ts +216 -74
  43. package/packages/core/src/operator/query.ts +68 -28
  44. package/packages/core/src/operator/read-view.ts +98 -0
  45. package/packages/core/src/ports/tickets/fake.ts +5 -1
  46. package/packages/core/src/ports/tickets/file.ts +169 -48
  47. package/packages/core/src/ports/tickets/linear.ts +33 -1
  48. package/packages/core/src/ports/tickets/operations.ts +1 -1
  49. package/packages/core/src/ports/tickets/remote.ts +8 -1
  50. package/packages/core/src/ports/types.ts +32 -1
  51. package/packages/core/src/processes/build-execution-state.ts +31 -15
  52. package/packages/core/src/processes/build-runner.ts +184 -90
  53. package/packages/core/src/processes/dispatcher-selectors.ts +123 -0
  54. package/packages/core/src/processes/dispatcher.ts +270 -176
  55. package/packages/core/src/processes/execution-settlement.ts +72 -39
  56. package/packages/core/src/processes/harvest-execution-state.ts +41 -29
  57. package/packages/core/src/processes/infrastructure-failure-budget.ts +31 -18
  58. package/packages/core/src/processes/orchestrator-tick.ts +44 -3
  59. package/packages/core/src/processes/publication-state.ts +164 -52
  60. package/packages/core/src/processes/repo-view.ts +1436 -0
  61. package/packages/core/src/processes/sandbox-state.ts +26 -2
  62. package/packages/core/src/processes/tick-state.ts +33 -0
  63. package/packages/core/src/store/adapter.ts +10 -1
  64. package/packages/core/src/store/build-scope.ts +26 -0
  65. package/packages/core/src/store/contract.ts +337 -0
  66. package/packages/core/src/store/digest.ts +71 -18
  67. package/packages/core/src/store/local/schema.ts +17 -0
  68. package/packages/core/src/store/local/store.ts +111 -4
  69. package/packages/core/src/store/memory.ts +68 -1
  70. package/packages/core/src/store/phase-session.ts +23 -0
  71. package/packages/core/src/store/remote/client.ts +50 -1
  72. package/packages/core/src/store/remote/index.ts +2 -0
  73. package/packages/core/src/store/remote/protocol.ts +15 -0
  74. package/packages/core/src/store/repo-state-events.ts +72 -7
  75. package/packages/core/src/store/session-handle.ts +30 -0
  76. package/packages/core/src/store/session-reducer.ts +52 -11
  77. package/packages/core/src/store/snapshots.ts +88 -0
  78. package/packages/core/src/store/ticket-assets.ts +41 -8
  79. package/packages/core/src/store/types.ts +38 -0
  80. package/skills/guide/SKILL.md +26 -7
  81. package/skills/guide/references/remote-store-protocol.md +35 -0
  82. package/skills/tickets/SKILL.md +57 -5
@@ -51,6 +51,7 @@ import { KERNEL, agentActor, type Actor } from '../events/envelope'
51
51
  import { isRemoteWorkspace } from '../events/workspace-remote'
52
52
  import type { IdSource } from '../ids'
53
53
  import { decideNext, type Decision } from '../kernel/engine'
54
+ import { defineReducer } from '../kernel/incremental'
54
55
  import { PHASE_SPECS } from '../kernel/phases'
55
56
  import { reduceBuild, type BuildState } from '../kernel/reducer'
56
57
  import { evaluateVerifyApplicability } from '../kernel/verify-applicability'
@@ -314,22 +315,155 @@ function providerAttemptSummary(attempts: readonly ProviderAttempt[] | undefined
314
315
  )
315
316
  }
316
317
 
318
+ export interface PhaseFailureTally {
319
+ count: number
320
+ lastError?: string
321
+ lastWillRetry?: boolean
322
+ lastProviderAttempts?: readonly ProviderAttempt[]
323
+ }
324
+
325
+ export const PHASE_FAILURES_REDUCER_VERSION = 1
326
+
327
+ /**
328
+ * phase.failed tally for one phase+round — D5's retry-guard input. A factory
329
+ * because phase and round are parameters of the projection.
330
+ *
331
+ * An answered escalation re-arms this budget only when its semantic class
332
+ * acknowledges phase-runner failures (§15.6-B). Runner-failure causes reset
333
+ * their matching round, the phase-level verify cause resets all rounds, and
334
+ * unrelated policy causes reset nothing. Cause-less historical and
335
+ * non-policy raises retain the legacy round-shaped behavior. Failures after
336
+ * an applicable answer count again, so a still-failing phase re-escalates on
337
+ * new evidence instead of deadlocking before its recovery session starts.
338
+ */
339
+ export function phaseFailuresReducer(phase: Phase, round: number) {
340
+ return defineReducer<
341
+ {
342
+ /** id → the raise's reset semantics — answers are matched by id (§15.3). */
343
+ raised: Record<string, PhaseFailureResetEscalation>
344
+ count: number
345
+ lastError?: string
346
+ lastWillRetry?: boolean
347
+ lastProviderAttempts?: readonly ProviderAttempt[]
348
+ },
349
+ AbEvent,
350
+ PhaseFailureTally
351
+ >({
352
+ version: PHASE_FAILURES_REDUCER_VERSION,
353
+ initial: () => ({ raised: {}, count: 0 }),
354
+ fold(acc, events) {
355
+ for (const event of events) {
356
+ switch (event.type) {
357
+ case 'escalation.raised':
358
+ acc.raised[event.payload.id] = {
359
+ phase: event.payload.phase,
360
+ source: event.payload.source,
361
+ ...(event.payload.policyCause !== undefined
362
+ ? { policyCause: event.payload.policyCause }
363
+ : {}),
364
+ ...(event.payload.round !== undefined ? { round: event.payload.round } : {}),
365
+ }
366
+ break
367
+ case 'escalation.answered': {
368
+ const raise = acc.raised[event.payload.id]
369
+ if (raise !== undefined && resetsPhaseFailureBudget(raise, phase, round)) {
370
+ acc.count = 0
371
+ delete acc.lastError
372
+ delete acc.lastWillRetry
373
+ delete acc.lastProviderAttempts
374
+ }
375
+ break
376
+ }
377
+ case 'phase.failed':
378
+ if (event.payload.phase === phase && event.payload.round === round) {
379
+ acc.count += 1
380
+ acc.lastError = event.payload.error
381
+ acc.lastWillRetry = event.payload.willRetry
382
+ acc.lastProviderAttempts = event.payload.providerAttempts
383
+ }
384
+ break
385
+ default:
386
+ break
387
+ }
388
+ }
389
+ },
390
+ finish: (acc) => ({
391
+ count: acc.count,
392
+ ...(acc.lastError !== undefined ? { lastError: acc.lastError } : {}),
393
+ ...(acc.lastWillRetry !== undefined ? { lastWillRetry: acc.lastWillRetry } : {}),
394
+ ...(acc.lastProviderAttempts !== undefined
395
+ ? { lastProviderAttempts: acc.lastProviderAttempts }
396
+ : {}),
397
+ }),
398
+ })
399
+ }
400
+
401
+ export const SETUP_STREAK_REDUCER_VERSION = 1
402
+
403
+ /** Consecutive durable setup failures since the latest successful attachment
404
+ * or explicit human answer. The accumulator keeps the boundary seq and the
405
+ * failure seqs, so it is exact for any array order. */
406
+ export const setupStreakReducer = defineReducer<
407
+ { escalations: string[]; boundary: number; failures: number[] },
408
+ AbEvent,
409
+ number
410
+ >({
411
+ version: SETUP_STREAK_REDUCER_VERSION,
412
+ initial: () => ({ escalations: [], boundary: 0, failures: [] }),
413
+ fold(acc, events) {
414
+ for (const event of events) {
415
+ if (event.type === 'runner.attached') acc.boundary = event.seq
416
+ else if (
417
+ event.type === 'escalation.raised' &&
418
+ event.payload.phase === 'setup' &&
419
+ event.payload.source === 'policy'
420
+ ) {
421
+ if (!acc.escalations.includes(event.payload.id)) acc.escalations.push(event.payload.id)
422
+ } else if (
423
+ event.type === 'escalation.answered' &&
424
+ acc.escalations.includes(event.payload.id)
425
+ ) {
426
+ acc.boundary = event.seq
427
+ } else if (event.type === 'runner.setup-failed') {
428
+ acc.failures.push(event.seq)
429
+ }
430
+ }
431
+ },
432
+ finish: (acc) => acc.failures.filter((seq) => seq > acc.boundary).length,
433
+ })
434
+
435
+ export const PUBLISHED_BRANCH_HEAD_REDUCER_VERSION = 1
436
+
437
+ /** Latest event-checkpointed branch head that kernel plumbing published;
438
+ * undefined while the build has none. */
439
+ export const publishedBranchHeadReducer = defineReducer<
440
+ { head?: string },
441
+ AbEvent,
442
+ string | undefined
443
+ >({
444
+ version: PUBLISHED_BRANCH_HEAD_REDUCER_VERSION,
445
+ initial: () => ({}),
446
+ fold(acc, events) {
447
+ for (const event of events) {
448
+ if (event.type === 'implement.completed') {
449
+ acc.head = event.payload.commits.head
450
+ } else if (event.type === 'reconcile.completed') {
451
+ acc.head = event.payload.mergeCommit
452
+ } else if (
453
+ event.type === 'finalize.step-completed' &&
454
+ event.payload.ok &&
455
+ event.payload.headSha !== undefined
456
+ ) {
457
+ acc.head = event.payload.headSha
458
+ }
459
+ }
460
+ },
461
+ finish: (acc) => acc.head,
462
+ })
463
+
317
464
  /** Latest event-checkpointed branch head that kernel plumbing published. */
318
465
  export function selectPublishedBranchHead(events: readonly AbEvent[]): string {
319
- let head: string | undefined
320
- for (const event of events) {
321
- if (event.type === 'implement.completed') {
322
- head = event.payload.commits.head
323
- } else if (event.type === 'reconcile.completed') {
324
- head = event.payload.mergeCommit
325
- } else if (
326
- event.type === 'finalize.step-completed' &&
327
- event.payload.ok &&
328
- event.payload.headSha !== undefined
329
- ) {
330
- head = event.payload.headSha
331
- }
332
- }
466
+ const head = publishedBranchHeadReducer.reduce(events)
333
467
  if (head === undefined) {
334
468
  throw new Error(
335
469
  'finalize publication requires a prior implement.completed, reconcile.completed, ' +
@@ -339,6 +473,37 @@ export function selectPublishedBranchHead(events: readonly AbEvent[]): string {
339
473
  return head
340
474
  }
341
475
 
476
+ export const VERIFY_DIFF_BASE_REDUCER_VERSION = 1
477
+
478
+ /**
479
+ * The durable tree against which a conditional verifier evaluates the
480
+ * branch's current HEAD, or undefined until a `workspace.provisioned` exists.
481
+ * The accumulator keeps the first provisioned base and the latest promoted
482
+ * reconcile base separately, so the result is exact for any array order (a
483
+ * promotion that precedes the provisioned fact in the array still wins).
484
+ */
485
+ export const verifyDiffBaseReducer = defineReducer<
486
+ { provisioned?: string; promoted?: string; pendingReconcileBase?: string },
487
+ AbEvent,
488
+ string | undefined
489
+ >({
490
+ version: VERIFY_DIFF_BASE_REDUCER_VERSION,
491
+ initial: () => ({}),
492
+ fold(acc, events) {
493
+ for (const event of events) {
494
+ if (event.type === 'workspace.provisioned') {
495
+ if (acc.provisioned === undefined) acc.provisioned = event.payload.base.sha
496
+ } else if (event.type === 'reconcile.started') {
497
+ acc.pendingReconcileBase = event.payload.baseSha
498
+ } else if (event.type === 'reconcile.completed') {
499
+ if (acc.pendingReconcileBase !== undefined) acc.promoted = acc.pendingReconcileBase
500
+ delete acc.pendingReconcileBase
501
+ }
502
+ }
503
+ },
504
+ finish: (acc) => (acc.provisioned === undefined ? undefined : (acc.promoted ?? acc.provisioned)),
505
+ })
506
+
342
507
  /**
343
508
  * Select the durable tree against which a conditional verifier evaluates the
344
509
  * branch's current HEAD. The initial branch-cut SHA is authoritative until a
@@ -346,24 +511,13 @@ export function selectPublishedBranchHead(events: readonly AbEvent[]): string {
346
511
  * refreshed base SHA. A dangling start is intentionally ignored.
347
512
  */
348
513
  export function selectVerifyDiffBase(events: readonly AbEvent[]): string {
349
- const provisioned = events.find((event) => event.type === 'workspace.provisioned')
350
- if (provisioned === undefined || provisioned.type !== 'workspace.provisioned') {
514
+ const base = verifyDiffBaseReducer.reduce(events)
515
+ if (base === undefined) {
351
516
  throw new Error(
352
517
  'conditional verify requires a workspace.provisioned base SHA, but this build has none',
353
518
  )
354
519
  }
355
-
356
- let baseSha = provisioned.payload.base.sha
357
- let pendingReconcileBase: string | undefined
358
- for (const event of events) {
359
- if (event.type === 'reconcile.started') {
360
- pendingReconcileBase = event.payload.baseSha
361
- } else if (event.type === 'reconcile.completed') {
362
- if (pendingReconcileBase !== undefined) baseSha = pendingReconcileBase
363
- pendingReconcileBase = undefined
364
- }
365
- }
366
- return baseSha
520
+ return base
367
521
  }
368
522
 
369
523
  /** Parse `git diff --name-only -z`; malformed output fails closed. */
@@ -483,22 +637,7 @@ export class BuildRunner {
483
637
  * or explicit human answer. The answer re-arms the budget but deliberately
484
638
  * does not erase the current diagnostic while recovery is still running. */
485
639
  private setupStreak(events: readonly AbEvent[]): number {
486
- const setupEscalations = new Set<string>()
487
- let boundary = 0
488
- for (const event of events) {
489
- if (event.type === 'runner.attached') boundary = event.seq
490
- else if (
491
- event.type === 'escalation.raised' &&
492
- event.payload.phase === 'setup' &&
493
- event.payload.source === 'policy'
494
- ) {
495
- setupEscalations.add(event.payload.id)
496
- } else if (event.type === 'escalation.answered' && setupEscalations.has(event.payload.id)) {
497
- boundary = event.seq
498
- }
499
- }
500
- return events.filter((event) => event.type === 'runner.setup-failed' && event.seq > boundary)
501
- .length
640
+ return setupStreakReducer.reduce(events)
502
641
  }
503
642
 
504
643
  private setupQuestion(failure: EventPayload<'runner.setup-failed'>): string {
@@ -2452,52 +2591,7 @@ export class BuildRunner {
2452
2591
  lastWillRetry?: boolean
2453
2592
  lastProviderAttempts?: readonly ProviderAttempt[]
2454
2593
  } {
2455
- /** id → the raise's reset semantics — answers are matched by id (§15.3). */
2456
- const raised = new Map<string, PhaseFailureResetEscalation>()
2457
- let count = 0
2458
- let lastError: string | undefined
2459
- let lastWillRetry: boolean | undefined
2460
- let lastProviderAttempts: readonly ProviderAttempt[] | undefined
2461
- for (const event of events) {
2462
- switch (event.type) {
2463
- case 'escalation.raised':
2464
- raised.set(event.payload.id, {
2465
- phase: event.payload.phase,
2466
- source: event.payload.source,
2467
- ...(event.payload.policyCause !== undefined
2468
- ? { policyCause: event.payload.policyCause }
2469
- : {}),
2470
- ...(event.payload.round !== undefined ? { round: event.payload.round } : {}),
2471
- })
2472
- break
2473
- case 'escalation.answered': {
2474
- const raise = raised.get(event.payload.id)
2475
- if (raise !== undefined && resetsPhaseFailureBudget(raise, phase, round)) {
2476
- count = 0
2477
- lastError = undefined
2478
- lastWillRetry = undefined
2479
- lastProviderAttempts = undefined
2480
- }
2481
- break
2482
- }
2483
- case 'phase.failed':
2484
- if (event.payload.phase === phase && event.payload.round === round) {
2485
- count += 1
2486
- lastError = event.payload.error
2487
- lastWillRetry = event.payload.willRetry
2488
- lastProviderAttempts = event.payload.providerAttempts
2489
- }
2490
- break
2491
- default:
2492
- break
2493
- }
2494
- }
2495
- return {
2496
- count,
2497
- ...(lastError !== undefined ? { lastError } : {}),
2498
- ...(lastWillRetry !== undefined ? { lastWillRetry } : {}),
2499
- ...(lastProviderAttempts !== undefined ? { lastProviderAttempts } : {}),
2500
- }
2594
+ return phaseFailuresReducer(phase, round).reduce(events)
2501
2595
  }
2502
2596
 
2503
2597
  private async failPhase(
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Pure event-log projections the dispatcher and janitor read, extracted from
3
+ * `dispatcher.ts` so each is an incremental reducer (see
4
+ * `kernel/incremental.ts`). The dispatcher's private helpers delegate here.
5
+ */
6
+ import type { AbEvent } from '../events/catalog'
7
+ import type { EventPayload } from '../events/payloads'
8
+ import { defineReducer } from '../kernel/incremental'
9
+
10
+ export type OpenBuildWorkspace = EventPayload<'workspace.provisioned'>
11
+
12
+ export const OPEN_BUILD_WORKSPACE_REDUCER_VERSION = 1
13
+
14
+ /** Latest `workspace.provisioned` not followed by a `workspace.released` —
15
+ * the reducer deliberately ignores workspace events (liveness is the
16
+ * dispatcher's concern), so the janitor scans the raw log. */
17
+ export const openBuildWorkspaceReducer = defineReducer<
18
+ { open: OpenBuildWorkspace | null },
19
+ AbEvent,
20
+ OpenBuildWorkspace | null
21
+ >({
22
+ version: OPEN_BUILD_WORKSPACE_REDUCER_VERSION,
23
+ initial: () => ({ open: null }),
24
+ fold(acc, events) {
25
+ for (const event of events) {
26
+ if (event.type === 'workspace.provisioned') acc.open = event.payload
27
+ else if (event.type === 'workspace.released') acc.open = null
28
+ }
29
+ },
30
+ finish: (acc) => (acc.open === null ? null : { ...acc.open }),
31
+ })
32
+
33
+ export function openBuildWorkspace(events: readonly AbEvent[]): OpenBuildWorkspace | null {
34
+ return openBuildWorkspaceReducer.reduce(events)
35
+ }
36
+
37
+ export const BASE_BRANCH_REDUCER_VERSION = 1
38
+
39
+ /** The base branch recorded on the FIRST `build.created` in array order, or
40
+ * undefined for logs missing one (callers fall back to the repo config). */
41
+ export const baseBranchReducer = defineReducer<
42
+ { baseBranch?: string },
43
+ AbEvent,
44
+ string | undefined
45
+ >({
46
+ version: BASE_BRANCH_REDUCER_VERSION,
47
+ initial: () => ({}),
48
+ fold(acc, events) {
49
+ if (acc.baseBranch !== undefined) return
50
+ for (const event of events) {
51
+ if (event.type === 'build.created') {
52
+ acc.baseBranch = event.payload.baseBranch
53
+ return
54
+ }
55
+ }
56
+ },
57
+ finish: (acc) => acc.baseBranch,
58
+ })
59
+
60
+ export interface ProvisionMarker {
61
+ provider: string
62
+ branch: string
63
+ generation: number
64
+ ts: string
65
+ seq: number
66
+ }
67
+
68
+ export const PROVISION_MARKER_REDUCER_VERSION = 1
69
+
70
+ /** The latest `workspace.provision-started` not yet followed by a
71
+ * provisioned/released fact — an open provisioning marker. */
72
+ export const provisionMarkerReducer = defineReducer<
73
+ { marker: ProvisionMarker | null },
74
+ AbEvent,
75
+ ProvisionMarker | undefined
76
+ >({
77
+ version: PROVISION_MARKER_REDUCER_VERSION,
78
+ initial: () => ({ marker: null }),
79
+ fold(acc, events) {
80
+ for (const event of events) {
81
+ if (event.type === 'workspace.provision-started') {
82
+ acc.marker = { ...event.payload, ts: event.ts, seq: event.seq }
83
+ } else if (
84
+ acc.marker !== null &&
85
+ (event.type === 'workspace.provisioned' || event.type === 'workspace.released')
86
+ ) {
87
+ acc.marker = null
88
+ }
89
+ }
90
+ },
91
+ finish: (acc) => (acc.marker === null ? undefined : { ...acc.marker }),
92
+ })
93
+
94
+ export const RECOVERY_CHECKPOINT_REDUCER_VERSION = 1
95
+
96
+ /** The commit a recovered workspace is re-cut from: the latest settled
97
+ * publication head, else the first provisioned base. */
98
+ export const recoveryCheckpointReducer = defineReducer<
99
+ { settled?: string; original?: string },
100
+ AbEvent,
101
+ string | undefined
102
+ >({
103
+ version: RECOVERY_CHECKPOINT_REDUCER_VERSION,
104
+ initial: () => ({}),
105
+ fold(acc, events) {
106
+ for (const event of events) {
107
+ if (event.type === 'workspace.provisioned' && acc.original === undefined) {
108
+ acc.original = event.payload.base.sha
109
+ } else if (event.type === 'implement.completed') {
110
+ acc.settled = event.payload.commits.head
111
+ } else if (event.type === 'reconcile.completed') {
112
+ acc.settled = event.payload.mergeCommit
113
+ } else if (
114
+ event.type === 'finalize.step-completed' &&
115
+ event.payload.ok &&
116
+ event.payload.headSha !== undefined
117
+ ) {
118
+ acc.settled = event.payload.headSha
119
+ }
120
+ }
121
+ },
122
+ finish: (acc) => acc.settled ?? acc.original,
123
+ })