@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
|
@@ -1,3 +1,10 @@
|
|
|
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'
|
|
6
|
+
import { readDispatcherLiveness, StandbyIdentityResolver } from './dispatcher-liveness'
|
|
7
|
+
import { formatDispatcherIdentity } from '../kernel/dispatcher-liveness'
|
|
1
8
|
/**
|
|
2
9
|
* `ab dispatch` — the operator's entry into the outer loop (SPEC §3.3, §12).
|
|
3
10
|
* Runs OUTSIDE build sessions like init/upgrade/ticket (§16.3): it takes a
|
|
@@ -180,18 +187,6 @@ export { DISPATCHER_EFFECTIVE_CONFIG_ARTIFACT } from '../store/retention'
|
|
|
180
187
|
// Local uses within this module.
|
|
181
188
|
import { DISPATCHER_EFFECTIVE_CONFIG_ARTIFACT as _effectiveConfigKind } from '../store/retention'
|
|
182
189
|
|
|
183
|
-
/** JSON encoding in the config schema's declarative input shape. Parsed Config
|
|
184
|
-
* has normalized `{steps, stepConfigs}` sections; flattening named step tables
|
|
185
|
-
* lets the frontend validate the artifact with the same strict configSchema. */
|
|
186
|
-
function effectiveConfigContent(config: Config): string {
|
|
187
|
-
const { verify, finalize, ...root } = config
|
|
188
|
-
return JSON.stringify({
|
|
189
|
-
...root,
|
|
190
|
-
verify: { steps: verify.steps, ...verify.stepConfigs },
|
|
191
|
-
finalize: { steps: finalize.steps, ...finalize.stepConfigs },
|
|
192
|
-
})
|
|
193
|
-
}
|
|
194
|
-
|
|
195
190
|
/** The pre-build naming prompt. Its output is only a proposal: dispatcher.ts
|
|
196
191
|
* owns strict validation, timeout/failure fallback, and store-wide uniqueness. */
|
|
197
192
|
export function slugNamingPrompt(spec: string): string {
|
|
@@ -210,7 +205,7 @@ export function slugNamingPrompt(spec: string): string {
|
|
|
210
205
|
function definedEnv(env: Record<string, string | undefined>): Record<string, string> {
|
|
211
206
|
const defined: Record<string, string> = {}
|
|
212
207
|
for (const [key, value] of Object.entries(env)) {
|
|
213
|
-
if (value !== undefined) defined[key] = value
|
|
208
|
+
if (value !== undefined && key !== 'AB_TOKEN') defined[key] = value
|
|
214
209
|
}
|
|
215
210
|
return defined
|
|
216
211
|
}
|
|
@@ -262,6 +257,7 @@ interface ResumePrompt {
|
|
|
262
257
|
/** The real adapters the loop drives — resolved by `wire` (default: the
|
|
263
258
|
* production ports; tests inject fakes). */
|
|
264
259
|
export interface DispatchWiring {
|
|
260
|
+
credentialIssuer?: typeof delegateCredential
|
|
265
261
|
store: BuildStore
|
|
266
262
|
tickets: TicketSource
|
|
267
263
|
forge: Forge
|
|
@@ -280,7 +276,7 @@ export interface DispatchWiring {
|
|
|
280
276
|
* same store as `store`, so an agent's `ab` commands write where the
|
|
281
277
|
* dispatcher reads. */
|
|
282
278
|
storeRef: string
|
|
283
|
-
/**
|
|
279
|
+
/** Private parent authority for remote exchange; never passed to execution. */
|
|
284
280
|
token?: string
|
|
285
281
|
ids: IdSource
|
|
286
282
|
uuids: UuidSource
|
|
@@ -691,6 +687,9 @@ class DispatchLoop {
|
|
|
691
687
|
* discarded on Escape. All read semantics live in the shared feed module. */
|
|
692
688
|
private sessionFeed: SessionStreamFeed | undefined
|
|
693
689
|
private warningLine: string | undefined
|
|
690
|
+
/** Invocation-local ownership; independent of every notice slot. */
|
|
691
|
+
private standby: string | undefined
|
|
692
|
+
private standbyResolver: StandbyIdentityResolver | undefined
|
|
694
693
|
/** Process-local, persistent release notice. It never shares the replaceable
|
|
695
694
|
* warning slot and is re-applied to every store projection. */
|
|
696
695
|
private availableUpgrade: string | undefined
|
|
@@ -1190,6 +1189,7 @@ class DispatchLoop {
|
|
|
1190
1189
|
selection: _oldSelection,
|
|
1191
1190
|
warningLines: _oldWarningLines,
|
|
1192
1191
|
availableUpgrade: _oldAvailableUpgrade,
|
|
1192
|
+
standby: _oldStandby,
|
|
1193
1193
|
resumeInput: _oldResumeInput,
|
|
1194
1194
|
abortConfirmation: _oldAbortConfirmation,
|
|
1195
1195
|
view: _oldView,
|
|
@@ -1204,6 +1204,7 @@ class DispatchLoop {
|
|
|
1204
1204
|
]
|
|
1205
1205
|
let effective: DashboardModel = {
|
|
1206
1206
|
...base,
|
|
1207
|
+
...(this.standby !== undefined ? { standby: this.standby } : {}),
|
|
1207
1208
|
...(this.availableUpgrade !== undefined ? { availableUpgrade: this.availableUpgrade } : {}),
|
|
1208
1209
|
...(warningLines.length > 0 ? { warningLines } : {}),
|
|
1209
1210
|
...(this.selection !== undefined ? { selection: this.selection } : {}),
|
|
@@ -1258,7 +1259,13 @@ class DispatchLoop {
|
|
|
1258
1259
|
const limit =
|
|
1259
1260
|
terminal === undefined
|
|
1260
1261
|
? 0
|
|
1261
|
-
: sessionScrollLimit(
|
|
1262
|
+
: sessionScrollLimit(
|
|
1263
|
+
view,
|
|
1264
|
+
terminal.columns,
|
|
1265
|
+
paintableRows(terminal.rows),
|
|
1266
|
+
upgrade,
|
|
1267
|
+
this.standby !== undefined,
|
|
1268
|
+
)
|
|
1262
1269
|
const current = view.follow ? limit : view.scroll
|
|
1263
1270
|
const scroll =
|
|
1264
1271
|
terminal === undefined
|
|
@@ -1270,6 +1277,7 @@ class DispatchLoop {
|
|
|
1270
1277
|
current,
|
|
1271
1278
|
delta,
|
|
1272
1279
|
upgrade,
|
|
1280
|
+
this.standby !== undefined,
|
|
1273
1281
|
)
|
|
1274
1282
|
let follow = view.follow
|
|
1275
1283
|
if (delta < 0 && follow && scroll < limit) follow = false
|
|
@@ -1291,6 +1299,7 @@ class DispatchLoop {
|
|
|
1291
1299
|
terminal.columns,
|
|
1292
1300
|
paintableRows(terminal.rows),
|
|
1293
1301
|
this.availableUpgrade !== undefined,
|
|
1302
|
+
this.standby !== undefined,
|
|
1294
1303
|
)
|
|
1295
1304
|
return { ...view, scroll: view.follow ? limit : Math.min(view.scroll, limit) }
|
|
1296
1305
|
}
|
|
@@ -1350,6 +1359,7 @@ class DispatchLoop {
|
|
|
1350
1359
|
this.view.scroll,
|
|
1351
1360
|
delta,
|
|
1352
1361
|
this.availableUpgrade !== undefined,
|
|
1362
|
+
this.standby !== undefined,
|
|
1353
1363
|
),
|
|
1354
1364
|
}
|
|
1355
1365
|
this.syncModelControls()
|
|
@@ -2215,7 +2225,7 @@ class DispatchLoop {
|
|
|
2215
2225
|
}
|
|
2216
2226
|
|
|
2217
2227
|
const { store, tickets, runtimes, ids, uuids, clock, storeRef, token } = this.wiring
|
|
2218
|
-
const
|
|
2228
|
+
const harvestDeps = {
|
|
2219
2229
|
store,
|
|
2220
2230
|
tickets,
|
|
2221
2231
|
config: this.currentConfig().config,
|
|
@@ -2230,16 +2240,66 @@ class DispatchLoop {
|
|
|
2230
2240
|
...(this.repoLeaseHolder !== undefined ? { leaseHolder: this.repoLeaseHolder } : {}),
|
|
2231
2241
|
sessionEnv: {
|
|
2232
2242
|
AB_STORE: storeRef,
|
|
2233
|
-
...(token !== undefined ? { AB_TOKEN: token } : {}),
|
|
2234
2243
|
},
|
|
2235
2244
|
opts: {
|
|
2236
2245
|
maxRecoveryAttempts: this.maxHarvestRecoveryAttempts,
|
|
2237
2246
|
},
|
|
2238
|
-
}
|
|
2247
|
+
}
|
|
2239
2248
|
|
|
2240
2249
|
let tracked: Promise<void>
|
|
2241
|
-
tracked =
|
|
2242
|
-
.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
|
+
})()
|
|
2243
2303
|
.then((result) => {
|
|
2244
2304
|
this.recordHarvestResult(result)
|
|
2245
2305
|
if (
|
|
@@ -2283,6 +2343,24 @@ class DispatchLoop {
|
|
|
2283
2343
|
* contract: provisioning takes minutes and must never stall a tick; the
|
|
2284
2344
|
* single-flight guard covers the whole span. Every failure is contained
|
|
2285
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
|
+
|
|
2286
2364
|
private async launchHostedHarvest(
|
|
2287
2365
|
harvestExecution: HarvestExecution,
|
|
2288
2366
|
execution: string,
|
|
@@ -2333,13 +2411,44 @@ class DispatchLoop {
|
|
|
2333
2411
|
if (pressure.trigger === undefined) return
|
|
2334
2412
|
}
|
|
2335
2413
|
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
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
|
+
},
|
|
2342
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
|
+
)
|
|
2343
2452
|
const identity = handle.identity
|
|
2344
2453
|
if (
|
|
2345
2454
|
identity === undefined ||
|
|
@@ -2612,7 +2721,7 @@ class DispatchLoop {
|
|
|
2612
2721
|
return null
|
|
2613
2722
|
}
|
|
2614
2723
|
|
|
2615
|
-
private async settlePendingPublication(slug: string): Promise<void> {
|
|
2724
|
+
private async settlePendingPublication(slug: string, reservation?: string): Promise<void> {
|
|
2616
2725
|
// The guest appended through its own handle; settle against fresh state.
|
|
2617
2726
|
await this.repoView.refreshBuild(slug)
|
|
2618
2727
|
await settleWorkspacePublication(
|
|
@@ -2629,6 +2738,16 @@ class DispatchLoop {
|
|
|
2629
2738
|
},
|
|
2630
2739
|
slug,
|
|
2631
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
|
+
})
|
|
2632
2751
|
}
|
|
2633
2752
|
|
|
2634
2753
|
private async recordInfrastructureFailure(input: {
|
|
@@ -2720,13 +2839,25 @@ class DispatchLoop {
|
|
|
2720
2839
|
// A prior publication acknowledgement may have been lost after its push
|
|
2721
2840
|
// reached the durable branch. Reconcile that fact before a replacement
|
|
2722
2841
|
// runner can rerun the phase and create a non-fast-forward successor.
|
|
2723
|
-
await this.settlePendingPublication(slug)
|
|
2724
|
-
const
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
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
|
+
)
|
|
2730
2861
|
active.handle = handle
|
|
2731
2862
|
const identity = handle.identity ?? {
|
|
2732
2863
|
provider: owning.name,
|
|
@@ -3389,6 +3520,18 @@ class DispatchLoop {
|
|
|
3389
3520
|
const claimed = await this.wiring.store
|
|
3390
3521
|
.claimRepoLease(this.repoIdentity, holder, REPO_LEASE_TTL_MS)
|
|
3391
3522
|
.catch(() => false)
|
|
3523
|
+
if (this.dashboard && !this.opts.once) {
|
|
3524
|
+
this.standbyResolver ??= new StandbyIdentityResolver(this.wiring.store, this.repoIdentity)
|
|
3525
|
+
this.standby = claimed
|
|
3526
|
+
? undefined
|
|
3527
|
+
: await this.standbyResolver.refresh(
|
|
3528
|
+
this.runId,
|
|
3529
|
+
this.yieldedTo ?? 'another invocation',
|
|
3530
|
+
this.wiring.clock(),
|
|
3531
|
+
)
|
|
3532
|
+
this.syncModelControls()
|
|
3533
|
+
this.paint()
|
|
3534
|
+
}
|
|
3392
3535
|
if (!claimed) {
|
|
3393
3536
|
if (this.repoLeaseHolder !== undefined) {
|
|
3394
3537
|
// We held the lease and the store says it is gone: a peer took over.
|
|
@@ -3474,10 +3617,17 @@ class DispatchLoop {
|
|
|
3474
3617
|
const holder = this.runId
|
|
3475
3618
|
if (!(await this.ensureRepoLease(holder))) {
|
|
3476
3619
|
await this.recordTickYielded()
|
|
3477
|
-
|
|
3478
|
-
?.
|
|
3479
|
-
|
|
3480
|
-
|
|
3620
|
+
if ((!this.dashboard && this.opts.kernelRunId === undefined) || this.opts.once) {
|
|
3621
|
+
const peer = (await this.wiring.store.getRepo(this.repoIdentity).catch(() => null))?.lease
|
|
3622
|
+
?.holder
|
|
3623
|
+
const liveness = await readDispatcherLiveness(
|
|
3624
|
+
this.wiring.store,
|
|
3625
|
+
this.repoIdentity,
|
|
3626
|
+
this.wiring.clock(),
|
|
3627
|
+
).catch(() => null)
|
|
3628
|
+
const message = `tick yielded: repository held by ${liveness?.dispatcher.holder ? formatDispatcherIdentity(liveness.dispatcher.holder) : (peer ?? 'another invocation')}`
|
|
3629
|
+
this.warn(message)
|
|
3630
|
+
}
|
|
3481
3631
|
if (this.opts.once === true) return
|
|
3482
3632
|
// Watch mode stays alive and retries the claim at every tick.
|
|
3483
3633
|
}
|
|
@@ -3498,7 +3648,17 @@ class DispatchLoop {
|
|
|
3498
3648
|
// lease and any provisioning state are released for the next
|
|
3499
3649
|
// invocation.
|
|
3500
3650
|
if (this.pastDeadline()) return
|
|
3501
|
-
const initial = await this.dispatcherTick(true)
|
|
3651
|
+
const initial = await this.dispatcherTick(true).catch(async (error: unknown) => {
|
|
3652
|
+
await this.appendStatus({
|
|
3653
|
+
actor: DISPATCHER,
|
|
3654
|
+
type: 'dispatcher.tick-failed',
|
|
3655
|
+
payload: {
|
|
3656
|
+
run: this.runId,
|
|
3657
|
+
error: error instanceof Error ? error.message : String(error),
|
|
3658
|
+
},
|
|
3659
|
+
})
|
|
3660
|
+
throw error
|
|
3661
|
+
})
|
|
3502
3662
|
const initialPrinted = this.printReport(initial, false)
|
|
3503
3663
|
await this.drainInFlight()
|
|
3504
3664
|
const settledReports = await this.publishSettlementReports()
|
|
@@ -3649,16 +3809,21 @@ function acceptableOriginModeStore(storeRef: string): boolean {
|
|
|
3649
3809
|
* names the operator's own machine (local development, integration harnesses).
|
|
3650
3810
|
* Requirements are validated here so a misconfigured origin-mode launch fails
|
|
3651
3811
|
* before any side effect. */
|
|
3652
|
-
async function resolveOriginModeState(
|
|
3812
|
+
async function resolveOriginModeState(
|
|
3813
|
+
opts: DispatchOpts,
|
|
3814
|
+
configStore?: string,
|
|
3815
|
+
): Promise<RepoStatePaths> {
|
|
3653
3816
|
const identity = normalizeGitRemoteUrl(opts.repository!)
|
|
3654
|
-
const selectedStore = opts.storeRef
|
|
3817
|
+
const selectedStore = opts.storeRef?.trim() || opts.env.AB_STORE?.trim() || configStore
|
|
3655
3818
|
if (selectedStore === undefined || !acceptableOriginModeStore(selectedStore)) {
|
|
3656
3819
|
throw new Error(
|
|
3657
3820
|
'origin-mode dispatch requires an HTTPS BuildStore — set AB_STORE (or --store) to the hosted Store URL',
|
|
3658
3821
|
)
|
|
3659
3822
|
}
|
|
3660
|
-
if (
|
|
3661
|
-
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
|
+
)
|
|
3662
3827
|
}
|
|
3663
3828
|
// Checkout-less hosts (serverless functions, sandboxes) have no gh CLI and
|
|
3664
3829
|
// no keyring, and the only workspace provider origin mode can run —
|
|
@@ -3676,6 +3841,7 @@ async function resolveOriginModeState(opts: DispatchOpts): Promise<RepoStatePath
|
|
|
3676
3841
|
await mkdir(scratch, { recursive: true })
|
|
3677
3842
|
return resolveRepoStatePaths({
|
|
3678
3843
|
repo: identity,
|
|
3844
|
+
configStore,
|
|
3679
3845
|
checkout: scratch,
|
|
3680
3846
|
...(opts.storeRef !== undefined ? { storeRef: opts.storeRef } : {}),
|
|
3681
3847
|
...(opts.env.AB_STORE !== undefined ? { envStore: opts.env.AB_STORE } : {}),
|
|
@@ -3698,9 +3864,23 @@ export async function abDispatch(opts: DispatchOpts): Promise<void> {
|
|
|
3698
3864
|
if (opts.wire !== undefined && opts.nonStoreWire !== undefined) {
|
|
3699
3865
|
throw new Error('dispatch wire and nonStoreWire are mutually exclusive')
|
|
3700
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
|
|
3701
3881
|
const state =
|
|
3702
3882
|
opts.repository !== undefined
|
|
3703
|
-
? await resolveOriginModeState(opts)
|
|
3883
|
+
? await resolveOriginModeState(opts, originConfig?.config.store?.url)
|
|
3704
3884
|
: await resolveRepoState({
|
|
3705
3885
|
targetRepo: opts.targetRepo,
|
|
3706
3886
|
exec: opts.exec,
|
|
@@ -3714,6 +3894,13 @@ export async function abDispatch(opts: DispatchOpts): Promise<void> {
|
|
|
3714
3894
|
const resolvedOpts: DispatchOpts = {
|
|
3715
3895
|
...opts,
|
|
3716
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
|
+
},
|
|
3717
3904
|
storeRef: state.storeRef,
|
|
3718
3905
|
repo: opts.repo ?? state.repo,
|
|
3719
3906
|
}
|
|
@@ -3771,7 +3958,7 @@ export async function abDispatch(opts: DispatchOpts): Promise<void> {
|
|
|
3771
3958
|
if (opts.repository !== undefined) {
|
|
3772
3959
|
// Origin mode startup config: the base branch's autobuild.toml, fetched
|
|
3773
3960
|
// through the forge (default branch first — baseBranch itself is config).
|
|
3774
|
-
const fetched =
|
|
3961
|
+
const fetched = originConfig!
|
|
3775
3962
|
configContent = fetched.content
|
|
3776
3963
|
config = fetched.config
|
|
3777
3964
|
if (config.forge !== 'github') {
|
|
@@ -3885,7 +4072,7 @@ export async function abDispatch(opts: DispatchOpts): Promise<void> {
|
|
|
3885
4072
|
},
|
|
3886
4073
|
{
|
|
3887
4074
|
kind: _effectiveConfigKind,
|
|
3888
|
-
content:
|
|
4075
|
+
content: effectiveBuildConfigContent(effectiveConfig),
|
|
3889
4076
|
metadata: { run: runId, effectiveChanged },
|
|
3890
4077
|
},
|
|
3891
4078
|
],
|
|
@@ -3950,7 +4137,7 @@ export async function abDispatch(opts: DispatchOpts): Promise<void> {
|
|
|
3950
4137
|
[
|
|
3951
4138
|
{
|
|
3952
4139
|
kind: _effectiveConfigKind,
|
|
3953
|
-
content:
|
|
4140
|
+
content: effectiveBuildConfigContent(config),
|
|
3954
4141
|
metadata: { run: runId, revision: 0 },
|
|
3955
4142
|
},
|
|
3956
4143
|
],
|
|
@@ -3963,6 +4150,7 @@ export async function abDispatch(opts: DispatchOpts): Promise<void> {
|
|
|
3963
4150
|
payload: {
|
|
3964
4151
|
run: runId,
|
|
3965
4152
|
pid: process.pid,
|
|
4153
|
+
host: hostname(),
|
|
3966
4154
|
effectiveConfig: { kind: artifact.kind, rev: artifact.revision },
|
|
3967
4155
|
roleWarnings: roleKeyWarnings(config),
|
|
3968
4156
|
},
|
|
@@ -3973,6 +4161,17 @@ export async function abDispatch(opts: DispatchOpts): Promise<void> {
|
|
|
3973
4161
|
const loop = new DispatchLoop(liveConfig, wiring, resolvedOpts, view)
|
|
3974
4162
|
try {
|
|
3975
4163
|
await loop.run()
|
|
4164
|
+
const stopRequest = (
|
|
4165
|
+
resolvedOpts.kernelRunId && resolvedOpts.signal?.aborted
|
|
4166
|
+
? await wiring.store.getRepoEvents(state.repo)
|
|
4167
|
+
: []
|
|
4168
|
+
).find((event) => event.type === 'dispatcher.stop-requested' && event.payload.run === runId)
|
|
4169
|
+
const stoppedSignal =
|
|
4170
|
+
stopRequest?.type === 'dispatcher.stop-requested'
|
|
4171
|
+
? stopRequest.payload.signal
|
|
4172
|
+
: typeof resolvedOpts.signal?.reason === 'string'
|
|
4173
|
+
? resolvedOpts.signal.reason
|
|
4174
|
+
: undefined
|
|
3976
4175
|
await view.appendRepo(state.repo, {
|
|
3977
4176
|
actor: DISPATCHER,
|
|
3978
4177
|
type: 'dispatcher.run-stopped',
|
|
@@ -3980,6 +4179,7 @@ export async function abDispatch(opts: DispatchOpts): Promise<void> {
|
|
|
3980
4179
|
run: runId,
|
|
3981
4180
|
outcome: 'normal',
|
|
3982
4181
|
exitCode: 0,
|
|
4182
|
+
...(stoppedSignal === undefined ? {} : { signal: stoppedSignal }),
|
|
3983
4183
|
...(loop.supersededByPeer ? { reason: 'superseded' as const } : {}),
|
|
3984
4184
|
},
|
|
3985
4185
|
})
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { RepositoryEvent } from '../events/repository'
|
|
2
|
+
import type { BuildStore, RepositoryRecord } from '../store/types'
|
|
3
|
+
import { formatDispatcherIdentity, projectDispatcherLiveness } from '../kernel/dispatcher-liveness'
|
|
4
|
+
|
|
5
|
+
/** Bounded first; a later yielding invocation can hide the owner's identity or
|
|
6
|
+
* an earlier stop. Only this correlation query falls back to full history. */
|
|
7
|
+
export async function readDispatcherLiveness(
|
|
8
|
+
store: BuildStore,
|
|
9
|
+
repo: string,
|
|
10
|
+
now = new Date(),
|
|
11
|
+
subset?: RepositoryEvent[],
|
|
12
|
+
) {
|
|
13
|
+
const record = await store.getRepo(repo)
|
|
14
|
+
let events = record === null ? [] : (subset ?? (await store.getRepoStateEvents(repo)))
|
|
15
|
+
const bounded = projectDispatcherLiveness(record, events, now)
|
|
16
|
+
if (
|
|
17
|
+
record &&
|
|
18
|
+
((record.lease &&
|
|
19
|
+
!events.some(
|
|
20
|
+
(e) => e.type === 'dispatcher.run-started' && e.payload.run === record.lease!.holder,
|
|
21
|
+
)) ||
|
|
22
|
+
(bounded.state !== 'held' &&
|
|
23
|
+
events.some((e) => e.type === 'dispatcher.run-started') &&
|
|
24
|
+
!events.some((e) => e.type === 'dispatcher.run-stopped')))
|
|
25
|
+
) {
|
|
26
|
+
events = await store.getRepoEvents(repo)
|
|
27
|
+
}
|
|
28
|
+
return { record, events, dispatcher: projectDispatcherLiveness(record, events, now) }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Invocation-local identity cache. Only start facts survive refreshes; ages
|
|
32
|
+
* always come from the current lease record and clock. Failed correlations are
|
|
33
|
+
* negatively cached so a standby cannot repeatedly replay a large journal. */
|
|
34
|
+
export class StandbyIdentityResolver {
|
|
35
|
+
private readonly starts = new Map<string, RepositoryEvent>()
|
|
36
|
+
private readonly attempted = new Set<string>()
|
|
37
|
+
private readonly pending = new Map<string, Promise<void>>()
|
|
38
|
+
|
|
39
|
+
constructor(
|
|
40
|
+
private readonly store: BuildStore,
|
|
41
|
+
private readonly repo: string,
|
|
42
|
+
) {}
|
|
43
|
+
|
|
44
|
+
ingest(events: readonly RepositoryEvent[]): void {
|
|
45
|
+
for (const event of events) {
|
|
46
|
+
if (event.type === 'dispatcher.run-started') this.starts.set(event.payload.run, event)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
private async correlate(holder: string): Promise<void> {
|
|
51
|
+
const pending = this.pending.get(holder)
|
|
52
|
+
if (pending !== undefined) return pending
|
|
53
|
+
if (this.starts.has(holder) || this.attempted.has(holder)) return
|
|
54
|
+
this.attempted.add(holder)
|
|
55
|
+
const lookup = (async () => {
|
|
56
|
+
try {
|
|
57
|
+
this.ingest(await this.store.getRepoStateEvents(this.repo))
|
|
58
|
+
if (!this.starts.has(holder)) this.ingest(await this.store.getRepoEvents(this.repo, 0))
|
|
59
|
+
} catch {
|
|
60
|
+
// Identity is best effort; the sampled holder id remains sufficient.
|
|
61
|
+
}
|
|
62
|
+
})()
|
|
63
|
+
this.pending.set(holder, lookup)
|
|
64
|
+
try {
|
|
65
|
+
await lookup
|
|
66
|
+
} finally {
|
|
67
|
+
this.pending.delete(holder)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async refresh(
|
|
72
|
+
run: string,
|
|
73
|
+
yieldedHolder: string | undefined,
|
|
74
|
+
now: Date,
|
|
75
|
+
): Promise<string | undefined> {
|
|
76
|
+
let record: RepositoryRecord | null
|
|
77
|
+
try {
|
|
78
|
+
record = await this.store.getRepo(this.repo)
|
|
79
|
+
} catch {
|
|
80
|
+
return yieldedHolder
|
|
81
|
+
}
|
|
82
|
+
const holder = record?.lease?.holder
|
|
83
|
+
if (holder === run) return undefined
|
|
84
|
+
if (holder === undefined) return yieldedHolder
|
|
85
|
+
await this.correlate(holder)
|
|
86
|
+
if (!this.starts.has(holder)) return holder
|
|
87
|
+
const projection = projectDispatcherLiveness(record, [...this.starts.values()], now)
|
|
88
|
+
const identity = projection.holder ?? projection.expiredHolder
|
|
89
|
+
return identity?.run === holder ? formatDispatcherIdentity(identity) : holder
|
|
90
|
+
}
|
|
91
|
+
}
|