@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
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomUUID } from 'node:crypto'
|
|
2
|
+
import { access, readFile, rename, unlink, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { constants } from 'node:fs'
|
|
4
|
+
import { dirname, join } from 'node:path'
|
|
5
|
+
import { parse } from 'smol-toml'
|
|
6
|
+
import {
|
|
7
|
+
normalizeConnectionRepository,
|
|
8
|
+
normalizeServiceURL,
|
|
9
|
+
type ConnectionStarted,
|
|
10
|
+
type ConnectionOutcome,
|
|
11
|
+
} from '../connection'
|
|
12
|
+
import type { Exec } from '../ports/workspace/git-worktree'
|
|
13
|
+
import { resolveMainRepo } from './repo-state'
|
|
14
|
+
import { ProjectCredentials } from './project-credentials'
|
|
15
|
+
|
|
16
|
+
interface ConnectOptions {
|
|
17
|
+
cwd: string
|
|
18
|
+
env: Record<string, string | undefined>
|
|
19
|
+
exec: Exec
|
|
20
|
+
print: (message: string) => void
|
|
21
|
+
signal?: AbortSignal
|
|
22
|
+
fetch?: (input: string, init?: RequestInit) => Promise<Response>
|
|
23
|
+
browser?: (url: string) => Promise<void>
|
|
24
|
+
sleep?: (ms: number) => Promise<void>
|
|
25
|
+
now?: () => number
|
|
26
|
+
}
|
|
27
|
+
function requireOperator(env: ConnectOptions['env']) {
|
|
28
|
+
if (
|
|
29
|
+
['AB_BUILD', 'AB_PHASE', 'AB_SESSION', 'AB_HARVEST', 'AB_REPO'].some(
|
|
30
|
+
(key) => env[key] !== undefined,
|
|
31
|
+
)
|
|
32
|
+
)
|
|
33
|
+
throw new Error('connect and disconnect are unavailable inside build or harvest sessions')
|
|
34
|
+
}
|
|
35
|
+
async function project(opts: ConnectOptions) {
|
|
36
|
+
requireOperator(opts.env)
|
|
37
|
+
const checkout = await resolveMainRepo(opts.cwd, opts.exec)
|
|
38
|
+
const remote = await opts.exec(['git', 'remote', 'get-url', 'origin'], { cwd: checkout })
|
|
39
|
+
if (remote.exitCode !== 0 || !remote.stdout.trim())
|
|
40
|
+
throw new Error('A network origin remote is required; add an origin before running ab connect')
|
|
41
|
+
const repo = normalizeConnectionRepository(remote.stdout.trim())
|
|
42
|
+
const path = join(checkout, 'autobuild.toml')
|
|
43
|
+
const text = await readFile(path, 'utf8')
|
|
44
|
+
const table = parse(text)
|
|
45
|
+
const store = table.store as { url?: unknown } | undefined
|
|
46
|
+
if (store && (typeof store !== 'object' || Array.isArray(store)))
|
|
47
|
+
throw new Error('Invalid [store] configuration')
|
|
48
|
+
const service = store?.url === undefined ? undefined : normalizeServiceURL(String(store.url))
|
|
49
|
+
return { checkout, repo, path, text, table, service }
|
|
50
|
+
}
|
|
51
|
+
async function request(
|
|
52
|
+
opts: ConnectOptions,
|
|
53
|
+
service: string,
|
|
54
|
+
path: string,
|
|
55
|
+
body?: unknown,
|
|
56
|
+
token?: string,
|
|
57
|
+
) {
|
|
58
|
+
const response = await (opts.fetch ?? fetch)(`${service}/connect/v1/${path}`, {
|
|
59
|
+
method: body === undefined ? 'GET' : 'POST',
|
|
60
|
+
headers: {
|
|
61
|
+
'content-type': 'application/json',
|
|
62
|
+
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
63
|
+
},
|
|
64
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
65
|
+
signal: AbortSignal.timeout(15000),
|
|
66
|
+
redirect: 'error',
|
|
67
|
+
})
|
|
68
|
+
return response
|
|
69
|
+
}
|
|
70
|
+
async function revoke(opts: ConnectOptions, service: string, token: string) {
|
|
71
|
+
const response = await request(opts, service, 'revoke', {}, token)
|
|
72
|
+
if (!response.ok && response.status !== 401)
|
|
73
|
+
throw new Error('Remote revocation failed; retry ab disconnect when the service is reachable')
|
|
74
|
+
}
|
|
75
|
+
async function atomicConfig(path: string, before: string, after: string) {
|
|
76
|
+
const temporary = `${path}.${randomUUID()}.tmp`
|
|
77
|
+
try {
|
|
78
|
+
await writeFile(temporary, after, { flag: 'wx' })
|
|
79
|
+
if ((await readFile(path, 'utf8')) !== before)
|
|
80
|
+
throw new Error('autobuild.toml changed during connection; retry ab connect')
|
|
81
|
+
await rename(temporary, path)
|
|
82
|
+
} finally {
|
|
83
|
+
await unlink(temporary).catch(() => {})
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
export async function abConnect(raw: string | undefined, opts: ConnectOptions): Promise<void> {
|
|
87
|
+
const p = await project(opts)
|
|
88
|
+
const credentials = new ProjectCredentials(opts.env)
|
|
89
|
+
if (raw === undefined) {
|
|
90
|
+
if (!p.service) {
|
|
91
|
+
opts.print(
|
|
92
|
+
`Project: ${p.repo}\nService: unconfigured\nCredential: missing; run ab connect <url>`,
|
|
93
|
+
)
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
const entry = credentials.load(p.service, p.repo)
|
|
97
|
+
let status = 'missing; run ab connect'
|
|
98
|
+
if (entry) {
|
|
99
|
+
try {
|
|
100
|
+
const response = await request(opts, p.service, 'status', undefined, entry.token)
|
|
101
|
+
if (response.ok) {
|
|
102
|
+
const body = (await response.json()) as {
|
|
103
|
+
repo?: string
|
|
104
|
+
credentialId?: string
|
|
105
|
+
accepted?: boolean
|
|
106
|
+
}
|
|
107
|
+
if (body.repo !== p.repo || body.credentialId !== entry.id || body.accepted !== true)
|
|
108
|
+
throw new Error('Invalid status response')
|
|
109
|
+
}
|
|
110
|
+
status = response.ok
|
|
111
|
+
? 'accepted'
|
|
112
|
+
: response.status === 401 || response.status === 403
|
|
113
|
+
? 'rejected; run ab connect'
|
|
114
|
+
: 'unreachable'
|
|
115
|
+
} catch {
|
|
116
|
+
status = 'unreachable'
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
opts.print(`Service: ${p.service}\nProject: ${p.repo}\nCredential: ${status}`)
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
const service = normalizeServiceURL(raw)
|
|
123
|
+
if (p.service && p.service !== service)
|
|
124
|
+
throw new Error(
|
|
125
|
+
'Configured [store] url differs; update it explicitly before connecting to another service',
|
|
126
|
+
)
|
|
127
|
+
await access(p.path, constants.W_OK)
|
|
128
|
+
await access(dirname(p.path), constants.W_OK)
|
|
129
|
+
const previous = credentials.load(service, p.repo)
|
|
130
|
+
const id = randomBytes(16).toString('hex'),
|
|
131
|
+
secret = randomBytes(32).toString('hex')
|
|
132
|
+
const token = `abp_1_${id}_${secret}`
|
|
133
|
+
const response = await request(opts, service, 'requests', {
|
|
134
|
+
repo: p.repo,
|
|
135
|
+
credentialId: id,
|
|
136
|
+
digest: createHash('sha256').update(secret).digest('hex'),
|
|
137
|
+
})
|
|
138
|
+
if (!response.ok)
|
|
139
|
+
throw new Error('Could not start connection approval; verify the web-enabled service URL')
|
|
140
|
+
const started = (await response.json()) as ConnectionStarted
|
|
141
|
+
if (
|
|
142
|
+
!/^[a-f0-9]{32}$/.test(started.id) ||
|
|
143
|
+
!/^[a-f0-9]{64}$/.test(started.capability) ||
|
|
144
|
+
!/^[A-F0-9]{10}$/.test(started.code) ||
|
|
145
|
+
!Number.isFinite(Date.parse(started.expiresAt))
|
|
146
|
+
)
|
|
147
|
+
throw new Error('Invalid connection response')
|
|
148
|
+
const verification = `${service}/connect?code=${started.code}`
|
|
149
|
+
opts.print(`Approve project ${p.repo}\nOpen ${verification}\nCode: ${started.code}`)
|
|
150
|
+
try {
|
|
151
|
+
if (opts.browser) await opts.browser(verification)
|
|
152
|
+
else {
|
|
153
|
+
const command =
|
|
154
|
+
process.platform === 'darwin'
|
|
155
|
+
? ['open', verification]
|
|
156
|
+
: process.platform === 'win32'
|
|
157
|
+
? ['rundll32', 'url.dll,FileProtocolHandler', verification]
|
|
158
|
+
: ['xdg-open', verification]
|
|
159
|
+
const child = Bun.spawn(command, { stdout: 'ignore', stderr: 'ignore' })
|
|
160
|
+
child.unref()
|
|
161
|
+
}
|
|
162
|
+
} catch {
|
|
163
|
+
opts.print('Browser could not be opened. Use the URL and code on another device.')
|
|
164
|
+
}
|
|
165
|
+
const capability = { id: started.id, capability: started.capability }
|
|
166
|
+
const now = opts.now ?? Date.now
|
|
167
|
+
const sleep = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)))
|
|
168
|
+
const interval = Math.max(5, Math.min(30, started.interval || 5)) * 1000
|
|
169
|
+
let approved = false
|
|
170
|
+
let configChanged = false
|
|
171
|
+
let saved = false
|
|
172
|
+
try {
|
|
173
|
+
let outcome: ConnectionOutcome | undefined
|
|
174
|
+
while (!opts.signal?.aborted && now() <= Date.parse(started.expiresAt) + interval) {
|
|
175
|
+
await sleep(interval)
|
|
176
|
+
const polled = await request(opts, service, 'poll', capability)
|
|
177
|
+
if (polled.status === 429) continue
|
|
178
|
+
if (!polled.ok) throw new Error('Approval polling failed; retry ab connect')
|
|
179
|
+
outcome = (await polled.json()) as ConnectionOutcome
|
|
180
|
+
if (outcome.status !== 'pending') break
|
|
181
|
+
}
|
|
182
|
+
if (!outcome || outcome.status === 'pending' || opts.signal?.aborted) {
|
|
183
|
+
const cancelled = await request(opts, service, 'cancel', capability)
|
|
184
|
+
if (!cancelled.ok)
|
|
185
|
+
throw new Error(
|
|
186
|
+
'Could not confirm cancellation; use the owner credential list to check this connection',
|
|
187
|
+
)
|
|
188
|
+
opts.print(
|
|
189
|
+
opts.signal?.aborted
|
|
190
|
+
? 'Connection cancelled; local configuration unchanged.'
|
|
191
|
+
: 'Connection timed out; local configuration unchanged.',
|
|
192
|
+
)
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
if (outcome.status !== 'approved') {
|
|
196
|
+
opts.print(`Connection ${outcome.status}; local configuration unchanged.`)
|
|
197
|
+
return
|
|
198
|
+
}
|
|
199
|
+
approved = true
|
|
200
|
+
if (outcome.repo !== p.repo || outcome.credentialId !== id)
|
|
201
|
+
throw new Error('Approval identity mismatch')
|
|
202
|
+
if (!p.service) {
|
|
203
|
+
// Dotted root key also supports an existing empty [store] table after it.
|
|
204
|
+
const after = `store.url = ${JSON.stringify(service)}\n${p.text}`
|
|
205
|
+
const parsed = parse(after)
|
|
206
|
+
if ((parsed.store as { url?: string })?.url !== service)
|
|
207
|
+
throw new Error('Cannot safely update [store] url; add it manually and retry')
|
|
208
|
+
await atomicConfig(p.path, p.text, after)
|
|
209
|
+
configChanged = true
|
|
210
|
+
}
|
|
211
|
+
credentials.save({ service, repo: p.repo, id, token })
|
|
212
|
+
saved = true
|
|
213
|
+
if (previous) {
|
|
214
|
+
try {
|
|
215
|
+
await revoke(opts, service, previous.token)
|
|
216
|
+
} catch {
|
|
217
|
+
opts.print(
|
|
218
|
+
`Connected, but the previous credential ${previous.id} could not be revoked; revoke it using the service administrative tools.`,
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
opts.print(`Connected ${p.repo} to ${service}.`)
|
|
223
|
+
if (configChanged) opts.print(`Updated ${p.path}; review and commit this non-secret setting.`)
|
|
224
|
+
} catch (error) {
|
|
225
|
+
if (!saved) {
|
|
226
|
+
try {
|
|
227
|
+
const cancelled = await request(opts, service, 'cancel', capability)
|
|
228
|
+
if (!cancelled.ok) throw new Error()
|
|
229
|
+
} catch {
|
|
230
|
+
opts.print(
|
|
231
|
+
`Could not confirm remote cleanup for credential ${id}; check the service administrative credential list.`,
|
|
232
|
+
)
|
|
233
|
+
}
|
|
234
|
+
if (configChanged) {
|
|
235
|
+
try {
|
|
236
|
+
await atomicConfig(p.path, `store.url = ${JSON.stringify(service)}\n${p.text}`, p.text)
|
|
237
|
+
} catch {
|
|
238
|
+
opts.print('Restore [store] url manually if needed; configuration changed concurrently.')
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
throw new Error(
|
|
243
|
+
approved
|
|
244
|
+
? 'Connection could not be saved; previous credential retained. Repair local storage and run ab connect again.'
|
|
245
|
+
: (error as Error).message,
|
|
246
|
+
)
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
export async function abDisconnect(opts: ConnectOptions): Promise<void> {
|
|
250
|
+
const p = await project(opts)
|
|
251
|
+
if (!p.service) throw new Error('No configured service')
|
|
252
|
+
const credentials = new ProjectCredentials(opts.env)
|
|
253
|
+
const entry = credentials.load(p.service, p.repo)
|
|
254
|
+
if (!entry) {
|
|
255
|
+
opts.print('No stored credential; project configuration and service data retained.')
|
|
256
|
+
return
|
|
257
|
+
}
|
|
258
|
+
await revoke(opts, p.service, entry.token)
|
|
259
|
+
credentials.remove(p.service, p.repo)
|
|
260
|
+
opts.print('Disconnected; project registration, data, and [store] url retained.')
|
|
261
|
+
}
|
|
@@ -116,11 +116,9 @@ export interface PipelineStep {
|
|
|
116
116
|
/** Absent ⇒ the step has never run in the current spec scope ⇒ no time is
|
|
117
117
|
* shown (AC 6). The renderer composes the elapsed segment from this. */
|
|
118
118
|
timing?: StepTiming
|
|
119
|
-
/** Display-only
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
* build is current at merge with effective auto-merge off; the terminal
|
|
123
|
-
* row/detail and the web row/detail render the same words. */
|
|
119
|
+
/** Display-only policy skip reason or parked merge explanation. The shared
|
|
120
|
+
* row/detail renderers use this wrapping prose segment for longer reasons;
|
|
121
|
+
* it is never consulted for routing. */
|
|
124
122
|
reason?: string
|
|
125
123
|
}
|
|
126
124
|
|
|
@@ -752,6 +750,7 @@ export function projectBuildFromFacts(
|
|
|
752
750
|
: stepResults.some((r) => r.outcome === 'skipped')
|
|
753
751
|
? 'skipped'
|
|
754
752
|
: undefined,
|
|
753
|
+
reason: stepResults.find((r) => r.skipCause === 'post-reconcile-policy')?.reason,
|
|
755
754
|
count,
|
|
756
755
|
timing: timingFor(intervals, phase, cycleSince, frozenNow),
|
|
757
756
|
}),
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import { RemoteBuildStore } from '../store/remote/client'
|
|
2
|
+
import { delegateCredential } from '../store/remote/credentials'
|
|
3
|
+
import { isRemoteStoreRef } from './repo-state'
|
|
4
|
+
import { projectToken } from './project-credentials'
|
|
5
|
+
import { settleSummaryRequests } from './pr-summary-publication'
|
|
1
6
|
import { readDispatcherLiveness, StandbyIdentityResolver } from './dispatcher-liveness'
|
|
2
7
|
import { formatDispatcherIdentity } from '../kernel/dispatcher-liveness'
|
|
3
8
|
/**
|
|
@@ -182,18 +187,6 @@ export { DISPATCHER_EFFECTIVE_CONFIG_ARTIFACT } from '../store/retention'
|
|
|
182
187
|
// Local uses within this module.
|
|
183
188
|
import { DISPATCHER_EFFECTIVE_CONFIG_ARTIFACT as _effectiveConfigKind } from '../store/retention'
|
|
184
189
|
|
|
185
|
-
/** JSON encoding in the config schema's declarative input shape. Parsed Config
|
|
186
|
-
* has normalized `{steps, stepConfigs}` sections; flattening named step tables
|
|
187
|
-
* lets the frontend validate the artifact with the same strict configSchema. */
|
|
188
|
-
function effectiveConfigContent(config: Config): string {
|
|
189
|
-
const { verify, finalize, ...root } = config
|
|
190
|
-
return JSON.stringify({
|
|
191
|
-
...root,
|
|
192
|
-
verify: { steps: verify.steps, ...verify.stepConfigs },
|
|
193
|
-
finalize: { steps: finalize.steps, ...finalize.stepConfigs },
|
|
194
|
-
})
|
|
195
|
-
}
|
|
196
|
-
|
|
197
190
|
/** The pre-build naming prompt. Its output is only a proposal: dispatcher.ts
|
|
198
191
|
* owns strict validation, timeout/failure fallback, and store-wide uniqueness. */
|
|
199
192
|
export function slugNamingPrompt(spec: string): string {
|
|
@@ -212,7 +205,7 @@ export function slugNamingPrompt(spec: string): string {
|
|
|
212
205
|
function definedEnv(env: Record<string, string | undefined>): Record<string, string> {
|
|
213
206
|
const defined: Record<string, string> = {}
|
|
214
207
|
for (const [key, value] of Object.entries(env)) {
|
|
215
|
-
if (value !== undefined) defined[key] = value
|
|
208
|
+
if (value !== undefined && key !== 'AB_TOKEN') defined[key] = value
|
|
216
209
|
}
|
|
217
210
|
return defined
|
|
218
211
|
}
|
|
@@ -264,6 +257,7 @@ interface ResumePrompt {
|
|
|
264
257
|
/** The real adapters the loop drives — resolved by `wire` (default: the
|
|
265
258
|
* production ports; tests inject fakes). */
|
|
266
259
|
export interface DispatchWiring {
|
|
260
|
+
credentialIssuer?: typeof delegateCredential
|
|
267
261
|
store: BuildStore
|
|
268
262
|
tickets: TicketSource
|
|
269
263
|
forge: Forge
|
|
@@ -282,7 +276,7 @@ export interface DispatchWiring {
|
|
|
282
276
|
* same store as `store`, so an agent's `ab` commands write where the
|
|
283
277
|
* dispatcher reads. */
|
|
284
278
|
storeRef: string
|
|
285
|
-
/**
|
|
279
|
+
/** Private parent authority for remote exchange; never passed to execution. */
|
|
286
280
|
token?: string
|
|
287
281
|
ids: IdSource
|
|
288
282
|
uuids: UuidSource
|
|
@@ -2231,7 +2225,7 @@ class DispatchLoop {
|
|
|
2231
2225
|
}
|
|
2232
2226
|
|
|
2233
2227
|
const { store, tickets, runtimes, ids, uuids, clock, storeRef, token } = this.wiring
|
|
2234
|
-
const
|
|
2228
|
+
const harvestDeps = {
|
|
2235
2229
|
store,
|
|
2236
2230
|
tickets,
|
|
2237
2231
|
config: this.currentConfig().config,
|
|
@@ -2246,16 +2240,66 @@ class DispatchLoop {
|
|
|
2246
2240
|
...(this.repoLeaseHolder !== undefined ? { leaseHolder: this.repoLeaseHolder } : {}),
|
|
2247
2241
|
sessionEnv: {
|
|
2248
2242
|
AB_STORE: storeRef,
|
|
2249
|
-
...(token !== undefined ? { AB_TOKEN: token } : {}),
|
|
2250
2243
|
},
|
|
2251
2244
|
opts: {
|
|
2252
2245
|
maxRecoveryAttempts: this.maxHarvestRecoveryAttempts,
|
|
2253
2246
|
},
|
|
2254
|
-
}
|
|
2247
|
+
}
|
|
2255
2248
|
|
|
2256
2249
|
let tracked: Promise<void>
|
|
2257
|
-
tracked =
|
|
2258
|
-
.run()
|
|
2250
|
+
tracked = (async () => {
|
|
2251
|
+
if (!/^https?:/i.test(storeRef)) return new HarvestRunner(harvestDeps).run()
|
|
2252
|
+
const prepared = await new HarvestRunner(harvestDeps).run(true)
|
|
2253
|
+
if (prepared.outcome !== 'prepared') return prepared
|
|
2254
|
+
await this.repoView.appendRepo(this.repoIdentity, {
|
|
2255
|
+
actor: DISPATCHER,
|
|
2256
|
+
type: 'harvest.execution.authorized',
|
|
2257
|
+
payload: {
|
|
2258
|
+
execution: harvestDeps.instance,
|
|
2259
|
+
run: prepared.run,
|
|
2260
|
+
launch: prepared.launch,
|
|
2261
|
+
leaseHolder: this.repoLeaseHolder ?? harvestDeps.instance,
|
|
2262
|
+
},
|
|
2263
|
+
})
|
|
2264
|
+
const scoped = await (this.wiring.credentialIssuer ?? delegateCredential)(storeRef, token, {
|
|
2265
|
+
kind: 'harvest-runner',
|
|
2266
|
+
execution: harvestDeps.instance,
|
|
2267
|
+
repo: this.repoIdentity,
|
|
2268
|
+
id: prepared.run,
|
|
2269
|
+
leaseHolder: this.repoLeaseHolder ?? harvestDeps.instance,
|
|
2270
|
+
})
|
|
2271
|
+
const runnerStore = new RemoteBuildStore({ url: storeRef, token: scoped })
|
|
2272
|
+
try {
|
|
2273
|
+
const runnerTickets =
|
|
2274
|
+
harvestDeps.config.tickets.source === 'hosted'
|
|
2275
|
+
? await createTicketSource(
|
|
2276
|
+
harvestDeps.config.tickets,
|
|
2277
|
+
{ ...this.opts.env, AB_STORE: storeRef, AB_TOKEN: scoped },
|
|
2278
|
+
this.opts.targetRepo,
|
|
2279
|
+
resolveRepoStatePaths({
|
|
2280
|
+
repo: this.repoIdentity,
|
|
2281
|
+
checkout: this.opts.targetRepo,
|
|
2282
|
+
storeRef,
|
|
2283
|
+
}).localStateRoot,
|
|
2284
|
+
this.wiring.plugins,
|
|
2285
|
+
)
|
|
2286
|
+
: harvestDeps.tickets
|
|
2287
|
+
return await new HarvestRunner({
|
|
2288
|
+
...harvestDeps,
|
|
2289
|
+
store: runnerStore,
|
|
2290
|
+
tickets: runnerTickets,
|
|
2291
|
+
preparedRun: { run: prepared.run },
|
|
2292
|
+
sessionEnv: { AB_STORE: storeRef, AB_TOKEN: scoped },
|
|
2293
|
+
}).run()
|
|
2294
|
+
} finally {
|
|
2295
|
+
await runnerStore.appendRepo(this.repoIdentity, {
|
|
2296
|
+
actor: DISPATCHER,
|
|
2297
|
+
type: 'harvest.execution.ended',
|
|
2298
|
+
payload: { execution: harvestDeps.instance, run: prepared.run },
|
|
2299
|
+
})
|
|
2300
|
+
await runnerStore.close()
|
|
2301
|
+
}
|
|
2302
|
+
})()
|
|
2259
2303
|
.then((result) => {
|
|
2260
2304
|
this.recordHarvestResult(result)
|
|
2261
2305
|
if (
|
|
@@ -2299,6 +2343,24 @@ class DispatchLoop {
|
|
|
2299
2343
|
* contract: provisioning takes minutes and must never stall a tick; the
|
|
2300
2344
|
* single-flight guard covers the whole span. Every failure is contained
|
|
2301
2345
|
* and retried on a later threshold tick. */
|
|
2346
|
+
private async prepareHarvest(instance: string): Promise<HarvestRunnerResult> {
|
|
2347
|
+
return new HarvestRunner({
|
|
2348
|
+
store: this.wiring.store,
|
|
2349
|
+
tickets: this.wiring.tickets,
|
|
2350
|
+
config: this.currentConfig().config,
|
|
2351
|
+
getConfig: () => this.currentConfig().config,
|
|
2352
|
+
runtimes: this.wiring.runtimes,
|
|
2353
|
+
repo: this.repoIdentity,
|
|
2354
|
+
workspacePath: this.opts.targetRepo,
|
|
2355
|
+
ids: this.wiring.ids,
|
|
2356
|
+
uuids: this.wiring.uuids,
|
|
2357
|
+
clock: this.wiring.clock,
|
|
2358
|
+
instance,
|
|
2359
|
+
...(this.repoLeaseHolder ? { leaseHolder: this.repoLeaseHolder } : {}),
|
|
2360
|
+
opts: { maxRecoveryAttempts: this.maxHarvestRecoveryAttempts },
|
|
2361
|
+
}).run(true)
|
|
2362
|
+
}
|
|
2363
|
+
|
|
2302
2364
|
private async launchHostedHarvest(
|
|
2303
2365
|
harvestExecution: HarvestExecution,
|
|
2304
2366
|
execution: string,
|
|
@@ -2349,13 +2411,44 @@ class DispatchLoop {
|
|
|
2349
2411
|
if (pressure.trigger === undefined) return
|
|
2350
2412
|
}
|
|
2351
2413
|
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2414
|
+
const prepared = await this.prepareHarvest(execution)
|
|
2415
|
+
if (prepared.outcome !== 'prepared') return
|
|
2416
|
+
await this.repoView.appendRepo(repo, {
|
|
2417
|
+
actor: DISPATCHER,
|
|
2418
|
+
type: 'harvest.execution.authorized',
|
|
2419
|
+
payload: {
|
|
2420
|
+
execution,
|
|
2421
|
+
run: prepared.run,
|
|
2422
|
+
launch: prepared.launch,
|
|
2423
|
+
leaseHolder: this.repoLeaseHolder ?? execution,
|
|
2424
|
+
},
|
|
2358
2425
|
})
|
|
2426
|
+
const launchAuth = /^https?:/i.test(storeRef)
|
|
2427
|
+
? {
|
|
2428
|
+
token: await (this.wiring.credentialIssuer ?? delegateCredential)(
|
|
2429
|
+
storeRef,
|
|
2430
|
+
this.wiring.token,
|
|
2431
|
+
{
|
|
2432
|
+
kind: 'harvest-runner',
|
|
2433
|
+
execution,
|
|
2434
|
+
repo,
|
|
2435
|
+
id: prepared.run,
|
|
2436
|
+
leaseHolder: this.repoLeaseHolder ?? execution,
|
|
2437
|
+
},
|
|
2438
|
+
),
|
|
2439
|
+
}
|
|
2440
|
+
: undefined
|
|
2441
|
+
handle = await harvestExecution.start(
|
|
2442
|
+
{
|
|
2443
|
+
preparedRun: { run: prepared.run },
|
|
2444
|
+
storeRef,
|
|
2445
|
+
repo,
|
|
2446
|
+
instance: execution,
|
|
2447
|
+
baseBranch: this.currentConfig().config.baseBranch,
|
|
2448
|
+
...(this.repoLeaseHolder !== undefined ? { leaseHolder: this.repoLeaseHolder } : {}),
|
|
2449
|
+
},
|
|
2450
|
+
launchAuth,
|
|
2451
|
+
)
|
|
2359
2452
|
const identity = handle.identity
|
|
2360
2453
|
if (
|
|
2361
2454
|
identity === undefined ||
|
|
@@ -2628,7 +2721,7 @@ class DispatchLoop {
|
|
|
2628
2721
|
return null
|
|
2629
2722
|
}
|
|
2630
2723
|
|
|
2631
|
-
private async settlePendingPublication(slug: string): Promise<void> {
|
|
2724
|
+
private async settlePendingPublication(slug: string, reservation?: string): Promise<void> {
|
|
2632
2725
|
// The guest appended through its own handle; settle against fresh state.
|
|
2633
2726
|
await this.repoView.refreshBuild(slug)
|
|
2634
2727
|
await settleWorkspacePublication(
|
|
@@ -2645,6 +2738,16 @@ class DispatchLoop {
|
|
|
2645
2738
|
},
|
|
2646
2739
|
slug,
|
|
2647
2740
|
)
|
|
2741
|
+
await settleSummaryRequests({
|
|
2742
|
+
store: this.repoView,
|
|
2743
|
+
forge: this.wiring.forge,
|
|
2744
|
+
slug,
|
|
2745
|
+
workspacePath: this.opts.targetRepo,
|
|
2746
|
+
storeRef: this.wiring.storeRef,
|
|
2747
|
+
clock: this.wiring.clock,
|
|
2748
|
+
holder: `summary:${this.runId}`,
|
|
2749
|
+
...(reservation === undefined ? {} : { reservation }),
|
|
2750
|
+
})
|
|
2648
2751
|
}
|
|
2649
2752
|
|
|
2650
2753
|
private async recordInfrastructureFailure(input: {
|
|
@@ -2736,13 +2839,25 @@ class DispatchLoop {
|
|
|
2736
2839
|
// A prior publication acknowledgement may have been lost after its push
|
|
2737
2840
|
// reached the durable branch. Reconcile that fact before a replacement
|
|
2738
2841
|
// runner can rerun the phase and create a non-fast-forward successor.
|
|
2739
|
-
await this.settlePendingPublication(slug)
|
|
2740
|
-
const
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2842
|
+
await this.settlePendingPublication(slug, instance)
|
|
2843
|
+
const launchAuth = /^https?:/i.test(this.wiring.storeRef)
|
|
2844
|
+
? {
|
|
2845
|
+
token: await (this.wiring.credentialIssuer ?? delegateCredential)(
|
|
2846
|
+
this.wiring.storeRef,
|
|
2847
|
+
this.wiring.token,
|
|
2848
|
+
{ kind: 'build-runner', id: slug },
|
|
2849
|
+
),
|
|
2850
|
+
}
|
|
2851
|
+
: undefined
|
|
2852
|
+
const handle = await owning.execution.start(
|
|
2853
|
+
{
|
|
2854
|
+
slug,
|
|
2855
|
+
storeRef: this.wiring.storeRef,
|
|
2856
|
+
instance,
|
|
2857
|
+
workspaceRef,
|
|
2858
|
+
},
|
|
2859
|
+
launchAuth,
|
|
2860
|
+
)
|
|
2746
2861
|
active.handle = handle
|
|
2747
2862
|
const identity = handle.identity ?? {
|
|
2748
2863
|
provider: owning.name,
|
|
@@ -3694,16 +3809,21 @@ function acceptableOriginModeStore(storeRef: string): boolean {
|
|
|
3694
3809
|
* names the operator's own machine (local development, integration harnesses).
|
|
3695
3810
|
* Requirements are validated here so a misconfigured origin-mode launch fails
|
|
3696
3811
|
* before any side effect. */
|
|
3697
|
-
async function resolveOriginModeState(
|
|
3812
|
+
async function resolveOriginModeState(
|
|
3813
|
+
opts: DispatchOpts,
|
|
3814
|
+
configStore?: string,
|
|
3815
|
+
): Promise<RepoStatePaths> {
|
|
3698
3816
|
const identity = normalizeGitRemoteUrl(opts.repository!)
|
|
3699
|
-
const selectedStore = opts.storeRef
|
|
3817
|
+
const selectedStore = opts.storeRef?.trim() || opts.env.AB_STORE?.trim() || configStore
|
|
3700
3818
|
if (selectedStore === undefined || !acceptableOriginModeStore(selectedStore)) {
|
|
3701
3819
|
throw new Error(
|
|
3702
3820
|
'origin-mode dispatch requires an HTTPS BuildStore — set AB_STORE (or --store) to the hosted Store URL',
|
|
3703
3821
|
)
|
|
3704
3822
|
}
|
|
3705
|
-
if (
|
|
3706
|
-
throw new Error(
|
|
3823
|
+
if (!projectToken(selectedStore, identity, opts.env)) {
|
|
3824
|
+
throw new Error(
|
|
3825
|
+
'origin-mode dispatch requires a project credential; run ab connect or set AB_TOKEN',
|
|
3826
|
+
)
|
|
3707
3827
|
}
|
|
3708
3828
|
// Checkout-less hosts (serverless functions, sandboxes) have no gh CLI and
|
|
3709
3829
|
// no keyring, and the only workspace provider origin mode can run —
|
|
@@ -3721,6 +3841,7 @@ async function resolveOriginModeState(opts: DispatchOpts): Promise<RepoStatePath
|
|
|
3721
3841
|
await mkdir(scratch, { recursive: true })
|
|
3722
3842
|
return resolveRepoStatePaths({
|
|
3723
3843
|
repo: identity,
|
|
3844
|
+
configStore,
|
|
3724
3845
|
checkout: scratch,
|
|
3725
3846
|
...(opts.storeRef !== undefined ? { storeRef: opts.storeRef } : {}),
|
|
3726
3847
|
...(opts.env.AB_STORE !== undefined ? { envStore: opts.env.AB_STORE } : {}),
|
|
@@ -3743,9 +3864,23 @@ export async function abDispatch(opts: DispatchOpts): Promise<void> {
|
|
|
3743
3864
|
if (opts.wire !== undefined && opts.nonStoreWire !== undefined) {
|
|
3744
3865
|
throw new Error('dispatch wire and nonStoreWire are mutually exclusive')
|
|
3745
3866
|
}
|
|
3867
|
+
const explicitOriginStore = opts.storeRef?.trim() || opts.env.AB_STORE?.trim()
|
|
3868
|
+
if (
|
|
3869
|
+
opts.repository !== undefined &&
|
|
3870
|
+
explicitOriginStore &&
|
|
3871
|
+
!acceptableOriginModeStore(explicitOriginStore)
|
|
3872
|
+
) {
|
|
3873
|
+
throw new Error(
|
|
3874
|
+
'origin-mode dispatch requires an HTTPS BuildStore — set AB_STORE (or --store) to the hosted Store URL',
|
|
3875
|
+
)
|
|
3876
|
+
}
|
|
3877
|
+
const originConfig =
|
|
3878
|
+
opts.repository !== undefined
|
|
3879
|
+
? await fetchOriginModeConfig(opts, opts.originConfigTransport)
|
|
3880
|
+
: undefined
|
|
3746
3881
|
const state =
|
|
3747
3882
|
opts.repository !== undefined
|
|
3748
|
-
? await resolveOriginModeState(opts)
|
|
3883
|
+
? await resolveOriginModeState(opts, originConfig?.config.store?.url)
|
|
3749
3884
|
: await resolveRepoState({
|
|
3750
3885
|
targetRepo: opts.targetRepo,
|
|
3751
3886
|
exec: opts.exec,
|
|
@@ -3759,6 +3894,13 @@ export async function abDispatch(opts: DispatchOpts): Promise<void> {
|
|
|
3759
3894
|
const resolvedOpts: DispatchOpts = {
|
|
3760
3895
|
...opts,
|
|
3761
3896
|
targetRepo: state.checkout,
|
|
3897
|
+
env: {
|
|
3898
|
+
...opts.env,
|
|
3899
|
+
AB_STORE: state.storeRef,
|
|
3900
|
+
...(isRemoteStoreRef(state.storeRef)
|
|
3901
|
+
? { AB_TOKEN: projectToken(state.storeRef, state.repo, opts.env) }
|
|
3902
|
+
: {}),
|
|
3903
|
+
},
|
|
3762
3904
|
storeRef: state.storeRef,
|
|
3763
3905
|
repo: opts.repo ?? state.repo,
|
|
3764
3906
|
}
|
|
@@ -3816,7 +3958,7 @@ export async function abDispatch(opts: DispatchOpts): Promise<void> {
|
|
|
3816
3958
|
if (opts.repository !== undefined) {
|
|
3817
3959
|
// Origin mode startup config: the base branch's autobuild.toml, fetched
|
|
3818
3960
|
// through the forge (default branch first — baseBranch itself is config).
|
|
3819
|
-
const fetched =
|
|
3961
|
+
const fetched = originConfig!
|
|
3820
3962
|
configContent = fetched.content
|
|
3821
3963
|
config = fetched.config
|
|
3822
3964
|
if (config.forge !== 'github') {
|
|
@@ -3930,7 +4072,7 @@ export async function abDispatch(opts: DispatchOpts): Promise<void> {
|
|
|
3930
4072
|
},
|
|
3931
4073
|
{
|
|
3932
4074
|
kind: _effectiveConfigKind,
|
|
3933
|
-
content:
|
|
4075
|
+
content: effectiveBuildConfigContent(effectiveConfig),
|
|
3934
4076
|
metadata: { run: runId, effectiveChanged },
|
|
3935
4077
|
},
|
|
3936
4078
|
],
|
|
@@ -3995,7 +4137,7 @@ export async function abDispatch(opts: DispatchOpts): Promise<void> {
|
|
|
3995
4137
|
[
|
|
3996
4138
|
{
|
|
3997
4139
|
kind: _effectiveConfigKind,
|
|
3998
|
-
content:
|
|
4140
|
+
content: effectiveBuildConfigContent(config),
|
|
3999
4141
|
metadata: { run: runId, revision: 0 },
|
|
4000
4142
|
},
|
|
4001
4143
|
],
|