@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.
- package/README.md +3 -1
- package/SPEC.md +237 -23
- package/bin/ab-dispatch-kernel.ts +1 -1
- package/docs/architecture.md +66 -4
- package/docs/configuration.md +137 -19
- package/docs/hosted-dispatcher.md +17 -2
- package/docs/hosted-service.md +214 -0
- package/docs/mcp.md +8 -2
- package/docs/operator-api.md +107 -7
- package/docs/remote-store-protocol.md +189 -144
- package/docs/remote-ticket-protocol.md +111 -18
- package/docs/setup.md +173 -16
- package/docs/ticket-dependencies.md +1 -1
- package/package.json +7 -6
- package/packages/core/src/cli/attention.ts +209 -0
- package/packages/core/src/cli/binary.ts +3 -3
- package/packages/core/src/cli/configured-store.ts +17 -0
- package/packages/core/src/cli/connect.ts +261 -0
- package/packages/core/src/cli/dashboard/model.ts +8 -5
- package/packages/core/src/cli/dashboard/render.ts +37 -7
- package/packages/core/src/cli/dispatch-frontend.ts +16 -1
- package/packages/core/src/cli/dispatch-process.ts +25 -7
- package/packages/core/src/cli/dispatch.ts +247 -47
- package/packages/core/src/cli/dispatcher-liveness.ts +91 -0
- package/packages/core/src/cli/help.ts +45 -13
- package/packages/core/src/cli/init-validation.ts +27 -2
- package/packages/core/src/cli/main.ts +41 -15
- package/packages/core/src/cli/mcp.ts +7 -1
- package/packages/core/src/cli/pr-summary-publication.ts +220 -0
- package/packages/core/src/cli/pr-summary.ts +2 -0
- package/packages/core/src/cli/project-credentials.ts +132 -0
- package/packages/core/src/cli/repo-state.ts +15 -2
- package/packages/core/src/cli/repository-status.ts +45 -5
- package/packages/core/src/cli/self-update.ts +10 -0
- package/packages/core/src/cli/store-opening.ts +22 -14
- package/packages/core/src/cli/terminals.ts +0 -10
- package/packages/core/src/cli/ticket.ts +8 -1
- package/packages/core/src/cli/upgrade-compatibility.ts +80 -0
- package/packages/core/src/cli/wait.ts +84 -17
- package/packages/core/src/cli/watch.ts +350 -68
- package/packages/core/src/config/live.ts +4 -0
- package/packages/core/src/config/schema.ts +65 -11
- package/packages/core/src/connection.ts +86 -0
- package/packages/core/src/events/catalog.ts +10 -2
- package/packages/core/src/events/globs.ts +8 -3
- package/packages/core/src/events/payloads.ts +44 -0
- package/packages/core/src/events/repository.ts +64 -2
- package/packages/core/src/integration/harness.ts +2 -1
- package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +148 -83
- package/packages/core/src/kernel/dispatch-status.ts +9 -1
- package/packages/core/src/kernel/dispatcher-liveness.ts +70 -0
- package/packages/core/src/kernel/engine.ts +24 -0
- package/packages/core/src/kernel/generators/build-log.ts +10 -0
- package/packages/core/src/kernel/log-index.ts +17 -0
- package/packages/core/src/kernel/pr-summary.ts +89 -0
- package/packages/core/src/kernel/reducer-registry.ts +12 -0
- package/packages/core/src/kernel/reducer.ts +8 -0
- package/packages/core/src/kernel/ticket-source-health.ts +30 -0
- package/packages/core/src/ontology.ts +1 -0
- package/packages/core/src/operator/index.ts +7 -0
- package/packages/core/src/operator/query.ts +44 -19
- package/packages/core/src/operator/ticket-source-backend.ts +1 -0
- package/packages/core/src/operator/tickets.ts +6 -8
- package/packages/core/src/plugin-sdk/index.ts +1 -0
- package/packages/core/src/plugins/manifest.ts +5 -3
- package/packages/core/src/plugins/runtimes.ts +3 -1
- package/packages/core/src/ports/forge/fake.ts +13 -1
- package/packages/core/src/ports/forge/github-transport.ts +36 -1
- package/packages/core/src/ports/forge/github.ts +29 -5
- package/packages/core/src/ports/forge/local-git.ts +12 -4
- package/packages/core/src/ports/forge/summary-marker.ts +8 -0
- package/packages/core/src/ports/runner/session-env.ts +1 -0
- package/packages/core/src/ports/tickets/contract.ts +25 -0
- package/packages/core/src/ports/tickets/create.ts +11 -3
- package/packages/core/src/ports/tickets/fake.ts +10 -8
- package/packages/core/src/ports/tickets/file.ts +1 -0
- package/packages/core/src/ports/tickets/hosted-index.ts +0 -3
- package/packages/core/src/ports/tickets/linear.ts +241 -49
- package/packages/core/src/ports/tickets/remote-protocol.ts +3 -0
- package/packages/core/src/ports/tickets/remote.ts +1 -0
- package/packages/core/src/ports/types.ts +17 -0
- package/packages/core/src/ports/workspace/build-execution.ts +5 -1
- package/packages/core/src/ports/workspace/create.ts +11 -2
- package/packages/core/src/ports/workspace/distribution-archive.ts +3 -5
- package/packages/core/src/ports/workspace/harvest-execution.ts +3 -1
- package/packages/core/src/ports/workspace/local-build-execution.ts +8 -1
- package/packages/core/src/ports/workspace/vercel-sandbox.ts +47 -19
- package/packages/core/src/processes/build-execution-state.ts +1 -1
- package/packages/core/src/processes/build-runner.ts +87 -26
- package/packages/core/src/processes/dispatch-failure-budget.ts +83 -0
- package/packages/core/src/processes/dispatcher.ts +336 -83
- package/packages/core/src/processes/harvest-child.ts +1 -0
- package/packages/core/src/processes/harvest-execution-state.ts +19 -2
- package/packages/core/src/processes/harvest-runner.ts +142 -100
- package/packages/core/src/processes/phase-failure-budget.ts +1 -0
- package/packages/core/src/processes/publication-loss.ts +18 -1
- package/packages/core/src/processes/repo-view.ts +2 -1
- package/packages/core/src/store/adapter.ts +1 -0
- package/packages/core/src/store/contract.ts +109 -0
- package/packages/core/src/store/digest.ts +14 -2
- package/packages/core/src/store/local/store.ts +18 -5
- package/packages/core/src/store/remote/client.ts +1 -1
- package/packages/core/src/store/remote/compatibility.ts +90 -0
- package/packages/core/src/store/remote/credentials.ts +28 -0
- package/packages/core/src/store/remote/index.ts +14 -1
- package/packages/core/src/store/remote/protocol.ts +26 -0
- package/packages/core/src/store/remote/token.ts +74 -13
- package/packages/core/src/store/repo-state-events.ts +49 -59
- package/packages/core/src/store/types.ts +5 -1
- package/packages/core/src/testing/index.ts +7 -0
- package/skills/guide/SKILL.md +175 -26
- package/skills/guide/references/operator-api.md +135 -13
- package/skills/guide/references/plugin-authoring.md +10 -2
- package/skills/guide/references/remote-store-protocol.md +189 -144
- package/skills/guide/references/remote-ticket-protocol.md +111 -18
- package/skills/guide/references/setup.md +173 -16
- package/skills/guide/references/ticket-dependencies.md +1 -1
- package/skills/operate/SKILL.md +159 -34
- package/templates/hosted-service/package.json +13 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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(
|
|
173
|
+
raw = JSON.parse(new TextDecoder().decode(content))
|
|
151
174
|
} catch {
|
|
152
175
|
throw new OperatorQueryError(
|
|
153
176
|
'effective-config-unavailable',
|
|
154
|
-
`effective config ${
|
|
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 ${
|
|
184
|
+
`effective config ${label} is invalid: ${parsed.error.message}`,
|
|
162
185
|
)
|
|
163
186
|
}
|
|
164
|
-
return
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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
|
-
/**
|
|
55
|
-
|
|
56
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
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
|
-
|
|
594
|
-
|
|
595
|
-
|
|
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
|
+
}
|