@defrex/autobuild 0.11.0 → 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 (96) hide show
  1. package/README.md +3 -1
  2. package/SPEC.md +222 -22
  3. package/docs/architecture.md +66 -4
  4. package/docs/configuration.md +105 -16
  5. package/docs/hosted-dispatcher.md +17 -2
  6. package/docs/hosted-service.md +214 -0
  7. package/docs/mcp.md +8 -2
  8. package/docs/operator-api.md +90 -7
  9. package/docs/remote-store-protocol.md +189 -144
  10. package/docs/remote-ticket-protocol.md +107 -19
  11. package/docs/setup.md +173 -16
  12. package/docs/ticket-dependencies.md +1 -1
  13. package/package.json +7 -6
  14. package/packages/core/src/cli/attention.ts +209 -0
  15. package/packages/core/src/cli/configured-store.ts +17 -0
  16. package/packages/core/src/cli/connect.ts +261 -0
  17. package/packages/core/src/cli/dashboard/model.ts +4 -5
  18. package/packages/core/src/cli/dispatch.ts +183 -41
  19. package/packages/core/src/cli/help.ts +39 -10
  20. package/packages/core/src/cli/init-validation.ts +27 -2
  21. package/packages/core/src/cli/main.ts +41 -15
  22. package/packages/core/src/cli/mcp.ts +7 -1
  23. package/packages/core/src/cli/pr-summary-publication.ts +220 -0
  24. package/packages/core/src/cli/pr-summary.ts +2 -0
  25. package/packages/core/src/cli/project-credentials.ts +132 -0
  26. package/packages/core/src/cli/repo-state.ts +15 -2
  27. package/packages/core/src/cli/self-update.ts +10 -0
  28. package/packages/core/src/cli/store-opening.ts +22 -14
  29. package/packages/core/src/cli/terminals.ts +0 -10
  30. package/packages/core/src/cli/ticket.ts +8 -1
  31. package/packages/core/src/cli/upgrade-compatibility.ts +80 -0
  32. package/packages/core/src/cli/wait.ts +84 -17
  33. package/packages/core/src/cli/watch.ts +197 -60
  34. package/packages/core/src/config/live.ts +3 -0
  35. package/packages/core/src/config/schema.ts +56 -9
  36. package/packages/core/src/connection.ts +86 -0
  37. package/packages/core/src/events/catalog.ts +10 -2
  38. package/packages/core/src/events/payloads.ts +44 -0
  39. package/packages/core/src/events/repository.ts +13 -0
  40. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +130 -83
  41. package/packages/core/src/kernel/engine.ts +24 -0
  42. package/packages/core/src/kernel/generators/build-log.ts +10 -0
  43. package/packages/core/src/kernel/log-index.ts +17 -0
  44. package/packages/core/src/kernel/pr-summary.ts +89 -0
  45. package/packages/core/src/kernel/reducer-registry.ts +8 -0
  46. package/packages/core/src/kernel/reducer.ts +8 -0
  47. package/packages/core/src/ontology.ts +1 -0
  48. package/packages/core/src/operator/index.ts +7 -0
  49. package/packages/core/src/operator/query.ts +38 -16
  50. package/packages/core/src/operator/ticket-source-backend.ts +1 -0
  51. package/packages/core/src/operator/tickets.ts +6 -8
  52. package/packages/core/src/plugin-sdk/index.ts +1 -0
  53. package/packages/core/src/plugins/manifest.ts +5 -3
  54. package/packages/core/src/plugins/runtimes.ts +3 -1
  55. package/packages/core/src/ports/forge/fake.ts +13 -1
  56. package/packages/core/src/ports/forge/github-transport.ts +36 -1
  57. package/packages/core/src/ports/forge/github.ts +29 -5
  58. package/packages/core/src/ports/forge/local-git.ts +12 -4
  59. package/packages/core/src/ports/forge/summary-marker.ts +8 -0
  60. package/packages/core/src/ports/runner/session-env.ts +1 -0
  61. package/packages/core/src/ports/tickets/create.ts +11 -3
  62. package/packages/core/src/ports/tickets/hosted-index.ts +0 -3
  63. package/packages/core/src/ports/tickets/remote-protocol.ts +1 -0
  64. package/packages/core/src/ports/tickets/remote.ts +1 -0
  65. package/packages/core/src/ports/workspace/build-execution.ts +5 -1
  66. package/packages/core/src/ports/workspace/create.ts +11 -2
  67. package/packages/core/src/ports/workspace/distribution-archive.ts +3 -5
  68. package/packages/core/src/ports/workspace/harvest-execution.ts +3 -1
  69. package/packages/core/src/ports/workspace/local-build-execution.ts +8 -1
  70. package/packages/core/src/ports/workspace/vercel-sandbox.ts +47 -19
  71. package/packages/core/src/processes/build-execution-state.ts +1 -1
  72. package/packages/core/src/processes/build-runner.ts +87 -26
  73. package/packages/core/src/processes/dispatch-failure-budget.ts +83 -0
  74. package/packages/core/src/processes/dispatcher.ts +59 -10
  75. package/packages/core/src/processes/harvest-child.ts +1 -0
  76. package/packages/core/src/processes/harvest-execution-state.ts +19 -2
  77. package/packages/core/src/processes/harvest-runner.ts +142 -100
  78. package/packages/core/src/processes/phase-failure-budget.ts +1 -0
  79. package/packages/core/src/processes/publication-loss.ts +18 -1
  80. package/packages/core/src/store/remote/client.ts +1 -1
  81. package/packages/core/src/store/remote/compatibility.ts +90 -0
  82. package/packages/core/src/store/remote/credentials.ts +28 -0
  83. package/packages/core/src/store/remote/index.ts +14 -1
  84. package/packages/core/src/store/remote/protocol.ts +25 -0
  85. package/packages/core/src/store/remote/token.ts +74 -13
  86. package/packages/core/src/testing/index.ts +7 -0
  87. package/skills/guide/SKILL.md +144 -26
  88. package/skills/guide/references/operator-api.md +135 -13
  89. package/skills/guide/references/plugin-authoring.md +10 -2
  90. package/skills/guide/references/remote-store-protocol.md +189 -144
  91. package/skills/guide/references/remote-ticket-protocol.md +111 -18
  92. package/skills/guide/references/setup.md +173 -16
  93. package/skills/guide/references/ticket-dependencies.md +1 -1
  94. package/skills/operate/SKILL.md +126 -36
  95. package/templates/hosted-service/package.json +13 -0
  96. 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,5 @@
1
+ import { summaryBoundaryReducer, summaryRequestsReducer } from './pr-summary'
2
+ import { dispatchFailureBudgetReducer } from '../processes/dispatch-failure-budget'
1
3
  import { ticketSourceHealthReducer } from './ticket-source-health'
2
4
  /**
3
5
  * Every incremental reducer, with its published version and the whole-array
@@ -58,6 +60,8 @@ export interface RegisteredReducer {
58
60
  /** Parameterized reducers are registered with representative arguments; the
59
61
  * accumulator shape does not depend on them. */
60
62
  export const REDUCERS: Record<string, RegisteredReducer> = {
63
+ summaryBoundary: { reducer: summaryBoundaryReducer, covers: ['summaryBoundary'] },
64
+ summaryRequests: { reducer: summaryRequestsReducer, covers: ['pendingSummaryRequests'] },
61
65
  ticketSourceHealth: { reducer: ticketSourceHealthReducer, covers: ['reduceTicketSourceHealth'] },
62
66
  build: { reducer: buildReducer, covers: ['reduceBuild'] },
63
67
  harvest: { reducer: harvestReducer, covers: ['reduceHarvest'] },
@@ -97,6 +101,10 @@ export const REDUCERS: Record<string, RegisteredReducer> = {
97
101
  covers: ['openHarvestExecutions'],
98
102
  },
99
103
  openTick: { reducer: openTickReducer('run-a'), covers: ['hasOpenTick'] },
104
+ dispatchFailureBudget: {
105
+ reducer: dispatchFailureBudgetReducer(false),
106
+ covers: ['dispatchFailureBudget'],
107
+ },
100
108
  infrastructureFailureReset: {
101
109
  reducer: infrastructureFailureResetReducer,
102
110
  covers: ['infrastructureFailureResetSeq'],
@@ -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) {
@@ -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'
@@ -38,7 +38,7 @@ export type BuildListScope = 'active' | 'queued' | 'all'
38
38
 
39
39
  export class OperatorQueryError extends Error {
40
40
  constructor(
41
- readonly code: 'not-found' | 'effective-config-unavailable',
41
+ readonly code: 'not-found' | 'effective-config-unavailable' | 'external-ticket-source',
42
42
  message: string,
43
43
  ) {
44
44
  super(message)
@@ -121,48 +121,70 @@ export async function effectiveConfig(
121
121
  status: ReturnType<typeof reduceDispatchStatus>
122
122
  }> {
123
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[]) {
124
141
  let latestRun: string | undefined
125
142
  for (const event of repositoryEvents) {
126
143
  if (event.type === 'dispatcher.run-started') latestRun = event.payload.run
127
144
  }
128
- 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) {
129
156
  throw new OperatorQueryError(
130
157
  'effective-config-unavailable',
131
158
  `effective config unavailable for repository "${repo}": no dispatcher run is recorded`,
132
159
  )
133
160
  }
134
- const status = reduceDispatchStatus(repositoryEvents, latestRun)
135
- const ref = status.effectiveConfig
136
- if (ref === undefined) {
161
+ if (status.effectiveConfig === undefined) {
137
162
  throw new OperatorQueryError(
138
163
  'effective-config-unavailable',
139
164
  `effective config unavailable for repository "${repo}"`,
140
165
  )
141
166
  }
142
- const artifact = await store.getRepoArtifact(repo, ref.kind, ref.rev)
143
- if (artifact === null) {
144
- throw new OperatorQueryError(
145
- 'effective-config-unavailable',
146
- `effective config ${ref.kind}@${ref.rev} is not retrievable`,
147
- )
148
- }
167
+ return status
168
+ }
169
+
170
+ export function decodeEffectiveConfig(content: Uint8Array, label: string): Config {
149
171
  let raw: unknown
150
172
  try {
151
- raw = JSON.parse(new TextDecoder().decode(artifact.content))
173
+ raw = JSON.parse(new TextDecoder().decode(content))
152
174
  } catch {
153
175
  throw new OperatorQueryError(
154
176
  'effective-config-unavailable',
155
- `effective config ${ref.kind}@${ref.rev} is not valid JSON`,
177
+ `effective config ${label} is not valid JSON`,
156
178
  )
157
179
  }
158
180
  const parsed = configSchema.safeParse(raw)
159
181
  if (!parsed.success) {
160
182
  throw new OperatorQueryError(
161
183
  'effective-config-unavailable',
162
- `effective config ${ref.kind}@${ref.rev} is invalid: ${parsed.error.message}`,
184
+ `effective config ${label} is invalid: ${parsed.error.message}`,
163
185
  )
164
186
  }
165
- return { config: parsed.data, repositoryEvents, status }
187
+ return parsed.data
166
188
  }
167
189
 
168
190
  export interface OperatorBuildView {
@@ -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
@@ -1,3 +1,7 @@
1
+ import { configuredStore } from '../../cli/configured-store'
2
+ import { projectToken } from '../../cli/project-credentials'
3
+ import { resolveRepoOrigin } from '../../cli/repo-state'
4
+ import { spawnExec } from '../workspace/git-worktree'
1
5
  /**
2
6
  * TicketSource factory (SPEC §3.2, §13): construct the builtin or plugin source
3
7
  * named by [tickets]. Plugin credentials, like LINEAR_API_KEY, come only from
@@ -28,7 +32,7 @@ export async function createTicketSource(
28
32
  plugins: PluginRegistry = createPluginRegistry(),
29
33
  ): Promise<TicketSource> {
30
34
  if (config.source === 'hosted') {
31
- const rawUrl = env.AB_STORE?.trim()
35
+ const rawUrl = env.AB_STORE?.trim() || configuredStore(targetRepo)
32
36
  if (!rawUrl) {
33
37
  throw new Error(
34
38
  'AB_STORE is not set — required when [tickets].source = "hosted"; set it to the hosted service HTTP(S) URL',
@@ -43,10 +47,14 @@ export async function createTicketSource(
43
47
  if (serviceUrl.protocol !== 'http:' && serviceUrl.protocol !== 'https:') {
44
48
  throw new Error('AB_STORE must be an HTTP(S) URL when [tickets].source = "hosted"')
45
49
  }
46
- const token = env.AB_TOKEN?.trim()
50
+ const token = projectToken(
51
+ rawUrl,
52
+ (await resolveRepoOrigin(targetRepo, spawnExec)) ?? targetRepo,
53
+ env,
54
+ )
47
55
  if (!token) {
48
56
  throw new Error(
49
- 'AB_TOKEN is not set — an operator-scope token is required when [tickets].source = "hosted"',
57
+ 'No project credential; run ab connect (or set AB_TOKEN) — a project credential is required when [tickets].source = "hosted"',
50
58
  )
51
59
  }
52
60
  if (config.teamKey === undefined) {
@@ -9,6 +9,3 @@ export {
9
9
  hostedTicketRequestSchemas,
10
10
  } from './remote-protocol'
11
11
  export type { HostedTicketContext, HostedTicketOperation } from './remote-protocol'
12
- // The hosted service composes the already live-proven Linear adapter; provider
13
- // credentials remain server-side.
14
- export { LinearTicketSource } from './linear'
@@ -4,6 +4,7 @@ import { ticketRefSchema } from '../../ontology'
4
4
  /** Repository-owned policy sent with every hosted ticket operation. */
5
5
  export const hostedTicketContextSchema = z.strictObject({
6
6
  teamKey: z.string().min(1),
7
+ repo: z.string().min(1).optional(),
7
8
  claimedState: z.string().min(1).optional(),
8
9
  createState: z.string().min(1).optional(),
9
10
  })
@@ -49,6 +49,7 @@ export class HostedTicketSource implements TicketSource {
49
49
  this.fetchFn = options.fetchFn ?? fetch
50
50
  this.context = {
51
51
  teamKey: options.teamKey,
52
+ ...(options.repo !== undefined ? { repo: options.repo } : {}),
52
53
  ...(options.claimedState !== undefined ? { claimedState: options.claimedState } : {}),
53
54
  ...(options.createState !== undefined ? { createState: options.createState } : {}),
54
55
  }
@@ -114,8 +114,12 @@ export type ExecutionObservation =
114
114
  * provider may implement this by starting the fixed build process beside its
115
115
  * checkout; the kernel need not know where that is. Every provider owns full
116
116
  * teardown of the environment and descendants it starts before completion. */
117
+ export interface ExecutionLaunchAuth {
118
+ token: string
119
+ }
120
+
117
121
  export interface BuildExecution {
118
- start(input: BuildExecutionStart): Promise<BuildExecutionHandle>
122
+ start(input: BuildExecutionStart, auth?: ExecutionLaunchAuth): Promise<BuildExecutionHandle>
119
123
  /** Optional liveness observation of a previously recorded execution, from
120
124
  * provider state alone. Absent = this provider cannot observe; callers
121
125
  * supervise exactly as before ("cannot observe" never means "act"). */