@defrex/autobuild 0.10.1 → 0.12.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 (120) hide show
  1. package/README.md +3 -1
  2. package/SPEC.md +237 -23
  3. package/bin/ab-dispatch-kernel.ts +1 -1
  4. package/docs/architecture.md +66 -4
  5. package/docs/configuration.md +137 -19
  6. package/docs/hosted-dispatcher.md +17 -2
  7. package/docs/hosted-service.md +214 -0
  8. package/docs/mcp.md +8 -2
  9. package/docs/operator-api.md +107 -7
  10. package/docs/remote-store-protocol.md +189 -144
  11. package/docs/remote-ticket-protocol.md +111 -18
  12. package/docs/setup.md +173 -16
  13. package/docs/ticket-dependencies.md +1 -1
  14. package/package.json +7 -6
  15. package/packages/core/src/cli/attention.ts +209 -0
  16. package/packages/core/src/cli/binary.ts +3 -3
  17. package/packages/core/src/cli/configured-store.ts +17 -0
  18. package/packages/core/src/cli/connect.ts +261 -0
  19. package/packages/core/src/cli/dashboard/model.ts +8 -5
  20. package/packages/core/src/cli/dashboard/render.ts +37 -7
  21. package/packages/core/src/cli/dispatch-frontend.ts +16 -1
  22. package/packages/core/src/cli/dispatch-process.ts +25 -7
  23. package/packages/core/src/cli/dispatch.ts +247 -47
  24. package/packages/core/src/cli/dispatcher-liveness.ts +91 -0
  25. package/packages/core/src/cli/help.ts +45 -13
  26. package/packages/core/src/cli/init-validation.ts +27 -2
  27. package/packages/core/src/cli/main.ts +41 -15
  28. package/packages/core/src/cli/mcp.ts +7 -1
  29. package/packages/core/src/cli/pr-summary-publication.ts +220 -0
  30. package/packages/core/src/cli/pr-summary.ts +2 -0
  31. package/packages/core/src/cli/project-credentials.ts +132 -0
  32. package/packages/core/src/cli/repo-state.ts +15 -2
  33. package/packages/core/src/cli/repository-status.ts +45 -5
  34. package/packages/core/src/cli/self-update.ts +10 -0
  35. package/packages/core/src/cli/store-opening.ts +22 -14
  36. package/packages/core/src/cli/terminals.ts +0 -10
  37. package/packages/core/src/cli/ticket.ts +8 -1
  38. package/packages/core/src/cli/upgrade-compatibility.ts +80 -0
  39. package/packages/core/src/cli/wait.ts +84 -17
  40. package/packages/core/src/cli/watch.ts +350 -68
  41. package/packages/core/src/config/live.ts +4 -0
  42. package/packages/core/src/config/schema.ts +65 -11
  43. package/packages/core/src/connection.ts +86 -0
  44. package/packages/core/src/events/catalog.ts +10 -2
  45. package/packages/core/src/events/globs.ts +8 -3
  46. package/packages/core/src/events/payloads.ts +44 -0
  47. package/packages/core/src/events/repository.ts +64 -2
  48. package/packages/core/src/integration/harness.ts +2 -1
  49. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +148 -83
  50. package/packages/core/src/kernel/dispatch-status.ts +9 -1
  51. package/packages/core/src/kernel/dispatcher-liveness.ts +70 -0
  52. package/packages/core/src/kernel/engine.ts +24 -0
  53. package/packages/core/src/kernel/generators/build-log.ts +10 -0
  54. package/packages/core/src/kernel/log-index.ts +17 -0
  55. package/packages/core/src/kernel/pr-summary.ts +89 -0
  56. package/packages/core/src/kernel/reducer-registry.ts +12 -0
  57. package/packages/core/src/kernel/reducer.ts +8 -0
  58. package/packages/core/src/kernel/ticket-source-health.ts +30 -0
  59. package/packages/core/src/ontology.ts +1 -0
  60. package/packages/core/src/operator/index.ts +7 -0
  61. package/packages/core/src/operator/query.ts +44 -19
  62. package/packages/core/src/operator/ticket-source-backend.ts +1 -0
  63. package/packages/core/src/operator/tickets.ts +6 -8
  64. package/packages/core/src/plugin-sdk/index.ts +1 -0
  65. package/packages/core/src/plugins/manifest.ts +5 -3
  66. package/packages/core/src/plugins/runtimes.ts +3 -1
  67. package/packages/core/src/ports/forge/fake.ts +13 -1
  68. package/packages/core/src/ports/forge/github-transport.ts +36 -1
  69. package/packages/core/src/ports/forge/github.ts +29 -5
  70. package/packages/core/src/ports/forge/local-git.ts +12 -4
  71. package/packages/core/src/ports/forge/summary-marker.ts +8 -0
  72. package/packages/core/src/ports/runner/session-env.ts +1 -0
  73. package/packages/core/src/ports/tickets/contract.ts +25 -0
  74. package/packages/core/src/ports/tickets/create.ts +11 -3
  75. package/packages/core/src/ports/tickets/fake.ts +10 -8
  76. package/packages/core/src/ports/tickets/file.ts +1 -0
  77. package/packages/core/src/ports/tickets/hosted-index.ts +0 -3
  78. package/packages/core/src/ports/tickets/linear.ts +241 -49
  79. package/packages/core/src/ports/tickets/remote-protocol.ts +3 -0
  80. package/packages/core/src/ports/tickets/remote.ts +1 -0
  81. package/packages/core/src/ports/types.ts +17 -0
  82. package/packages/core/src/ports/workspace/build-execution.ts +5 -1
  83. package/packages/core/src/ports/workspace/create.ts +11 -2
  84. package/packages/core/src/ports/workspace/distribution-archive.ts +3 -5
  85. package/packages/core/src/ports/workspace/harvest-execution.ts +3 -1
  86. package/packages/core/src/ports/workspace/local-build-execution.ts +8 -1
  87. package/packages/core/src/ports/workspace/vercel-sandbox.ts +47 -19
  88. package/packages/core/src/processes/build-execution-state.ts +1 -1
  89. package/packages/core/src/processes/build-runner.ts +87 -26
  90. package/packages/core/src/processes/dispatch-failure-budget.ts +83 -0
  91. package/packages/core/src/processes/dispatcher.ts +336 -83
  92. package/packages/core/src/processes/harvest-child.ts +1 -0
  93. package/packages/core/src/processes/harvest-execution-state.ts +19 -2
  94. package/packages/core/src/processes/harvest-runner.ts +142 -100
  95. package/packages/core/src/processes/phase-failure-budget.ts +1 -0
  96. package/packages/core/src/processes/publication-loss.ts +18 -1
  97. package/packages/core/src/processes/repo-view.ts +2 -1
  98. package/packages/core/src/store/adapter.ts +1 -0
  99. package/packages/core/src/store/contract.ts +109 -0
  100. package/packages/core/src/store/digest.ts +14 -2
  101. package/packages/core/src/store/local/store.ts +18 -5
  102. package/packages/core/src/store/remote/client.ts +1 -1
  103. package/packages/core/src/store/remote/compatibility.ts +90 -0
  104. package/packages/core/src/store/remote/credentials.ts +28 -0
  105. package/packages/core/src/store/remote/index.ts +14 -1
  106. package/packages/core/src/store/remote/protocol.ts +26 -0
  107. package/packages/core/src/store/remote/token.ts +74 -13
  108. package/packages/core/src/store/repo-state-events.ts +49 -59
  109. package/packages/core/src/store/types.ts +5 -1
  110. package/packages/core/src/testing/index.ts +7 -0
  111. package/skills/guide/SKILL.md +175 -26
  112. package/skills/guide/references/operator-api.md +135 -13
  113. package/skills/guide/references/plugin-authoring.md +10 -2
  114. package/skills/guide/references/remote-store-protocol.md +189 -144
  115. package/skills/guide/references/remote-ticket-protocol.md +111 -18
  116. package/skills/guide/references/setup.md +173 -16
  117. package/skills/guide/references/ticket-dependencies.md +1 -1
  118. package/skills/operate/SKILL.md +159 -34
  119. package/templates/hosted-service/package.json +13 -0
  120. package/templates/hosted-service/vercel.json +7 -0
@@ -84,6 +84,7 @@ export interface VerifyRecord {
84
84
  attempt: number
85
85
  outcome: VerifyOutcome
86
86
  report?: ArtifactRef
87
+ skipCause?: 'post-reconcile-policy'
87
88
  reason?: string
88
89
  }
89
90
 
@@ -111,6 +112,12 @@ export interface ReconcileProgressRecord {
111
112
  }
112
113
 
113
114
  export interface LogIndex {
115
+ /** Latest post-restart policy fact; authoritative only for its originating
116
+ * reconcile completion, never for a newer review or reconcile cycle. */
117
+ reconcilePolicy?: {
118
+ reconcileSeq: number
119
+ steps: { step: string; enabled: boolean; source: string }[]
120
+ }
114
121
  /** seq of the latest `spec.revised`, else 0 — the restart boundary (§6.3). */
115
122
  restartSeq: number
116
123
  plan: LoopIndex
@@ -195,6 +202,7 @@ export type LogIndexCandidate =
195
202
  }
196
203
  | { kind: 'implement-completed'; seq: number }
197
204
  | { kind: 'verify-started'; seq: number; attempt: number }
205
+ | { kind: 'reconcile-policy'; seq: number; policy: NonNullable<LogIndex['reconcilePolicy']> }
198
206
  | { kind: 'verify-completed'; record: VerifyRecord }
199
207
  | { kind: 'finalize-completed'; seq: number }
200
208
  | { kind: 'finalize-step'; seq: number; step: string }
@@ -362,6 +370,9 @@ function foldLogIndex(acc: LogIndexAcc, events: readonly AbEvent[]): void {
362
370
  }
363
371
  break
364
372
  }
373
+ case 'verify.reconcile-policy-captured':
374
+ acc.candidates.push({ kind: 'reconcile-policy', seq: event.seq, policy: event.payload })
375
+ break
365
376
  case 'verify.completed': {
366
377
  const result = normalizeVerifyCompletion(event.payload)
367
378
  acc.maxVerifyAttemptEver = Math.max(acc.maxVerifyAttemptEver, result.attempt)
@@ -374,6 +385,7 @@ function foldLogIndex(acc: LogIndexAcc, events: readonly AbEvent[]): void {
374
385
  outcome: result.outcome,
375
386
  ...(result.report !== undefined ? { report: result.report } : {}),
376
387
  ...(result.reason !== undefined ? { reason: result.reason } : {}),
388
+ ...(result.skipCause !== undefined ? { skipCause: result.skipCause } : {}),
377
389
  },
378
390
  })
379
391
  break
@@ -451,6 +463,7 @@ function finishLogIndex(acc: LogIndexAcc): LogIndex {
451
463
  const verifyCompleted: VerifyRecord[] = []
452
464
  const verifyStarted: { seq: number; attempt: number }[] = []
453
465
  const finalizeStepsDone = new Set<string>()
466
+ let reconcilePolicy: LogIndex['reconcilePolicy']
454
467
  let lastReconcileCompletedSeq = 0
455
468
  let lastImplementCompletedSeq = 0
456
469
  let finalizeCompleted = false
@@ -521,6 +534,9 @@ function finishLogIndex(acc: LogIndexAcc): LogIndex {
521
534
  case 'verify-started':
522
535
  verifyStarted.push({ seq: candidate.seq, attempt: candidate.attempt })
523
536
  break
537
+ case 'reconcile-policy':
538
+ reconcilePolicy = candidate.policy
539
+ break
524
540
  case 'verify-completed':
525
541
  verifyCompleted.push({ ...candidate.record })
526
542
  break
@@ -594,6 +610,7 @@ function finishLogIndex(acc: LogIndexAcc): LogIndex {
594
610
  verifyCompleted,
595
611
  verifyStarted,
596
612
  maxVerifyAttemptEver: acc.maxVerifyAttemptEver,
613
+ reconcilePolicy,
597
614
  lastReconcileCompletedSeq,
598
615
  lastImplementCompletedSeq,
599
616
  finalizeCompleted,
@@ -0,0 +1,89 @@
1
+ import { defineReducer } from './incremental'
2
+ import type { AbEvent } from '../events/catalog'
3
+
4
+ /** Latest completed occurrence only: never replay an obsolete publication backlog. */
5
+ export const summaryBoundaryReducer = defineReducer<
6
+ {
7
+ occurrence?: number
8
+ boundary?: number
9
+ sessions: Record<string, true>
10
+ },
11
+ AbEvent,
12
+ { occurrence: number; boundary: number } | undefined
13
+ >({
14
+ version: 1,
15
+ initial: () => ({ sessions: {} }),
16
+ fold(acc, events) {
17
+ for (const event of events) {
18
+ if (
19
+ [
20
+ 'plan.started',
21
+ 'plan-review.started',
22
+ 'implement.started',
23
+ 'code-review.started',
24
+ 'verify.started',
25
+ 'reconcile.started',
26
+ 'finalize.started',
27
+ 'finalize.step-started',
28
+ ].includes(event.type)
29
+ ) {
30
+ acc.occurrence = event.seq
31
+ delete acc.boundary
32
+ }
33
+ if (event.type === 'session.started') acc.sessions[event.payload.session] = true
34
+ if (event.type === 'session.ended') delete acc.sessions[event.payload.session]
35
+ if (
36
+ acc.occurrence !== undefined &&
37
+ (event.type === 'session.ended' ||
38
+ [
39
+ 'plan.completed',
40
+ 'plan-review.verdict',
41
+ 'implement.completed',
42
+ 'code-review.verdict',
43
+ 'verify.completed',
44
+ 'reconcile.completed',
45
+ 'finalize.completed',
46
+ 'finalize.step-completed',
47
+ 'phase.failed',
48
+ 'escalation.raised',
49
+ 'publication.requested',
50
+ ].includes(event.type))
51
+ )
52
+ acc.boundary = event.seq
53
+ }
54
+ },
55
+ finish: (acc) =>
56
+ acc.occurrence !== undefined &&
57
+ acc.boundary !== undefined &&
58
+ Object.keys(acc.sessions).length === 0
59
+ ? { occurrence: acc.occurrence, boundary: acc.boundary }
60
+ : undefined,
61
+ })
62
+
63
+ export function summaryBoundary(events: readonly AbEvent[]) {
64
+ return summaryBoundaryReducer.reduce(events)
65
+ }
66
+
67
+ type SummaryRequest = Extract<AbEvent, { type: 'pr-summary.requested' }>
68
+ export const summaryRequestsReducer = defineReducer<
69
+ {
70
+ requests: SummaryRequest[]
71
+ settled: number[]
72
+ },
73
+ AbEvent,
74
+ SummaryRequest[]
75
+ >({
76
+ version: 1,
77
+ initial: () => ({ requests: [], settled: [] }),
78
+ fold(acc, events) {
79
+ for (const event of events) {
80
+ if (event.type === 'pr-summary.requested') acc.requests.push(event)
81
+ if (event.type === 'pr-summary.settled') acc.settled.push(event.payload.request)
82
+ }
83
+ },
84
+ finish: (acc) => acc.requests.filter((event) => !acc.settled.includes(event.seq)),
85
+ })
86
+
87
+ export function pendingSummaryRequests(events: readonly AbEvent[]) {
88
+ return summaryRequestsReducer.reduce(events)
89
+ }
@@ -1,3 +1,6 @@
1
+ import { summaryBoundaryReducer, summaryRequestsReducer } from './pr-summary'
2
+ import { dispatchFailureBudgetReducer } from '../processes/dispatch-failure-budget'
3
+ import { ticketSourceHealthReducer } from './ticket-source-health'
1
4
  /**
2
5
  * Every incremental reducer, with its published version and the whole-array
3
6
  * functions it backs. `reducer-registry.test.ts` snapshots the versions and
@@ -57,6 +60,9 @@ export interface RegisteredReducer {
57
60
  /** Parameterized reducers are registered with representative arguments; the
58
61
  * accumulator shape does not depend on them. */
59
62
  export const REDUCERS: Record<string, RegisteredReducer> = {
63
+ summaryBoundary: { reducer: summaryBoundaryReducer, covers: ['summaryBoundary'] },
64
+ summaryRequests: { reducer: summaryRequestsReducer, covers: ['pendingSummaryRequests'] },
65
+ ticketSourceHealth: { reducer: ticketSourceHealthReducer, covers: ['reduceTicketSourceHealth'] },
60
66
  build: { reducer: buildReducer, covers: ['reduceBuild'] },
61
67
  harvest: { reducer: harvestReducer, covers: ['reduceHarvest'] },
62
68
  dispatchSettings: { reducer: dispatchSettingsReducer, covers: ['reduceDispatchSettings'] },
@@ -95,6 +101,10 @@ export const REDUCERS: Record<string, RegisteredReducer> = {
95
101
  covers: ['openHarvestExecutions'],
96
102
  },
97
103
  openTick: { reducer: openTickReducer('run-a'), covers: ['hasOpenTick'] },
104
+ dispatchFailureBudget: {
105
+ reducer: dispatchFailureBudgetReducer(false),
106
+ covers: ['dispatchFailureBudget'],
107
+ },
98
108
  infrastructureFailureReset: {
99
109
  reducer: infrastructureFailureResetReducer,
100
110
  covers: ['infrastructureFailureResetSeq'],
@@ -134,6 +144,8 @@ export const REDUCERS: Record<string, RegisteredReducer> = {
134
144
  /** Event-array functions that are deliberately not reducers, each with the
135
145
  * concrete reason. */
136
146
  export const EXCLUDED: Record<string, string> = {
147
+ projectDispatcherLiveness:
148
+ 'combines mutable repository lease metadata and an explicit clock with correlated facts; cannot be restored from an event-only snapshot',
137
149
  decideNext: 'a decision function over config; returns a Decision, not a state',
138
150
  harvestCreationsDuringReadyScan:
139
151
  'a derived query over two reduceHarvest states (before/after), which is already incremental',
@@ -113,6 +113,8 @@ export interface VerifyResult {
113
113
  attempt: number
114
114
  outcome: VerifyOutcome
115
115
  report?: ArtifactRef
116
+ /** Optional classification on kernel policy skips; historical skips omit it. */
117
+ skipCause?: 'post-reconcile-policy'
116
118
  /** Present exactly when `outcome === "skipped"`. */
117
119
  reason?: string
118
120
  /** seq of the `verify.completed` event — what `cycleSince` is compared to. */
@@ -457,6 +459,11 @@ function foldBuild(acc: BuildAcc, events: readonly AbEvent[]): void {
457
459
  break
458
460
  case 'workspace.provisioned':
459
461
  case 'workspace.released':
462
+ case 'pr-summary.requested':
463
+ case 'pr-summary.attempted':
464
+ case 'pr-summary.result':
465
+ case 'pr-summary.settled':
466
+ case 'finalize.step-started':
460
467
  case 'publication.requested':
461
468
  // Inert loss fact (AUT-328): the dispatcher released a workspace with a
462
469
  // pending publication. Recovery runs through the existing
@@ -689,6 +696,7 @@ function foldBuild(acc: BuildAcc, events: readonly AbEvent[]): void {
689
696
  outcome: result.outcome,
690
697
  report: result.report,
691
698
  ...(result.reason !== undefined ? { reason: result.reason } : {}),
699
+ ...(result.skipCause !== undefined ? { skipCause: result.skipCause } : {}),
692
700
  seq: event.seq,
693
701
  })
694
702
  if (verify.currentStep === result.step) {
@@ -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
+ }
@@ -108,6 +108,7 @@ export const policyEscalationCauseSchema = z.enum([
108
108
  'review-round-limit',
109
109
  'verify-failure-limit',
110
110
  'reconcile-no-progress',
111
+ 'dispatch-failure-limit',
111
112
  'setup-failure-limit',
112
113
  'infrastructure-failure-limit',
113
114
  'phase-attempt-limit',
@@ -125,3 +125,10 @@ export type { HarvestStatusView } from '../cli/harvest'
125
125
  export type { RepositoryStatus } from '../cli/repository-status'
126
126
  export type { BuildSummary } from '../cli/status'
127
127
  export type { SessionRecord } from '../store/types'
128
+
129
+ export {
130
+ effectiveConfig,
131
+ selectEffectiveConfig,
132
+ findEffectiveConfigRef,
133
+ decodeEffectiveConfig,
134
+ } from './query'
@@ -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'
@@ -37,7 +38,7 @@ export type BuildListScope = 'active' | 'queued' | 'all'
37
38
 
38
39
  export class OperatorQueryError extends Error {
39
40
  constructor(
40
- readonly code: 'not-found' | 'effective-config-unavailable',
41
+ readonly code: 'not-found' | 'effective-config-unavailable' | 'external-ticket-source',
41
42
  message: string,
42
43
  ) {
43
44
  super(message)
@@ -120,48 +121,70 @@ export async function effectiveConfig(
120
121
  status: ReturnType<typeof reduceDispatchStatus>
121
122
  }> {
122
123
  const repositoryEvents = journal ?? (await readRepoEventsIfRecorded(store, repo))
124
+ const status = selectEffectiveConfig(repositoryEvents, repo)
125
+ const ref = status.effectiveConfig!
126
+ const artifact = await store.getRepoArtifact(repo, ref.kind, ref.rev)
127
+ if (artifact === null) {
128
+ throw new OperatorQueryError(
129
+ 'effective-config-unavailable',
130
+ `effective config ${ref.kind}@${ref.rev} is not retrievable`,
131
+ )
132
+ }
133
+ return {
134
+ config: decodeEffectiveConfig(artifact.content, `${ref.kind}@${ref.rev}`),
135
+ repositoryEvents,
136
+ status,
137
+ }
138
+ }
139
+
140
+ function latestDispatchConfigStatus(repositoryEvents: RepositoryEvent[]) {
123
141
  let latestRun: string | undefined
124
142
  for (const event of repositoryEvents) {
125
143
  if (event.type === 'dispatcher.run-started') latestRun = event.payload.run
126
144
  }
127
- if (latestRun === undefined) {
145
+ return latestRun === undefined ? undefined : reduceDispatchStatus(repositoryEvents, latestRun)
146
+ }
147
+
148
+ /** Absence is distinct from a selected reference whose bytes cannot be read. */
149
+ export function findEffectiveConfigRef(repositoryEvents: RepositoryEvent[]) {
150
+ return latestDispatchConfigStatus(repositoryEvents)?.effectiveConfig
151
+ }
152
+
153
+ export function selectEffectiveConfig(repositoryEvents: RepositoryEvent[], repo: string) {
154
+ const status = latestDispatchConfigStatus(repositoryEvents)
155
+ if (status === undefined) {
128
156
  throw new OperatorQueryError(
129
157
  'effective-config-unavailable',
130
158
  `effective config unavailable for repository "${repo}": no dispatcher run is recorded`,
131
159
  )
132
160
  }
133
- const status = reduceDispatchStatus(repositoryEvents, latestRun)
134
- const ref = status.effectiveConfig
135
- if (ref === undefined) {
161
+ if (status.effectiveConfig === undefined) {
136
162
  throw new OperatorQueryError(
137
163
  'effective-config-unavailable',
138
164
  `effective config unavailable for repository "${repo}"`,
139
165
  )
140
166
  }
141
- const artifact = await store.getRepoArtifact(repo, ref.kind, ref.rev)
142
- if (artifact === null) {
143
- throw new OperatorQueryError(
144
- 'effective-config-unavailable',
145
- `effective config ${ref.kind}@${ref.rev} is not retrievable`,
146
- )
147
- }
167
+ return status
168
+ }
169
+
170
+ export function decodeEffectiveConfig(content: Uint8Array, label: string): Config {
148
171
  let raw: unknown
149
172
  try {
150
- raw = JSON.parse(new TextDecoder().decode(artifact.content))
173
+ raw = JSON.parse(new TextDecoder().decode(content))
151
174
  } catch {
152
175
  throw new OperatorQueryError(
153
176
  'effective-config-unavailable',
154
- `effective config ${ref.kind}@${ref.rev} is not valid JSON`,
177
+ `effective config ${label} is not valid JSON`,
155
178
  )
156
179
  }
157
180
  const parsed = configSchema.safeParse(raw)
158
181
  if (!parsed.success) {
159
182
  throw new OperatorQueryError(
160
183
  'effective-config-unavailable',
161
- `effective config ${ref.kind}@${ref.rev} is invalid: ${parsed.error.message}`,
184
+ `effective config ${label} is invalid: ${parsed.error.message}`,
162
185
  )
163
186
  }
164
- return { config: parsed.data, repositoryEvents, status }
187
+ return parsed.data
165
188
  }
166
189
 
167
190
  export interface OperatorBuildView {
@@ -255,10 +278,12 @@ export async function getOperatorBuild(opts: {
255
278
  export async function getRepositoryStatus(
256
279
  store: BuildStore,
257
280
  repo: string,
281
+ now = new Date(),
258
282
  ): Promise<RepositoryStatus> {
259
- return withOperatorView(store, repo, 'journal', async (view) =>
260
- projectRepositoryStatus(repo, view.recordedJournal()),
261
- )
283
+ return withOperatorView(store, repo, 'journal', async (view) => {
284
+ const { dispatcher } = await readDispatcherLiveness(store, repo, now, view.recordedJournal())
285
+ return projectRepositoryStatus(repo, view.recordedJournal(), dispatcher)
286
+ })
262
287
  }
263
288
 
264
289
  export async function getHarvestStatus(
@@ -39,6 +39,7 @@ export function ticketBackendFromSource(options: {
39
39
  tickets.readyState,
40
40
  tickets.createState,
41
41
  tickets.claimedState,
42
+ tickets.doneState,
42
43
  tickets.proposalState ?? defaultTriageState(options.config),
43
44
  ].filter((state): state is string => typeof state === 'string' && state.trim().length > 0)
44
45
  return [...new Set(states)]
@@ -10,9 +10,11 @@ import {
10
10
  import type { DependencyState, Ticket, TicketSource, TicketUpdate } from '../ports/types'
11
11
  import type { BuildStore } from '../store/types'
12
12
  import { defaultTriageState } from '../processes/dispatcher'
13
- import { effectiveConfig, OperatorQueryError } from './query'
13
+ import { effectiveConfig } from './query'
14
14
 
15
15
  export interface OperatorTicketContext {
16
+ repo: string
17
+ tickets: Config['tickets']
16
18
  teamKey: string
17
19
  claimedState?: string
18
20
  createState?: string
@@ -48,14 +50,10 @@ export interface OperatorTicketDetail {
48
50
 
49
51
  function contextFor(config: Config, repo: string): OperatorTicketContext {
50
52
  const tickets = config.tickets
51
- if (tickets.teamKey === undefined) {
52
- throw new OperatorQueryError(
53
- 'effective-config-unavailable',
54
- `effective config unavailable for repository "${repo}": [tickets].teamKey is required for hosted ticket access`,
55
- )
56
- }
57
53
  return {
58
- teamKey: tickets.teamKey,
54
+ repo,
55
+ tickets,
56
+ teamKey: tickets.teamKey ?? '',
59
57
  ...(tickets.claimedState !== undefined ? { claimedState: tickets.claimedState } : {}),
60
58
  ...(tickets.createState !== undefined ? { createState: tickets.createState } : {}),
61
59
  }
@@ -5,6 +5,7 @@
5
5
  */
6
6
  export type * from '../ports/types'
7
7
  export type * from '../ports/workspace/build-execution'
8
+ export type * from '../ports/workspace/harvest-execution'
8
9
  export type * from '../store/types'
9
10
  export * from '../store/session-reducer'
10
11
  export type {
@@ -1,3 +1,4 @@
1
+ import type { delegateCredential } from '../store/remote/credentials'
1
2
  import semver from 'semver'
2
3
  import { z } from 'zod'
3
4
  import { openMap } from '../open-map'
@@ -51,9 +52,10 @@ export interface WorkspaceProviderPluginFactoryContext<Config = Record<string, u
51
52
  * `createWorkspaceProvider` refuses construction before the factory runs
52
53
  * when either store seam is missing. */
53
54
  storeRef?: string
54
- /** Scoped store token (`AB_TOKEN`). Guaranteed present under
55
- * `capabilities.storeRequirements` for the same reason. */
56
- storeToken?: string
55
+ /** @deprecated Project credentials are never delivered to plugin factories. */
56
+ storeToken?: never
57
+ /** Host-only exchange; pass the returned per-resource token at launch. */
58
+ delegateCredential?: typeof delegateCredential
57
59
  /** Host-derived effective runtime routes (the referenced runtimes and their
58
60
  * selection provenance). Absent at call sites that reference no runtimes
59
61
  * (for example the MCP server), which never construct a workspace. */
@@ -39,7 +39,9 @@ export async function materializePluginRuntimes(
39
39
 
40
40
  const merged = Object.assign(Object.create(null) as RuntimeRegistry, builtins)
41
41
  const config = Object.freeze({})
42
- const env = Object.freeze({ ...opts.env })
42
+ const env = Object.freeze(
43
+ Object.fromEntries(Object.entries(opts.env).filter(([key]) => key !== 'AB_TOKEN')),
44
+ )
43
45
  const context: PluginFactoryContext = {
44
46
  config,
45
47
  env,
@@ -1,3 +1,4 @@
1
+ import { findSummaryMarker } from './summary-marker'
1
2
  /**
2
3
  * FakeForge (SPEC §3.2): in-memory Forge for seam tests. Gate existence and
3
4
  * current PR merge state are independent inputs, matching GitHubForge's safety
@@ -514,6 +515,17 @@ export class FakeForge implements Forge {
514
515
 
515
516
  async commentOnPr(workspacePath: string, number: number, body: string): Promise<void> {
516
517
  this.assertPr(number)
517
- this.comments.push({ workspacePath, number, body })
518
+ const marker = findSummaryMarker(body)
519
+ const existing =
520
+ marker === undefined
521
+ ? undefined
522
+ : this.comments.find(
523
+ (comment) =>
524
+ comment.number === number &&
525
+ comment.workspacePath === workspacePath &&
526
+ findSummaryMarker(comment.body) === marker,
527
+ )
528
+ if (existing !== undefined) existing.body = body
529
+ else this.comments.push({ workspacePath, number, body })
518
530
  }
519
531
  }
@@ -52,7 +52,12 @@ export class GitHubApiError extends Error {
52
52
  readonly status: number
53
53
  readonly body?: unknown
54
54
 
55
- constructor(status: number, message: string, body?: unknown) {
55
+ constructor(
56
+ status: number,
57
+ message: string,
58
+ body?: unknown,
59
+ readonly headers: Record<string, string> = {},
60
+ ) {
56
61
  super(message)
57
62
  this.name = 'GitHubApiError'
58
63
  this.status = status
@@ -225,6 +230,7 @@ export function createGitHubFetchTransport(opts: {
225
230
  response.status,
226
231
  `${githubErrorMessage(response.status, json, text)}${anonymous}`,
227
232
  json,
233
+ headerMap,
228
234
  )
229
235
  }
230
236
  return {
@@ -361,3 +367,32 @@ export function githubTokenSource(
361
367
  ): () => Promise<GitHubCredential> {
362
368
  return () => resolveGitHubToken(env, exec)
363
369
  }
370
+
371
+ /** Documented primary and secondary limits; permission failures are ordinary errors. */
372
+ export function githubRateLimitUntil(error: unknown, now: number): string | undefined {
373
+ if (!(error instanceof GitHubApiError)) return undefined
374
+ if (
375
+ error.status !== 429 &&
376
+ !(
377
+ error.status === 403 &&
378
+ (error.headers['x-ratelimit-remaining'] === '0' ||
379
+ /rate limit|secondary.*limit|content.creation.*limit/i.test(error.message))
380
+ )
381
+ )
382
+ return undefined
383
+ const retry = error.headers['retry-after']
384
+ const seconds = retry === undefined ? NaN : Number(retry)
385
+ const retryAt = Number.isFinite(seconds) ? now + seconds * 1000 : Date.parse(retry ?? '')
386
+ // The reset describes the primary quota window, even on secondary-limit responses.
387
+ const reset =
388
+ error.headers['x-ratelimit-remaining'] === '0'
389
+ ? Number(error.headers['x-ratelimit-reset']) * 1000
390
+ : NaN
391
+ return new Date(
392
+ Math.max(
393
+ now + 60_000,
394
+ Number.isFinite(retryAt) ? retryAt : 0,
395
+ Number.isFinite(reset) ? reset : 0,
396
+ ),
397
+ ).toISOString()
398
+ }
@@ -1,3 +1,4 @@
1
+ import { findSummaryMarker } from './summary-marker'
1
2
  /**
2
3
  * GitHubForge (SPEC §3.2): the Forge adapter for GitHub, speaking the GitHub
3
4
  * REST API through an injectable transport seam. All remote access is
@@ -1131,11 +1132,34 @@ export class GitHubForge implements Forge {
1131
1132
  /** The build's summary comment (SPEC §7.5) — links into the store. */
1132
1133
  async commentOnPr(_workspacePath: string, number: number, body: string): Promise<void> {
1133
1134
  const { owner, name } = await this.requireCoordinates()
1134
- await this.request(
1135
- 'POST',
1136
- `repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/issues/${number}/comments`,
1137
- { body: { body } },
1138
- )
1135
+ const root = `repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`
1136
+ const collection = `${root}/issues/${number}/comments`
1137
+ const marker = findSummaryMarker(body)
1138
+ if (marker !== undefined) {
1139
+ const schema = z.array(z.object({ id: z.number().int().positive(), body: z.string() }))
1140
+ // A failed discovery never authorizes creation. Re-scan once if an edit
1141
+ // races deletion; an uncertain POST is rediscovered on the next phase.
1142
+ for (let scan = 0; scan < 2; scan++) {
1143
+ let match: { id: number; body: string } | undefined
1144
+ for (let page = 1; ; page++) {
1145
+ const response = await this.request('GET', collection, {
1146
+ query: { per_page: '100', page: String(page) },
1147
+ })
1148
+ const comments = schema.parse(response.json)
1149
+ match ??= comments.find((comment) => findSummaryMarker(comment.body) === marker)
1150
+ if (comments.length < 100) break
1151
+ }
1152
+ if (match === undefined) break
1153
+ if (match.body === body) return
1154
+ try {
1155
+ await this.request('PATCH', `${root}/issues/comments/${match.id}`, { body: { body } })
1156
+ return
1157
+ } catch (error) {
1158
+ if (!(error instanceof GitHubApiError) || error.status !== 404 || scan !== 0) throw error
1159
+ }
1160
+ }
1161
+ }
1162
+ await this.request('POST', collection, { body: { body } })
1139
1163
  }
1140
1164
 
1141
1165
  // ── Optional capabilities (checkout-less dispatcher) ──────────────────────
@@ -1,3 +1,4 @@
1
+ import { findSummaryMarker } from './summary-marker'
1
2
  import { mkdtemp, rm, writeFile } from 'node:fs/promises'
2
3
  import { tmpdir } from 'node:os'
3
4
  import { join } from 'node:path'
@@ -590,9 +591,16 @@ export class LocalGitForge implements Forge {
590
591
  }
591
592
 
592
593
  async commentOnPr(workspacePath: string, number: number, body: string): Promise<void> {
593
- await this.updateRecord(workspacePath, number, (record) => ({
594
- ...record,
595
- comments: record.comments.includes(body) ? record.comments : [...record.comments, body],
596
- }))
594
+ const marker = findSummaryMarker(body)
595
+ await this.updateRecord(workspacePath, number, (record) => {
596
+ const index =
597
+ marker === undefined
598
+ ? -1
599
+ : record.comments.findIndex((comment) => findSummaryMarker(comment) === marker)
600
+ const comments = [...record.comments]
601
+ if (index >= 0) comments[index] = body
602
+ else if (!comments.includes(body)) comments.push(body)
603
+ return { ...record, comments }
604
+ })
597
605
  }
598
606
  }
@@ -0,0 +1,8 @@
1
+ /** Stable, injection-safe identity shared by built-in forge adapters. */
2
+ export function summaryMarker(build: string): string {
3
+ return `<!-- autobuild-summary:v1:${Buffer.from(build).toString('hex')} -->`
4
+ }
5
+
6
+ export function findSummaryMarker(body: string): string | undefined {
7
+ return body.split('\n').find((line) => /^<!-- autobuild-summary:v1:[0-9a-f]+ -->$/.test(line))
8
+ }
@@ -21,6 +21,7 @@ export function sessionEnv(
21
21
  for (const [key, value] of Object.entries(ambient)) {
22
22
  if (value !== undefined) env[key] = value
23
23
  }
24
+ delete env.AB_TOKEN
24
25
  Object.assign(env, scoped)
25
26
 
26
27
  const inheritedPath = env.PATH