@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.
- package/README.md +3 -1
- package/SPEC.md +222 -22
- package/docs/architecture.md +66 -4
- package/docs/configuration.md +105 -16
- 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 +90 -7
- package/docs/remote-store-protocol.md +189 -144
- package/docs/remote-ticket-protocol.md +107 -19
- 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/configured-store.ts +17 -0
- package/packages/core/src/cli/connect.ts +261 -0
- package/packages/core/src/cli/dashboard/model.ts +4 -5
- package/packages/core/src/cli/dispatch.ts +183 -41
- package/packages/core/src/cli/help.ts +39 -10
- 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/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 +197 -60
- package/packages/core/src/config/live.ts +3 -0
- package/packages/core/src/config/schema.ts +56 -9
- package/packages/core/src/connection.ts +86 -0
- package/packages/core/src/events/catalog.ts +10 -2
- package/packages/core/src/events/payloads.ts +44 -0
- package/packages/core/src/events/repository.ts +13 -0
- package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +130 -83
- 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 +8 -0
- package/packages/core/src/kernel/reducer.ts +8 -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 +38 -16
- 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/create.ts +11 -3
- package/packages/core/src/ports/tickets/hosted-index.ts +0 -3
- package/packages/core/src/ports/tickets/remote-protocol.ts +1 -0
- package/packages/core/src/ports/tickets/remote.ts +1 -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 +59 -10
- 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/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 +25 -0
- package/packages/core/src/store/remote/token.ts +74 -13
- package/packages/core/src/testing/index.ts +7 -0
- package/skills/guide/SKILL.md +144 -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 +126 -36
- 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,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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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(
|
|
173
|
+
raw = JSON.parse(new TextDecoder().decode(content))
|
|
152
174
|
} catch {
|
|
153
175
|
throw new OperatorQueryError(
|
|
154
176
|
'effective-config-unavailable',
|
|
155
|
-
`effective config ${
|
|
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 ${
|
|
184
|
+
`effective config ${label} is invalid: ${parsed.error.message}`,
|
|
163
185
|
)
|
|
164
186
|
}
|
|
165
|
-
return
|
|
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
|
|
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
|
+
}
|
|
@@ -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 =
|
|
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
|
-
'
|
|
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"). */
|