@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,4 @@
|
|
|
1
|
+
import { delegateCredential } from '../store/remote/credentials'
|
|
1
2
|
/**
|
|
2
3
|
* Crash-safe repository-scoped harvest workflow:
|
|
3
4
|
* scan -> synthesize <-> review -> file. It reuses `converge` for adversarial
|
|
@@ -100,11 +101,13 @@ export interface HarvestRunnerDeps {
|
|
|
100
101
|
* executes in (hosted harvest). Stamped into the run's harvest.started
|
|
101
102
|
* payload so the journal records where it ran; absent on local runs. */
|
|
102
103
|
environment?: { provider: string; environmentId: string; sessionId?: string }
|
|
104
|
+
preparedRun?: { run: string }
|
|
103
105
|
sessionEnv?: Record<string, string>
|
|
104
106
|
opts?: HarvestRunnerOpts
|
|
105
107
|
}
|
|
106
108
|
|
|
107
109
|
export type HarvestRunnerResult =
|
|
110
|
+
| { outcome: 'prepared'; run: string; launch: 'started' | 'resumed' }
|
|
108
111
|
| { outcome: 'idle' }
|
|
109
112
|
| { outcome: 'held' }
|
|
110
113
|
| { outcome: 'parked'; run?: string }
|
|
@@ -217,9 +220,9 @@ export class HarvestRunner {
|
|
|
217
220
|
)
|
|
218
221
|
}
|
|
219
222
|
|
|
220
|
-
async run(): Promise<HarvestRunnerResult> {
|
|
223
|
+
async run(preparationOnly = false): Promise<HarvestRunnerResult> {
|
|
221
224
|
const { store, repo, instance } = this.deps
|
|
222
|
-
await store.ensureRepo(repo)
|
|
225
|
+
if (!this.deps.preparedRun) await store.ensureRepo(repo)
|
|
223
226
|
if (
|
|
224
227
|
this.deps.leaseHolder === undefined &&
|
|
225
228
|
!(await store.claimRepoLease(repo, instance, this.leaseTtlMs))
|
|
@@ -233,101 +236,116 @@ export class HarvestRunner {
|
|
|
233
236
|
await this.ensureLease()
|
|
234
237
|
// Bounded read (AUT-489): reduceHarvest and the verdict/failure filters
|
|
235
238
|
// below consume durable types only, so the subset is replay-equivalent.
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
await
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
const pressure = evaluateHarvestPressureFromDigests({
|
|
263
|
-
digests,
|
|
264
|
-
harvestEvents: gateEvents,
|
|
265
|
-
policy: this.currentConfig().policy,
|
|
266
|
-
})
|
|
267
|
-
if (pressure.trigger === undefined) {
|
|
268
|
-
// Treat the read's completion as a boundary even when the gate did
|
|
269
|
-
// not trip, so a request arriving during the read is acknowledged
|
|
270
|
-
// before this runner returns idle.
|
|
239
|
+
if (this.deps.preparedRun) {
|
|
240
|
+
const events = await store.getRepoStateEvents(repo)
|
|
241
|
+
const authorization = events.find(
|
|
242
|
+
(event) =>
|
|
243
|
+
event.type === 'harvest.execution.authorized' &&
|
|
244
|
+
event.payload.execution === instance &&
|
|
245
|
+
event.payload.run === this.deps.preparedRun!.run,
|
|
246
|
+
)
|
|
247
|
+
if (authorization?.type !== 'harvest.execution.authorized')
|
|
248
|
+
throw new Error('prepared harvest execution unavailable')
|
|
249
|
+
run = reduceHarvest(events).runs.find((run) => run.run === this.deps.preparedRun!.run)
|
|
250
|
+
initial = authorization.payload.launch
|
|
251
|
+
} else {
|
|
252
|
+
let events = await store.getRepoStateEvents(repo)
|
|
253
|
+
let state = reduceHarvest(events)
|
|
254
|
+
run = actionableHarvestRun(state)
|
|
255
|
+
|
|
256
|
+
// Settle durable operator control before scanning or resuming work. The
|
|
257
|
+
// selected run comes from the full journal, so a shadowed failure or
|
|
258
|
+
// exhaustion barrier is recovered/reported before any later open run.
|
|
259
|
+
await this.controlBoundary(run?.run)
|
|
260
|
+
events = await store.getRepoStateEvents(repo)
|
|
261
|
+
state = reduceHarvest(events)
|
|
262
|
+
run = openHarvestRun(state)
|
|
263
|
+
|
|
264
|
+
if (!run) {
|
|
271
265
|
await this.controlBoundary()
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
266
|
+
// Flat-cost gate (AUT-521): the pressure trigger is evaluated from
|
|
267
|
+
// the journal and one repo-scoped digest batch read — no per-build
|
|
268
|
+
// history reads — so the idle evaluation no longer grows with
|
|
269
|
+
// accumulated finished builds. Only a tripped gate pays for the full
|
|
270
|
+
// unclaimed-observation scan, because the packet artifact needs the
|
|
271
|
+
// observation payloads no bounded source carries.
|
|
272
|
+
// Bounded read (AUT-489): the gate's journal input is consumed only
|
|
273
|
+
// through reduceHarvest, so the subset is replay-equivalent here too.
|
|
274
|
+
const [gateEvents, digests] = await Promise.all([
|
|
275
|
+
store.getRepoStateEvents(repo),
|
|
276
|
+
store.getRepoBuildDigests(repo),
|
|
277
|
+
])
|
|
278
|
+
const pressure = evaluateHarvestPressureFromDigests({
|
|
279
|
+
digests,
|
|
280
|
+
harvestEvents: gateEvents,
|
|
281
|
+
policy: this.currentConfig().policy,
|
|
282
|
+
})
|
|
283
|
+
if (pressure.trigger === undefined) {
|
|
284
|
+
// Treat the read's completion as a boundary even when the gate did
|
|
285
|
+
// not trip, so a request arriving during the read is acknowledged
|
|
286
|
+
// before this runner returns idle.
|
|
287
|
+
await this.controlBoundary()
|
|
288
|
+
return { outcome: 'idle' }
|
|
289
|
+
}
|
|
283
290
|
await this.controlBoundary()
|
|
284
|
-
|
|
291
|
+
const scan = await scanUnclaimedObservations(store, repo)
|
|
292
|
+
// The trigger came from an earlier read than this scan, and a claim
|
|
293
|
+
// may have landed in the window. Re-confirm the gate against the
|
|
294
|
+
// fresh scan (pure, free): an emptied window returns idle exactly as
|
|
295
|
+
// if the gate had not tripped, so a packet is only ever built from a
|
|
296
|
+
// scan that itself triggered.
|
|
297
|
+
const confirmed = evaluateHarvestPressure(scan, this.currentConfig().policy)
|
|
298
|
+
if (confirmed.trigger === undefined) {
|
|
299
|
+
await this.controlBoundary()
|
|
300
|
+
return { outcome: 'idle' }
|
|
301
|
+
}
|
|
302
|
+
const runId = this.deps.ids('harvest')
|
|
303
|
+
const packet = await makeHarvestScanPacket({
|
|
304
|
+
store,
|
|
305
|
+
tickets: this.deps.tickets,
|
|
306
|
+
repo,
|
|
307
|
+
run: runId,
|
|
308
|
+
observations: scan.observations,
|
|
309
|
+
state: scan.state,
|
|
310
|
+
})
|
|
311
|
+
// Scan preparation is not a claim. Re-read operator control immediately
|
|
312
|
+
// before atomically committing the immutable snapshot.
|
|
313
|
+
await this.controlBoundary()
|
|
314
|
+
initial = 'started'
|
|
315
|
+
await store.appendRepoWithArtifacts(
|
|
316
|
+
repo,
|
|
317
|
+
[
|
|
318
|
+
{
|
|
319
|
+
kind: HARVEST_SCAN_ARTIFACT,
|
|
320
|
+
content: `${JSON.stringify(packet, null, 2)}\n`,
|
|
321
|
+
metadata: { run: runId, observations: packet.observations.length },
|
|
322
|
+
},
|
|
323
|
+
],
|
|
324
|
+
(deposited) => ({
|
|
325
|
+
actor: KERNEL,
|
|
326
|
+
type: 'harvest.started',
|
|
327
|
+
payload: {
|
|
328
|
+
run: runId,
|
|
329
|
+
observations: packet.observations.map((item) => item.occurrence),
|
|
330
|
+
scan: artifactRef(deposited[0]!),
|
|
331
|
+
trigger: confirmed.trigger,
|
|
332
|
+
...(this.deps.environment !== undefined
|
|
333
|
+
? { environment: this.deps.environment }
|
|
334
|
+
: {}),
|
|
335
|
+
},
|
|
336
|
+
}),
|
|
337
|
+
)
|
|
338
|
+
// Bounded read (AUT-489): reduceHarvest consumes durable types only.
|
|
339
|
+
events = await store.getRepoStateEvents(repo)
|
|
340
|
+
run = reduceHarvest(events).runs.find((candidate) => candidate.run === runId)
|
|
341
|
+
if (!run) throw new Error(`harvest.started did not reduce run "${runId}"`)
|
|
342
|
+
// A request racing the claim parks this exact run; its snapshot stays
|
|
343
|
+
// open and is resumed rather than scanned again.
|
|
344
|
+
await this.controlBoundary(run.run)
|
|
285
345
|
}
|
|
286
|
-
const runId = this.deps.ids('harvest')
|
|
287
|
-
const packet = await makeHarvestScanPacket({
|
|
288
|
-
store,
|
|
289
|
-
tickets: this.deps.tickets,
|
|
290
|
-
repo,
|
|
291
|
-
run: runId,
|
|
292
|
-
observations: scan.observations,
|
|
293
|
-
state: scan.state,
|
|
294
|
-
})
|
|
295
|
-
// Scan preparation is not a claim. Re-read operator control immediately
|
|
296
|
-
// before atomically committing the immutable snapshot.
|
|
297
|
-
await this.controlBoundary()
|
|
298
|
-
initial = 'started'
|
|
299
|
-
await store.appendRepoWithArtifacts(
|
|
300
|
-
repo,
|
|
301
|
-
[
|
|
302
|
-
{
|
|
303
|
-
kind: HARVEST_SCAN_ARTIFACT,
|
|
304
|
-
content: `${JSON.stringify(packet, null, 2)}\n`,
|
|
305
|
-
metadata: { run: runId, observations: packet.observations.length },
|
|
306
|
-
},
|
|
307
|
-
],
|
|
308
|
-
(deposited) => ({
|
|
309
|
-
actor: KERNEL,
|
|
310
|
-
type: 'harvest.started',
|
|
311
|
-
payload: {
|
|
312
|
-
run: runId,
|
|
313
|
-
observations: packet.observations.map((item) => item.occurrence),
|
|
314
|
-
scan: artifactRef(deposited[0]!),
|
|
315
|
-
trigger: confirmed.trigger,
|
|
316
|
-
...(this.deps.environment !== undefined
|
|
317
|
-
? { environment: this.deps.environment }
|
|
318
|
-
: {}),
|
|
319
|
-
},
|
|
320
|
-
}),
|
|
321
|
-
)
|
|
322
|
-
// Bounded read (AUT-489): reduceHarvest consumes durable types only.
|
|
323
|
-
events = await store.getRepoStateEvents(repo)
|
|
324
|
-
run = reduceHarvest(events).runs.find((candidate) => candidate.run === runId)
|
|
325
|
-
if (!run) throw new Error(`harvest.started did not reduce run "${runId}"`)
|
|
326
|
-
// A request racing the claim parks this exact run; its snapshot stays
|
|
327
|
-
// open and is resumed rather than scanned again.
|
|
328
|
-
await this.controlBoundary(run.run)
|
|
329
346
|
}
|
|
330
|
-
|
|
347
|
+
if (!run) throw new Error('prepared harvest run unavailable')
|
|
348
|
+
if (preparationOnly) return { outcome: 'prepared', run: run.run, launch: initial }
|
|
331
349
|
try {
|
|
332
350
|
await this.ensureScanStep(run)
|
|
333
351
|
await this.controlBoundary(run.run)
|
|
@@ -691,7 +709,7 @@ export class HarvestRunner {
|
|
|
691
709
|
live.handle,
|
|
692
710
|
`Revise harvest proposals for round ${spec.round}: run ab harvest context, address .ab/findings.json, then submit.`,
|
|
693
711
|
{
|
|
694
|
-
env: this.sessionEnv(spec.run, spec.step, spec.round, session),
|
|
712
|
+
env: await this.sessionEnv(spec.run, spec.step, spec.round, session),
|
|
695
713
|
...(stream !== undefined ? { stream } : {}),
|
|
696
714
|
},
|
|
697
715
|
)
|
|
@@ -702,7 +720,7 @@ export class HarvestRunner {
|
|
|
702
720
|
workspacePath,
|
|
703
721
|
...(target.model !== undefined ? { model: target.model } : {}),
|
|
704
722
|
...(target.args !== undefined ? { args: target.args } : {}),
|
|
705
|
-
env: this.sessionEnv(spec.run, spec.step, spec.round, session),
|
|
723
|
+
env: await this.sessionEnv(spec.run, spec.step, spec.round, session),
|
|
706
724
|
...(stream !== undefined ? { stream } : {}),
|
|
707
725
|
})
|
|
708
726
|
handle = turn.session
|
|
@@ -941,7 +959,7 @@ export class HarvestRunner {
|
|
|
941
959
|
live.handle,
|
|
942
960
|
`Revise harvest proposals for round ${spec.round}: run ab harvest context, address .ab/findings.json, then submit.`,
|
|
943
961
|
{
|
|
944
|
-
env: this.sessionEnv(spec.run, spec.step, spec.round, session),
|
|
962
|
+
env: await this.sessionEnv(spec.run, spec.step, spec.round, session),
|
|
945
963
|
...(stream !== undefined ? { stream } : {}),
|
|
946
964
|
},
|
|
947
965
|
)
|
|
@@ -952,7 +970,7 @@ export class HarvestRunner {
|
|
|
952
970
|
workspacePath,
|
|
953
971
|
...(resolved.model !== undefined ? { model: resolved.model } : {}),
|
|
954
972
|
...(resolved.args !== undefined ? { args: resolved.args } : {}),
|
|
955
|
-
env: this.sessionEnv(spec.run, spec.step, spec.round, session),
|
|
973
|
+
env: await this.sessionEnv(spec.run, spec.step, spec.round, session),
|
|
956
974
|
...(stream !== undefined ? { stream } : {}),
|
|
957
975
|
})
|
|
958
976
|
handle = turn.session
|
|
@@ -1391,6 +1409,7 @@ export class HarvestRunner {
|
|
|
1391
1409
|
* give-up before returning. Initial/within-step failures do not spend this
|
|
1392
1410
|
* outer budget and therefore remain recoverable. */
|
|
1393
1411
|
private async settleRecoveryExhaustion(run: string): Promise<void> {
|
|
1412
|
+
if (this.deps.preparedRun) return
|
|
1394
1413
|
await this.ensureLease()
|
|
1395
1414
|
// Bounded read (AUT-489): reduceHarvest consumes durable types only.
|
|
1396
1415
|
const state = reduceHarvest(await this.deps.store.getRepoStateEvents(this.deps.repo))
|
|
@@ -1459,6 +1478,7 @@ export class HarvestRunner {
|
|
|
1459
1478
|
const state = reduceHarvest(await this.deps.store.getRepoStateEvents(this.deps.repo))
|
|
1460
1479
|
const decision = decideHarvestControl(state, this.maxRecoveryAttempts)
|
|
1461
1480
|
if (decision.kind === 'proceed') return
|
|
1481
|
+
if (this.deps.preparedRun) throw new HarvestParkedSignal(run)
|
|
1462
1482
|
if (decision.kind === 'park') {
|
|
1463
1483
|
throw new HarvestParkedSignal(actionableHarvestRun(state)?.run ?? run)
|
|
1464
1484
|
}
|
|
@@ -1602,15 +1622,26 @@ export class HarvestRunner {
|
|
|
1602
1622
|
}
|
|
1603
1623
|
}
|
|
1604
1624
|
|
|
1605
|
-
private sessionEnv(
|
|
1625
|
+
private async sessionEnv(
|
|
1606
1626
|
run: string,
|
|
1607
1627
|
step: 'synthesize' | 'review',
|
|
1608
1628
|
round: number,
|
|
1609
1629
|
session: string,
|
|
1610
|
-
): Record<string, string
|
|
1630
|
+
): Promise<Record<string, string>> {
|
|
1631
|
+
const parent = this.deps.sessionEnv
|
|
1632
|
+
const token =
|
|
1633
|
+
parent?.AB_STORE && /^https?:/i.test(parent.AB_STORE)
|
|
1634
|
+
? await delegateCredential(parent.AB_STORE, parent.AB_TOKEN, {
|
|
1635
|
+
kind: 'harvest-phase',
|
|
1636
|
+
repo: this.deps.repo,
|
|
1637
|
+
id: run,
|
|
1638
|
+
session,
|
|
1639
|
+
})
|
|
1640
|
+
: undefined
|
|
1611
1641
|
return {
|
|
1612
1642
|
AB_STORE: 'local',
|
|
1613
1643
|
...this.deps.sessionEnv,
|
|
1644
|
+
...(token ? { AB_TOKEN: token } : {}),
|
|
1614
1645
|
AB_REPO: this.deps.repo,
|
|
1615
1646
|
AB_HARVEST: run,
|
|
1616
1647
|
AB_PHASE: `${step}@${round}`,
|
|
@@ -1638,7 +1669,18 @@ export class HarvestRunner {
|
|
|
1638
1669
|
const open = target.openSessionStream
|
|
1639
1670
|
if (open === undefined) return undefined
|
|
1640
1671
|
const sink = createSessionStreamSink({
|
|
1641
|
-
store:
|
|
1672
|
+
store: {
|
|
1673
|
+
...this.deps.store,
|
|
1674
|
+
createStream: (scope, label) =>
|
|
1675
|
+
this.deps.store.createStream(
|
|
1676
|
+
scope,
|
|
1677
|
+
this.deps.preparedRun
|
|
1678
|
+
? `harvest:${this.deps.preparedRun.run}:${this.deps.instance}:${label}`
|
|
1679
|
+
: label,
|
|
1680
|
+
),
|
|
1681
|
+
appendStreamParts: (id, parts) => this.deps.store.appendStreamParts(id, parts),
|
|
1682
|
+
closeStream: (id, outcome) => this.deps.store.closeStream(id, outcome),
|
|
1683
|
+
},
|
|
1642
1684
|
scope: { kind: 'repo', repo: this.deps.repo },
|
|
1643
1685
|
onDiagnostic: this.onDiagnostic,
|
|
1644
1686
|
})
|
|
@@ -11,6 +11,7 @@ export const POLICY_PHASE_FAILURE_RESET_SCOPES = {
|
|
|
11
11
|
'review-round-limit': 'matching-round',
|
|
12
12
|
'verify-failure-limit': 'all-phase-rounds',
|
|
13
13
|
'reconcile-no-progress': 'none',
|
|
14
|
+
'dispatch-failure-limit': 'none',
|
|
14
15
|
'setup-failure-limit': 'none',
|
|
15
16
|
'infrastructure-failure-limit': 'none',
|
|
16
17
|
'phase-attempt-limit': 'matching-round',
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { summaryBoundary } from '../kernel/pr-summary'
|
|
2
|
+
import { isRemoteWorkspace } from '../events/workspace-remote'
|
|
1
3
|
/**
|
|
2
4
|
* Last-chance publication settlement at the workspace-release boundary
|
|
3
5
|
* (AUT-328). A pending `publication.requested` whose workspace is destroyed
|
|
@@ -37,7 +39,22 @@ export async function settlePublicationBeforeRelease(
|
|
|
37
39
|
reason: string,
|
|
38
40
|
): Promise<AbEvent[]> {
|
|
39
41
|
const request = latestUncompletedPublicationRequest(events)
|
|
40
|
-
if (request === undefined)
|
|
42
|
+
if (request === undefined) {
|
|
43
|
+
const workspace = events.findLast((event) => event.type === 'workspace.provisioned')
|
|
44
|
+
if (
|
|
45
|
+
workspace?.type === 'workspace.provisioned' &&
|
|
46
|
+
isRemoteWorkspace(workspace.payload) &&
|
|
47
|
+
summaryBoundary(events) !== undefined
|
|
48
|
+
) {
|
|
49
|
+
try {
|
|
50
|
+
await deps.settlePublication?.(slug)
|
|
51
|
+
} catch {
|
|
52
|
+
/* Summary is best effort. */
|
|
53
|
+
}
|
|
54
|
+
return deps.store.getEvents(slug)
|
|
55
|
+
}
|
|
56
|
+
return events
|
|
57
|
+
}
|
|
41
58
|
try {
|
|
42
59
|
await deps.settlePublication?.(slug)
|
|
43
60
|
} catch {
|
|
@@ -32,7 +32,8 @@
|
|
|
32
32
|
* What is held:
|
|
33
33
|
*
|
|
34
34
|
* - the bounded repository journal (`getRepoStateEvents`' subset), pruned on
|
|
35
|
-
* arrival by the same keep rule — durable
|
|
35
|
+
* arrival by the same keep rule — durable facts, latest-only measurements,
|
|
36
|
+
* and the tail (excluding latest-only types) from the latest
|
|
36
37
|
* `dispatcher.run-started` — which is exact for seq-ordered input;
|
|
37
38
|
* - for *work* builds (nonterminal, aborted, reclaim-pending, execution-open),
|
|
38
39
|
* the full event array. Settled terminal builds hold only the discovery
|
|
@@ -607,21 +607,25 @@ export function describeBuildStoreContract(name: string, factory: BuildStoreFact
|
|
|
607
607
|
})
|
|
608
608
|
expect(digests.get('dg-done')).toEqual({
|
|
609
609
|
slug: 'dg-done',
|
|
610
|
+
readinessChangeSeq: 2,
|
|
610
611
|
terminal: 'done',
|
|
611
612
|
observations: [{ seq: 3, ts: tsOf('dg-done', 3) }],
|
|
612
613
|
})
|
|
613
614
|
expect(digests.get('dg-cleaning')).toEqual({
|
|
614
615
|
slug: 'dg-cleaning',
|
|
616
|
+
readinessChangeSeq: 2,
|
|
615
617
|
terminal: 'aborted',
|
|
616
618
|
observations: [{ seq: 3, ts: tsOf('dg-cleaning', 3) }],
|
|
617
619
|
})
|
|
618
620
|
expect(digests.get('dg-abandoned')).toEqual({
|
|
619
621
|
slug: 'dg-abandoned',
|
|
622
|
+
readinessChangeSeq: 3,
|
|
620
623
|
terminal: 'done',
|
|
621
624
|
observations: [],
|
|
622
625
|
})
|
|
623
626
|
expect(digests.get('dg-overwrite')).toEqual({
|
|
624
627
|
slug: 'dg-overwrite',
|
|
628
|
+
readinessChangeSeq: 4,
|
|
625
629
|
terminal: 'done',
|
|
626
630
|
observations: [],
|
|
627
631
|
})
|
|
@@ -684,6 +688,7 @@ export function describeBuildStoreContract(name: string, factory: BuildStoreFact
|
|
|
684
688
|
digest = (await store.getRepoBuildDigests(repo)).get('dg-paths')
|
|
685
689
|
expect(digest).toEqual({
|
|
686
690
|
slug: 'dg-paths',
|
|
691
|
+
readinessChangeSeq: 6,
|
|
687
692
|
terminal: 'done',
|
|
688
693
|
merged: secondMergeTs,
|
|
689
694
|
observations: [
|
|
@@ -1176,6 +1181,48 @@ export function describeBuildStoreContract(name: string, factory: BuildStoreFact
|
|
|
1176
1181
|
]
|
|
1177
1182
|
}
|
|
1178
1183
|
|
|
1184
|
+
test('ticket-source health remains durable across newer runs', async () => {
|
|
1185
|
+
await withStore(factory, undefined, async (store) => {
|
|
1186
|
+
const repo = 'acme/source-health'
|
|
1187
|
+
await store.ensureRepo(repo)
|
|
1188
|
+
const condition = {
|
|
1189
|
+
source: 'fake',
|
|
1190
|
+
cause: 'rate-limited' as const,
|
|
1191
|
+
operation: 'ready-scan' as const,
|
|
1192
|
+
message: 'quota exhausted',
|
|
1193
|
+
resetAt: '2026-10-14T12:00:00.000Z',
|
|
1194
|
+
}
|
|
1195
|
+
await store.appendRepo(repo, {
|
|
1196
|
+
actor: DISPATCHER,
|
|
1197
|
+
type: 'dispatcher.ticket-source-degraded',
|
|
1198
|
+
payload: condition,
|
|
1199
|
+
})
|
|
1200
|
+
await store.appendRepo(repo, {
|
|
1201
|
+
actor: DISPATCHER,
|
|
1202
|
+
type: 'dispatcher.ticket-source-dependency-limited',
|
|
1203
|
+
payload: { source: 'fake' },
|
|
1204
|
+
})
|
|
1205
|
+
for (const write of noopInvocation('new-run')) await store.appendRepo(repo, write)
|
|
1206
|
+
const bounded = await store.getRepoStateEvents(repo)
|
|
1207
|
+
expect(bounded[0]?.type).toBe('dispatcher.ticket-source-degraded')
|
|
1208
|
+
expect(bounded[1]?.type).toBe('dispatcher.ticket-source-dependency-limited')
|
|
1209
|
+
await store.appendRepo(repo, {
|
|
1210
|
+
actor: DISPATCHER,
|
|
1211
|
+
type: 'dispatcher.ticket-source-recovered',
|
|
1212
|
+
payload: { source: 'fake', cause: 'rate-limited', operation: 'dependency-check' },
|
|
1213
|
+
})
|
|
1214
|
+
expect(
|
|
1215
|
+
(await store.getRepoStateEvents(repo)).filter((event) =>
|
|
1216
|
+
event.type.startsWith('dispatcher.ticket-source-'),
|
|
1217
|
+
),
|
|
1218
|
+
).toEqual(
|
|
1219
|
+
(await store.getRepoEvents(repo)).filter((event) =>
|
|
1220
|
+
event.type.startsWith('dispatcher.ticket-source-'),
|
|
1221
|
+
),
|
|
1222
|
+
)
|
|
1223
|
+
})
|
|
1224
|
+
})
|
|
1225
|
+
|
|
1179
1226
|
function noopTickCounters() {
|
|
1180
1227
|
return {
|
|
1181
1228
|
merged: 0,
|
|
@@ -1202,6 +1249,68 @@ export function describeBuildStoreContract(name: string, factory: BuildStoreFact
|
|
|
1202
1249
|
}
|
|
1203
1250
|
}
|
|
1204
1251
|
|
|
1252
|
+
test('latest-only scans stay bounded across scans, runs and repositories', async () => {
|
|
1253
|
+
await withStore(factory, undefined, async (store) => {
|
|
1254
|
+
for (const repo of ['acme/scans', 'acme/foreign-scans']) await store.ensureRepo(repo)
|
|
1255
|
+
const appendScan = (repo: string, queued: number) =>
|
|
1256
|
+
store.appendRepo(repo, {
|
|
1257
|
+
actor: DISPATCHER,
|
|
1258
|
+
type: 'dispatcher.ticket-scan-completed',
|
|
1259
|
+
payload: {
|
|
1260
|
+
startedAt: '2026-10-14T00:00:00.000Z',
|
|
1261
|
+
mode: 'admission',
|
|
1262
|
+
readinessFingerprint: 'r',
|
|
1263
|
+
criteriaFingerprint: 'c',
|
|
1264
|
+
queued,
|
|
1265
|
+
invalidTickets: 0,
|
|
1266
|
+
ticketDiagnostics: [],
|
|
1267
|
+
dependencyBlocked: 0,
|
|
1268
|
+
dependencyDiagnostics: [],
|
|
1269
|
+
creationWithheld: 0,
|
|
1270
|
+
creationDiagnostics: [],
|
|
1271
|
+
},
|
|
1272
|
+
})
|
|
1273
|
+
await store.appendRepo('acme/scans', {
|
|
1274
|
+
actor: humanActor('op'),
|
|
1275
|
+
type: 'dispatcher.intake-set',
|
|
1276
|
+
payload: { enabled: true },
|
|
1277
|
+
})
|
|
1278
|
+
const check = async (expected: number, rows: number) => {
|
|
1279
|
+
const all = await store.getRepoEvents('acme/scans')
|
|
1280
|
+
const bounded = await store.getRepoStateEvents('acme/scans')
|
|
1281
|
+
expect(bounded).toEqual(projectRepositoryStateEvents(all))
|
|
1282
|
+
expect(bounded).toHaveLength(rows)
|
|
1283
|
+
const scans = bounded.filter((e) => e.type === 'dispatcher.ticket-scan-completed')
|
|
1284
|
+
expect(scans).toHaveLength(1)
|
|
1285
|
+
expect(scans[0]!.payload).toMatchObject({ queued: expected })
|
|
1286
|
+
expect(reduceDispatchSettings(bounded)).toEqual(reduceDispatchSettings(all))
|
|
1287
|
+
}
|
|
1288
|
+
for (let i = 0; i < 200; i++) await appendScan('acme/scans', i)
|
|
1289
|
+
await check(199, 2)
|
|
1290
|
+
for (let i = 200; i < 400; i++) {
|
|
1291
|
+
await appendScan('acme/scans', i)
|
|
1292
|
+
await appendScan('acme/foreign-scans', 999)
|
|
1293
|
+
}
|
|
1294
|
+
await check(399, 2)
|
|
1295
|
+
for (let run = 0; run < 20; run++) {
|
|
1296
|
+
for (const write of noopInvocation(`scan-run-${run}`))
|
|
1297
|
+
await store.appendRepo('acme/scans', write)
|
|
1298
|
+
await appendScan('acme/scans', run)
|
|
1299
|
+
}
|
|
1300
|
+
await check(19, 6)
|
|
1301
|
+
for (const write of noopInvocation('scan-final'))
|
|
1302
|
+
await store.appendRepo('acme/scans', write)
|
|
1303
|
+
await check(19, 6) // latest scan is older than the run anchor
|
|
1304
|
+
for (let i = 0; i < 100; i++) await appendScan('acme/scans', i)
|
|
1305
|
+
await check(99, 6)
|
|
1306
|
+
expect(
|
|
1307
|
+
(await store.getRepoEvents('acme/scans')).filter(
|
|
1308
|
+
(e) => e.type === 'dispatcher.ticket-scan-completed',
|
|
1309
|
+
),
|
|
1310
|
+
).toHaveLength(520)
|
|
1311
|
+
})
|
|
1312
|
+
})
|
|
1313
|
+
|
|
1205
1314
|
test('unknown repo rejects like getRepoEvents', async () => {
|
|
1206
1315
|
await withStore(factory, undefined, async (store) => {
|
|
1207
1316
|
await expect(store.getRepoStateEvents('acme/never-seen')).rejects.toThrow(/unknown repo/)
|
|
@@ -45,6 +45,7 @@ export type DigestEvent = DigestEventRow
|
|
|
45
45
|
|
|
46
46
|
/** The carried accumulator for `reduceBuildDigest`. */
|
|
47
47
|
export interface DigestAcc {
|
|
48
|
+
readinessChangeSeq?: number
|
|
48
49
|
terminal?: BuildDigest['terminal']
|
|
49
50
|
merged?: string
|
|
50
51
|
observations: { seq: number; ts: string }[]
|
|
@@ -57,7 +58,7 @@ export interface DigestAcc {
|
|
|
57
58
|
}
|
|
58
59
|
|
|
59
60
|
/** Bump when `DigestAcc`'s shape or fold semantics change. */
|
|
60
|
-
export const BUILD_DIGEST_REDUCER_VERSION =
|
|
61
|
+
export const BUILD_DIGEST_REDUCER_VERSION = 3
|
|
61
62
|
|
|
62
63
|
export const buildDigestReducer: IncrementalReducer<
|
|
63
64
|
DigestAcc,
|
|
@@ -68,6 +69,8 @@ export const buildDigestReducer: IncrementalReducer<
|
|
|
68
69
|
initial: (): DigestAcc => ({ observations: [], hosted: [], reclaimed: [], openInstance: null }),
|
|
69
70
|
fold(acc, events) {
|
|
70
71
|
for (const event of events) {
|
|
72
|
+
if (['pr.merged', 'build.completed', 'build.aborted'].includes(event.type))
|
|
73
|
+
acc.readinessChangeSeq = event.seq
|
|
71
74
|
if (event.type === 'build.completed') acc.terminal = 'done'
|
|
72
75
|
else if (event.type === 'build.aborted') acc.terminal = 'aborted'
|
|
73
76
|
else if (event.type === 'pr.merged') acc.merged = event.ts
|
|
@@ -84,11 +87,20 @@ export const buildDigestReducer: IncrementalReducer<
|
|
|
84
87
|
}
|
|
85
88
|
}
|
|
86
89
|
},
|
|
87
|
-
finish: ({
|
|
90
|
+
finish: ({
|
|
91
|
+
readinessChangeSeq,
|
|
92
|
+
terminal,
|
|
93
|
+
merged,
|
|
94
|
+
observations,
|
|
95
|
+
hosted,
|
|
96
|
+
reclaimed,
|
|
97
|
+
openInstance,
|
|
98
|
+
}) => {
|
|
88
99
|
const reclaimedSet = new Set(reclaimed)
|
|
89
100
|
const reclaimPending = hosted.some((seq) => !reclaimedSet.has(seq))
|
|
90
101
|
return {
|
|
91
102
|
observations: [...observations],
|
|
103
|
+
...(readinessChangeSeq !== undefined ? { readinessChangeSeq } : {}),
|
|
92
104
|
...(merged !== undefined ? { merged } : {}),
|
|
93
105
|
...(terminal !== undefined ? { terminal } : {}),
|
|
94
106
|
...(reclaimPending ? { reclaimPending: true as const } : {}),
|
|
@@ -46,6 +46,7 @@ import { createSessionScopedStore } from '../session-handle'
|
|
|
46
46
|
import { DIGEST_EVENT_TYPES, type DigestEventRow, reduceBuildDigest } from '../digest'
|
|
47
47
|
import {
|
|
48
48
|
REPOSITORY_STATE_EVENT_TYPES,
|
|
49
|
+
REPOSITORY_LATEST_EVENT_TYPES,
|
|
49
50
|
readRepoStateEventsWithAnchorRecheck,
|
|
50
51
|
} from '../repo-state-events'
|
|
51
52
|
import {
|
|
@@ -1188,14 +1189,26 @@ export class SqliteBuildStore implements BuildStore {
|
|
|
1188
1189
|
},
|
|
1189
1190
|
async (anchor) => {
|
|
1190
1191
|
const durable = inArray(repoEvents.type, [...REPOSITORY_STATE_EVENT_TYPES])
|
|
1192
|
+
const latest = REPOSITORY_LATEST_EVENT_TYPES.map((type) =>
|
|
1193
|
+
and(
|
|
1194
|
+
eq(repoEvents.type, type),
|
|
1195
|
+
sql`${repoEvents.seq} = (SELECT seq FROM repo_events WHERE repo = ${repo} AND type = ${type} ORDER BY seq DESC LIMIT 1)`,
|
|
1196
|
+
),
|
|
1197
|
+
)
|
|
1198
|
+
const tail =
|
|
1199
|
+
anchor === undefined
|
|
1200
|
+
? undefined
|
|
1201
|
+
: and(
|
|
1202
|
+
gte(repoEvents.seq, anchor),
|
|
1203
|
+
sql`${repoEvents.type} NOT IN (${sql.join(
|
|
1204
|
+
REPOSITORY_LATEST_EVENT_TYPES.map((type) => sql`${type}`),
|
|
1205
|
+
sql`, `,
|
|
1206
|
+
)})`,
|
|
1207
|
+
)
|
|
1191
1208
|
const rows = this.db
|
|
1192
1209
|
.select()
|
|
1193
1210
|
.from(repoEvents)
|
|
1194
|
-
.where(
|
|
1195
|
-
anchor === undefined
|
|
1196
|
-
? and(eq(repoEvents.repo, repo), durable)
|
|
1197
|
-
: and(eq(repoEvents.repo, repo), or(gte(repoEvents.seq, anchor), durable)),
|
|
1198
|
-
)
|
|
1211
|
+
.where(and(eq(repoEvents.repo, repo), or(durable, tail, ...latest)))
|
|
1199
1212
|
.orderBy(asc(repoEvents.seq))
|
|
1200
1213
|
.all()
|
|
1201
1214
|
return rows.map(
|
|
@@ -578,7 +578,7 @@ export class RemoteBuildStore implements BuildStore {
|
|
|
578
578
|
|
|
579
579
|
async getRepoHighWater(repo: string): Promise<number> {
|
|
580
580
|
// Additive route beside `state-events`; the store service and client
|
|
581
|
-
//
|
|
581
|
+
// retain this additive route throughout the supported client window.
|
|
582
582
|
return this.requestJson('GET', `${this.repoPath(repo)}/high-water`, repoHighWaterSchema).then(
|
|
583
583
|
(body) => (body as { seq: number }).seq,
|
|
584
584
|
)
|