@defrex/autobuild 0.8.0 → 0.9.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 +1 -1
- package/SPEC.md +313 -26
- package/bin/ab-build-runner.ts +8 -2
- package/docs/architecture.md +57 -14
- package/docs/assets/headline-wide.png +0 -0
- package/docs/configuration.md +151 -25
- package/docs/hosted-dispatcher.md +69 -13
- package/docs/operator-api.md +2 -2
- package/docs/remote-store-protocol.md +169 -16
- package/docs/remote-ticket-protocol.md +1 -1
- package/docs/setup.md +12 -9
- package/docs/website.md +10 -0
- package/package.json +43 -23
- package/packages/core/src/cli/artifact.ts +130 -6
- package/packages/core/src/cli/binary.ts +9 -1
- package/packages/core/src/cli/build-control.ts +110 -13
- package/packages/core/src/cli/context.ts +46 -0
- package/packages/core/src/cli/dashboard/model.ts +57 -7
- package/packages/core/src/cli/dashboard/poll.ts +69 -10
- package/packages/core/src/cli/dashboard/render.ts +34 -5
- package/packages/core/src/cli/dispatch-frontend.ts +54 -5
- package/packages/core/src/cli/dispatch.ts +320 -46
- package/packages/core/src/cli/harvest.ts +122 -8
- package/packages/core/src/cli/help.ts +47 -8
- package/packages/core/src/cli/init-readiness-remote.ts +110 -0
- package/packages/core/src/cli/init-readiness-shared.ts +72 -0
- package/packages/core/src/cli/init-validation.ts +248 -222
- package/packages/core/src/cli/main.ts +70 -11
- package/packages/core/src/cli/mcp.ts +23 -1
- package/packages/core/src/cli/plugin.ts +7 -4
- package/packages/core/src/cli/remote-poll.ts +277 -0
- package/packages/core/src/cli/repo-state.ts +22 -0
- package/packages/core/src/cli/repository-status.ts +39 -1
- package/packages/core/src/cli/status.ts +149 -27
- package/packages/core/src/cli/terminals.ts +2 -1
- package/packages/core/src/cli/ticket-assets.ts +247 -0
- package/packages/core/src/cli/ticket.ts +307 -7
- package/packages/core/src/cli/wait.ts +147 -39
- package/packages/core/src/cli/watch.ts +128 -166
- package/packages/core/src/config/live.ts +22 -0
- package/packages/core/src/config/pipeline-source.ts +376 -0
- package/packages/core/src/config/schema.ts +230 -19
- package/packages/core/src/events/catalog.ts +1 -0
- package/packages/core/src/events/globs.ts +47 -0
- package/packages/core/src/events/payloads.ts +52 -2
- package/packages/core/src/events/repository.ts +52 -1
- package/packages/core/src/events/sessions.ts +69 -10
- package/packages/core/src/events/workspace-remote.ts +12 -0
- package/packages/core/src/integration/harness.ts +2 -1
- package/packages/core/src/kernel/auto-merge-default.ts +123 -0
- package/packages/core/src/kernel/phases.ts +16 -2
- package/packages/core/src/kernel/reducer.ts +40 -1
- package/packages/core/src/operator/annotations.ts +6 -0
- package/packages/core/src/operator/control.ts +19 -5
- package/packages/core/src/operator/index.ts +71 -6
- package/packages/core/src/operator/query.ts +208 -19
- package/packages/core/src/operator/registry.ts +55 -10
- package/packages/core/src/operator/requests.ts +9 -6
- package/packages/core/src/operator/sandbox-host.ts +140 -0
- package/packages/core/src/operator/sandbox.ts +247 -14
- package/packages/core/src/operator/ticket-source-backend.ts +47 -0
- package/packages/core/src/orchestrator/approvals.ts +115 -0
- package/packages/core/src/orchestrator/conversation.ts +167 -0
- package/packages/core/src/orchestrator/model.ts +86 -0
- package/packages/core/src/orchestrator/prompt.ts +61 -0
- package/packages/core/src/orchestrator/tools.ts +117 -0
- package/packages/core/src/orchestrator/turn-runner.ts +457 -0
- package/packages/core/src/plugin-sdk/index.ts +51 -1
- package/packages/core/src/plugins/load.ts +135 -7
- package/packages/core/src/plugins/manifest.ts +183 -10
- package/packages/core/src/plugins/registry.ts +73 -4
- package/packages/core/src/ports/forge/create.ts +1 -1
- package/packages/core/src/ports/forge/fake.ts +31 -0
- package/packages/core/src/ports/forge/github-transport.ts +13 -2
- package/packages/core/src/ports/forge/github.ts +13 -2
- package/packages/core/src/ports/forge/local-git.ts +31 -1
- package/packages/core/src/ports/runner/claude.ts +2 -2
- package/packages/core/src/ports/runner/codex.ts +6 -2
- package/packages/core/src/ports/runner/fake.ts +7 -1
- package/packages/core/src/ports/runner/pi.ts +8 -6
- package/packages/core/src/ports/runner/provider-error.ts +13 -3
- package/packages/core/src/ports/tickets/{remote-index.ts → hosted-index.ts} +3 -2
- package/packages/core/src/ports/types.ts +17 -2
- package/packages/core/src/ports/workspace/builtin-capabilities.ts +114 -0
- package/packages/core/src/ports/workspace/create.ts +74 -39
- package/packages/core/src/ports/workspace/distribution-archive.ts +171 -12
- package/packages/core/src/ports/workspace/fake.ts +169 -4
- package/packages/core/src/ports/workspace/git-worktree.ts +246 -26
- package/packages/core/src/ports/workspace/github-origin.ts +36 -0
- package/packages/core/src/ports/workspace/operator-sandbox.ts +7 -1
- package/packages/core/src/ports/workspace/provider-capabilities.ts +171 -0
- package/packages/core/src/ports/workspace/vercel-capabilities.ts +110 -0
- package/packages/core/src/ports/workspace/vercel-sandbox.ts +142 -114
- package/packages/core/src/processes/build-child.ts +17 -2
- package/packages/core/src/processes/build-execution-state.ts +37 -0
- package/packages/core/src/processes/build-runner.ts +54 -3
- package/packages/core/src/processes/dispatcher.ts +247 -41
- package/packages/core/src/processes/harvest-child.ts +5 -0
- package/packages/core/src/processes/harvest-runner.ts +66 -19
- package/packages/core/src/processes/harvest.ts +196 -24
- package/packages/core/src/processes/orchestrator-tick.ts +319 -0
- package/packages/core/src/processes/sandbox-state.ts +10 -0
- package/packages/core/src/store/adapter.ts +32 -0
- package/packages/core/src/store/build-scope.ts +64 -0
- package/packages/core/src/store/contract.ts +868 -0
- package/packages/core/src/store/digest.ts +49 -0
- package/packages/core/src/store/local/schema.ts +29 -2
- package/packages/core/src/store/local/store.ts +338 -4
- package/packages/core/src/store/memory.ts +179 -0
- package/packages/core/src/store/new-build.ts +17 -0
- package/packages/core/src/store/phase-session.ts +71 -0
- package/packages/core/src/store/remote/client.ts +260 -1
- package/packages/core/src/store/remote/index.ts +12 -2
- package/packages/core/src/store/remote/protocol.ts +100 -12
- package/packages/core/src/store/remote/version.ts +6 -2
- package/packages/core/src/store/repo-state-events.ts +140 -0
- package/packages/core/src/store/retention.ts +7 -0
- package/packages/core/src/store/session-handle.ts +64 -0
- package/packages/core/src/{sessions/reducer.ts → store/session-reducer.ts} +28 -9
- package/packages/core/src/store/streams/session-writer.ts +42 -2
- package/packages/core/src/store/streams/wait.ts +4 -5
- package/packages/core/src/store/ticket-assets.ts +425 -0
- package/packages/core/src/store/types.ts +122 -1
- package/packages/core/src/testing/index.ts +59 -0
- package/skills/code-review/SKILL.md +5 -1
- package/skills/guide/SKILL.md +126 -33
- package/skills/guide/references/operator-api.md +2 -2
- package/skills/guide/references/plugin-authoring.md +17 -0
- package/skills/guide/references/remote-store-protocol.md +169 -16
- package/skills/guide/references/remote-ticket-protocol.md +1 -1
- package/skills/guide/references/setup.md +12 -9
- package/skills/implement/SKILL.md +3 -0
- package/skills/operate/SKILL.md +126 -0
- package/skills/plan/SKILL.md +4 -0
- package/skills/plan-review/SKILL.md +5 -1
- package/skills/spec/SKILL.md +46 -3
- package/packages/core/src/cli/dashboard/frame-image.ts +0 -373
- package/packages/core/src/cli/store-opening.contract.ts +0 -289
- package/packages/core/src/cli/testkit.ts +0 -194
- package/packages/core/src/markdown.ts +0 -67
- package/packages/core/src/operator/client.ts +0 -311
- package/packages/core/src/operator/server.ts +0 -616
- package/packages/core/src/operator/sessions.ts +0 -223
- package/packages/core/src/ports/runner/live-contract-fixture.ts +0 -176
- package/packages/core/src/ports/tickets/remote-server.ts +0 -185
- package/packages/core/src/store/remote/server.ts +0 -923
- package/packages/core/src/testing/store-failures.ts +0 -32
|
@@ -46,6 +46,7 @@ import { defaultProposalState } from './dispatcher'
|
|
|
46
46
|
import {
|
|
47
47
|
artifactRef,
|
|
48
48
|
evaluateHarvestPressure,
|
|
49
|
+
evaluateHarvestPressureFromDigests,
|
|
49
50
|
HARVEST_REPORT_ARTIFACT,
|
|
50
51
|
HARVEST_SCAN_ARTIFACT,
|
|
51
52
|
HARVEST_TRANSCRIPT_ARTIFACT,
|
|
@@ -230,7 +231,9 @@ export class HarvestRunner {
|
|
|
230
231
|
try {
|
|
231
232
|
await this.startHeartbeat()
|
|
232
233
|
await this.ensureLease()
|
|
233
|
-
|
|
234
|
+
// Bounded read (AUT-489): reduceHarvest and the verdict/failure filters
|
|
235
|
+
// below consume durable types only, so the subset is replay-equivalent.
|
|
236
|
+
let events = await store.getRepoStateEvents(repo)
|
|
234
237
|
let state = reduceHarvest(events)
|
|
235
238
|
run = actionableHarvestRun(state)
|
|
236
239
|
|
|
@@ -238,19 +241,48 @@ export class HarvestRunner {
|
|
|
238
241
|
// selected run comes from the full journal, so a shadowed failure or
|
|
239
242
|
// exhaustion barrier is recovered/reported before any later open run.
|
|
240
243
|
await this.controlBoundary(run?.run)
|
|
241
|
-
events = await store.
|
|
244
|
+
events = await store.getRepoStateEvents(repo)
|
|
242
245
|
state = reduceHarvest(events)
|
|
243
246
|
run = openHarvestRun(state)
|
|
244
247
|
|
|
245
248
|
if (!run) {
|
|
246
249
|
await this.controlBoundary()
|
|
247
|
-
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
//
|
|
250
|
+
// Flat-cost gate (AUT-521): the pressure trigger is evaluated from
|
|
251
|
+
// the journal and one repo-scoped digest batch read — no per-build
|
|
252
|
+
// history reads — so the idle evaluation no longer grows with
|
|
253
|
+
// accumulated finished builds. Only a tripped gate pays for the full
|
|
254
|
+
// unclaimed-observation scan, because the packet artifact needs the
|
|
255
|
+
// observation payloads no bounded source carries.
|
|
256
|
+
// Bounded read (AUT-489): the gate's journal input is consumed only
|
|
257
|
+
// through reduceHarvest, so the subset is replay-equivalent here too.
|
|
258
|
+
const [gateEvents, digests] = await Promise.all([
|
|
259
|
+
store.getRepoStateEvents(repo),
|
|
260
|
+
store.getRepoBuildDigests(repo),
|
|
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.
|
|
271
|
+
await this.controlBoundary()
|
|
272
|
+
return { outcome: 'idle' }
|
|
273
|
+
}
|
|
251
274
|
await this.controlBoundary()
|
|
252
|
-
const
|
|
253
|
-
|
|
275
|
+
const scan = await scanUnclaimedObservations(store, repo)
|
|
276
|
+
// The trigger came from an earlier read than this scan, and a claim
|
|
277
|
+
// may have landed in the window. Re-confirm the gate against the
|
|
278
|
+
// fresh scan (pure, free): an emptied window returns idle exactly as
|
|
279
|
+
// if the gate had not tripped, so a packet is only ever built from a
|
|
280
|
+
// scan that itself triggered.
|
|
281
|
+
const confirmed = evaluateHarvestPressure(scan, this.currentConfig().policy)
|
|
282
|
+
if (confirmed.trigger === undefined) {
|
|
283
|
+
await this.controlBoundary()
|
|
284
|
+
return { outcome: 'idle' }
|
|
285
|
+
}
|
|
254
286
|
const runId = this.deps.ids('harvest')
|
|
255
287
|
const packet = await makeHarvestScanPacket({
|
|
256
288
|
store,
|
|
@@ -280,14 +312,15 @@ export class HarvestRunner {
|
|
|
280
312
|
run: runId,
|
|
281
313
|
observations: packet.observations.map((item) => item.occurrence),
|
|
282
314
|
scan: artifactRef(deposited[0]!),
|
|
283
|
-
trigger:
|
|
315
|
+
trigger: confirmed.trigger,
|
|
284
316
|
...(this.deps.environment !== undefined
|
|
285
317
|
? { environment: this.deps.environment }
|
|
286
318
|
: {}),
|
|
287
319
|
},
|
|
288
320
|
}),
|
|
289
321
|
)
|
|
290
|
-
|
|
322
|
+
// Bounded read (AUT-489): reduceHarvest consumes durable types only.
|
|
323
|
+
events = await store.getRepoStateEvents(repo)
|
|
291
324
|
run = reduceHarvest(events).runs.find((candidate) => candidate.run === runId)
|
|
292
325
|
if (!run) throw new Error(`harvest.started did not reduce run "${runId}"`)
|
|
293
326
|
// A request racing the claim parks this exact run; its snapshot stays
|
|
@@ -448,7 +481,9 @@ export class HarvestRunner {
|
|
|
448
481
|
existing.verdict,
|
|
449
482
|
existing.artifact,
|
|
450
483
|
)
|
|
451
|
-
|
|
484
|
+
// Bounded read (AUT-489): only harvest.review.verdict facts are
|
|
485
|
+
// filtered here, and those are durable.
|
|
486
|
+
const event = (await this.deps.store.getRepoStateEvents(this.deps.repo)).find(
|
|
452
487
|
(
|
|
453
488
|
candidate,
|
|
454
489
|
): candidate is Extract<RepositoryEvent, { type: 'harvest.review.verdict' }> =>
|
|
@@ -515,7 +550,9 @@ export class HarvestRunner {
|
|
|
515
550
|
event.actor.kind === 'agent' &&
|
|
516
551
|
event.actor.session === session,
|
|
517
552
|
})
|
|
518
|
-
|
|
553
|
+
// Bounded read (AUT-489): only harvest.review.verdict facts are filtered
|
|
554
|
+
// here, and those are durable.
|
|
555
|
+
const events = await this.deps.store.getRepoStateEvents(this.deps.repo)
|
|
519
556
|
const verdict = [...events]
|
|
520
557
|
.reverse()
|
|
521
558
|
.find(
|
|
@@ -565,7 +602,9 @@ export class HarvestRunner {
|
|
|
565
602
|
): Promise<void> {
|
|
566
603
|
const { store, repo, ids, workspacePath } = this.deps
|
|
567
604
|
await this.ensureLease()
|
|
568
|
-
|
|
605
|
+
// Bounded read (AUT-489): reduceHarvest and the harvest.failed filter
|
|
606
|
+
// consume durable types only.
|
|
607
|
+
const events = await store.getRepoStateEvents(repo)
|
|
569
608
|
const matchingFailures = events.filter(
|
|
570
609
|
(event): event is Extract<RepositoryEvent, { type: 'harvest.failed' }> =>
|
|
571
610
|
event.type === 'harvest.failed' &&
|
|
@@ -823,7 +862,9 @@ export class HarvestRunner {
|
|
|
823
862
|
}): Promise<void> {
|
|
824
863
|
const { store, repo, ids, workspacePath } = this.deps
|
|
825
864
|
await this.ensureLease()
|
|
826
|
-
|
|
865
|
+
// Bounded read (AUT-489): reduceHarvest and the harvest.failed filter
|
|
866
|
+
// consume durable types only.
|
|
867
|
+
const events = await store.getRepoStateEvents(repo)
|
|
827
868
|
const matchingFailures = events.filter(
|
|
828
869
|
(event): event is Extract<RepositoryEvent, { type: 'harvest.failed' }> =>
|
|
829
870
|
event.type === 'harvest.failed' &&
|
|
@@ -1296,7 +1337,9 @@ export class HarvestRunner {
|
|
|
1296
1337
|
|
|
1297
1338
|
private async recordWorkflowFailure(run: string, error: unknown): Promise<void> {
|
|
1298
1339
|
await this.ensureLease()
|
|
1299
|
-
|
|
1340
|
+
// Bounded read (AUT-489): reduceHarvest and the harvest.failed filter
|
|
1341
|
+
// consume durable types only.
|
|
1342
|
+
const events = await this.deps.store.getRepoStateEvents(this.deps.repo)
|
|
1300
1343
|
const state = reduceHarvest(events)
|
|
1301
1344
|
const current = state.runs.find((candidate) => candidate.run === run)
|
|
1302
1345
|
if (current?.status !== 'running') return
|
|
@@ -1349,7 +1392,8 @@ export class HarvestRunner {
|
|
|
1349
1392
|
* outer budget and therefore remain recoverable. */
|
|
1350
1393
|
private async settleRecoveryExhaustion(run: string): Promise<void> {
|
|
1351
1394
|
await this.ensureLease()
|
|
1352
|
-
|
|
1395
|
+
// Bounded read (AUT-489): reduceHarvest consumes durable types only.
|
|
1396
|
+
const state = reduceHarvest(await this.deps.store.getRepoStateEvents(this.deps.repo))
|
|
1353
1397
|
const decision = decideHarvestControl(state, this.maxRecoveryAttempts)
|
|
1354
1398
|
if (decision.kind !== 'exhaust-recovery' || decision.run !== run) return
|
|
1355
1399
|
await this.finalizeRecoveryExhaustion(decision.run, decision.attempts, decision.limit)
|
|
@@ -1374,7 +1418,8 @@ export class HarvestRunner {
|
|
|
1374
1418
|
})
|
|
1375
1419
|
|
|
1376
1420
|
await this.ensureLease()
|
|
1377
|
-
|
|
1421
|
+
// Bounded read (AUT-489): reduceHarvest consumes durable types only.
|
|
1422
|
+
const state = reduceHarvest(await this.deps.store.getRepoStateEvents(this.deps.repo))
|
|
1378
1423
|
const decision = decideHarvestControl(state, this.maxRecoveryAttempts)
|
|
1379
1424
|
if (
|
|
1380
1425
|
decision.kind !== 'exhaust-recovery' ||
|
|
@@ -1410,7 +1455,8 @@ export class HarvestRunner {
|
|
|
1410
1455
|
private async controlBoundary(run?: string): Promise<void> {
|
|
1411
1456
|
while (true) {
|
|
1412
1457
|
await this.ensureLease()
|
|
1413
|
-
|
|
1458
|
+
// Bounded read (AUT-489): reduceHarvest consumes durable types only.
|
|
1459
|
+
const state = reduceHarvest(await this.deps.store.getRepoStateEvents(this.deps.repo))
|
|
1414
1460
|
const decision = decideHarvestControl(state, this.maxRecoveryAttempts)
|
|
1415
1461
|
if (decision.kind === 'proceed') return
|
|
1416
1462
|
if (decision.kind === 'park') {
|
|
@@ -1453,7 +1499,8 @@ export class HarvestRunner {
|
|
|
1453
1499
|
}
|
|
1454
1500
|
|
|
1455
1501
|
private async refreshRun(run: string): Promise<HarvestRunState> {
|
|
1456
|
-
|
|
1502
|
+
// Bounded read (AUT-489): reduceHarvest consumes durable types only.
|
|
1503
|
+
const state = reduceHarvest(await this.deps.store.getRepoStateEvents(this.deps.repo))
|
|
1457
1504
|
const found = state.runs.find((candidate) => candidate.run === run)
|
|
1458
1505
|
if (!found) throw new Error(`unknown harvest run "${run}"`)
|
|
1459
1506
|
return found
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** Deterministic halves of observation harvest: scan, dedup, validation, and
|
|
2
2
|
* proposal rendering. Agent judgment is deliberately absent from this file. */
|
|
3
|
+
import type { AbEvent } from '../events/catalog'
|
|
3
4
|
import type { ArtifactRef } from '../ontology'
|
|
4
5
|
import {
|
|
5
6
|
harvestProposalSetSchema,
|
|
@@ -26,7 +27,14 @@ import {
|
|
|
26
27
|
} from '../kernel/harvest'
|
|
27
28
|
import type { TicketSource } from '../ports/types'
|
|
28
29
|
import { specConformance } from '../spec-standard'
|
|
29
|
-
import {
|
|
30
|
+
import type { RepositoryEvent } from '../events/repository'
|
|
31
|
+
import {
|
|
32
|
+
contentHash,
|
|
33
|
+
toBytes,
|
|
34
|
+
type BuildDigest,
|
|
35
|
+
type BuildRecord,
|
|
36
|
+
type BuildStore,
|
|
37
|
+
} from '../store/types'
|
|
30
38
|
|
|
31
39
|
export const HARVEST_SCAN_ARTIFACT = 'harvest-scan'
|
|
32
40
|
export const HARVEST_PROPOSALS_ARTIFACT = 'harvest-proposals'
|
|
@@ -52,21 +60,33 @@ export interface HarvestPressure {
|
|
|
52
60
|
trigger?: HarvestTrigger
|
|
53
61
|
}
|
|
54
62
|
|
|
55
|
-
/**
|
|
56
|
-
* unclaimed
|
|
57
|
-
|
|
58
|
-
|
|
63
|
+
/** The build- and occurrence-shaped facts both pressure evaluations project
|
|
64
|
+
* onto: the unclaimed occurrences with their event timestamps, and the merge
|
|
65
|
+
* facts. Keeping the oldest-observation selection and drift arithmetic in one
|
|
66
|
+
* place makes the scan-based and digest-based evaluations equal by
|
|
67
|
+
* construction rather than by parallel maintenance (AUT-521). */
|
|
68
|
+
interface HarvestPressureFacts {
|
|
69
|
+
unclaimed: { build: string; seq: number; ts: string }[]
|
|
70
|
+
merges: { build: string; ts: string }[]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function evaluateHarvestPressureFacts(
|
|
74
|
+
facts: HarvestPressureFacts,
|
|
59
75
|
policy: { harvestThreshold: number; harvestMaxDrift: number },
|
|
60
76
|
): HarvestPressure {
|
|
61
|
-
const observationCount =
|
|
77
|
+
const observationCount = facts.unclaimed.length
|
|
62
78
|
if (observationCount === 0) return { observationCount, drift: 0 }
|
|
63
79
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
80
|
+
// The oldest unclaimed occurrence: min ts, then build ascending, then seq
|
|
81
|
+
// ascending — exactly what the scan projection's sort-by-(build,seq) then
|
|
82
|
+
// strict-`<`-reduce produced, now stated as one comparator.
|
|
83
|
+
const oldest = [...facts.unclaimed].sort(
|
|
84
|
+
(a, b) =>
|
|
85
|
+
(a.ts < b.ts ? -1 : a.ts > b.ts ? 1 : 0) || a.build.localeCompare(b.build) || a.seq - b.seq,
|
|
86
|
+
)[0]!
|
|
67
87
|
const mergedBuilds = new Set<string>()
|
|
68
|
-
for (const merge of
|
|
69
|
-
if (merge.build === oldest.
|
|
88
|
+
for (const merge of facts.merges) {
|
|
89
|
+
if (merge.build === oldest.build || merge.ts <= oldest.ts) continue
|
|
70
90
|
mergedBuilds.add(merge.build)
|
|
71
91
|
}
|
|
72
92
|
const drift = mergedBuilds.size
|
|
@@ -87,23 +107,94 @@ export function evaluateHarvestPressure(
|
|
|
87
107
|
}
|
|
88
108
|
}
|
|
89
109
|
|
|
90
|
-
/**
|
|
91
|
-
*
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
110
|
+
/** Pure two-dimensional Harvest gate. Drift is measured from the oldest
|
|
111
|
+
* unclaimed observation and excludes that observation's own build. */
|
|
112
|
+
export function evaluateHarvestPressure(
|
|
113
|
+
scan: Pick<HarvestScanResult, 'observations' | 'merges'>,
|
|
114
|
+
policy: { harvestThreshold: number; harvestMaxDrift: number },
|
|
115
|
+
): HarvestPressure {
|
|
116
|
+
return evaluateHarvestPressureFacts(
|
|
117
|
+
{
|
|
118
|
+
unclaimed: scan.observations.map((observation) => ({
|
|
119
|
+
build: observation.occurrence.build,
|
|
120
|
+
seq: observation.occurrence.seq,
|
|
121
|
+
ts: observation.ts,
|
|
122
|
+
})),
|
|
123
|
+
merges: scan.merges,
|
|
124
|
+
},
|
|
125
|
+
policy,
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The same gate from build digests and the repository journal alone
|
|
130
|
+
* (AUT-521): claimedness comes from the journal, unclaimed occurrences and
|
|
131
|
+
* merge facts from the digests — no per-build history reads. The merge facts
|
|
132
|
+
* use each digest's latest `pr.merged` ts; drift counts a build when any of
|
|
133
|
+
* its merges is strictly after the oldest unclaimed observation, and
|
|
134
|
+
* `max(merge.ts) > oldest.ts` is equivalent to `any(merge.ts) > oldest.ts`,
|
|
135
|
+
* so latest-wins carries exactly the drift fact the scan collects. */
|
|
136
|
+
export function evaluateHarvestPressureFromDigests(input: {
|
|
137
|
+
digests: Map<string, BuildDigest>
|
|
138
|
+
harvestEvents: RepositoryEvent[]
|
|
139
|
+
policy: { harvestThreshold: number; harvestMaxDrift: number }
|
|
140
|
+
}): HarvestPressure {
|
|
141
|
+
const claimed = claimedOccurrenceKeys(reduceHarvest(input.harvestEvents))
|
|
142
|
+
const unclaimed: HarvestPressureFacts['unclaimed'] = []
|
|
143
|
+
const merges: HarvestPressureFacts['merges'] = []
|
|
144
|
+
for (const digest of input.digests.values()) {
|
|
145
|
+
for (const observation of digest.observations) {
|
|
146
|
+
if (claimed.has(occurrenceKey({ build: digest.slug, seq: observation.seq }))) continue
|
|
147
|
+
unclaimed.push({ build: digest.slug, seq: observation.seq, ts: observation.ts })
|
|
148
|
+
}
|
|
149
|
+
if (digest.merged !== undefined) merges.push({ build: digest.slug, ts: digest.merged })
|
|
150
|
+
}
|
|
151
|
+
return evaluateHarvestPressureFacts({ unclaimed, merges }, input.policy)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The dispatcher harvest-launch gate's read sequence as one named seam
|
|
155
|
+
* (AUT-521): `ensureRepo`, one repo-scoped digest batch read, and the pure
|
|
156
|
+
* digest evaluation with the caller's policy. Per-evaluation cost is one
|
|
157
|
+
* journal read (passed in by the caller, which already holds it) plus one
|
|
158
|
+
* bounded digest read — flat in the number of accumulated finished builds,
|
|
159
|
+
* where the previous scan read every build's full history per evaluation. */
|
|
160
|
+
export async function evaluateHarvestPressureFromStore(input: {
|
|
161
|
+
store: BuildStore
|
|
162
|
+
repo: string
|
|
163
|
+
harvestEvents: RepositoryEvent[]
|
|
164
|
+
policy: { harvestThreshold: number; harvestMaxDrift: number }
|
|
165
|
+
}): Promise<HarvestPressure> {
|
|
166
|
+
await input.store.ensureRepo(input.repo)
|
|
167
|
+
const digests = await input.store.getRepoBuildDigests(input.repo)
|
|
168
|
+
return evaluateHarvestPressureFromDigests({
|
|
169
|
+
digests,
|
|
170
|
+
harvestEvents: input.harvestEvents,
|
|
171
|
+
policy: input.policy,
|
|
172
|
+
})
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The deterministic core of the unclaimed-observation scan, over
|
|
176
|
+
* already-loaded streams: exactly the reduce/claim/collect/sort the
|
|
177
|
+
* store-reading scan performs, with no store calls. The dashboard consumes
|
|
178
|
+
* this directly with the journal and per-build event arrays it has already
|
|
179
|
+
* fetched (AUT-486), while `scanUnclaimedObservations` keeps the
|
|
180
|
+
* ensure/read/delegate shape its other callers depend on. */
|
|
181
|
+
export function collectUnclaimedObservations(input: {
|
|
182
|
+
repo: string
|
|
183
|
+
records: BuildRecord[]
|
|
184
|
+
eventsByBuild: Map<string, AbEvent[]>
|
|
185
|
+
harvestEvents: RepositoryEvent[]
|
|
186
|
+
}): HarvestScanResult {
|
|
187
|
+
const state = reduceHarvest(input.harvestEvents)
|
|
100
188
|
const claimed = claimedOccurrenceKeys(state)
|
|
101
189
|
const observations: HarvestObservation[] = []
|
|
102
190
|
const merges: HarvestMerge[] = []
|
|
103
191
|
|
|
104
|
-
for (const record of
|
|
105
|
-
if (record.repo !== repo) continue
|
|
106
|
-
const events =
|
|
192
|
+
for (const record of input.records) {
|
|
193
|
+
if (record.repo !== input.repo) continue
|
|
194
|
+
const events = input.eventsByBuild.get(record.slug)
|
|
195
|
+
if (events === undefined) {
|
|
196
|
+
throw new Error(`eventsByBuild is missing an entry for build "${record.slug}"`)
|
|
197
|
+
}
|
|
107
198
|
for (const event of events) {
|
|
108
199
|
if (event.type === 'pr.merged') {
|
|
109
200
|
merges.push({ build: record.slug, ts: event.ts })
|
|
@@ -132,6 +223,87 @@ export async function scanUnclaimedObservations(
|
|
|
132
223
|
return { observations, merges, state }
|
|
133
224
|
}
|
|
134
225
|
|
|
226
|
+
/** The unclaimed-observation count from build digests and the repository
|
|
227
|
+
* journal alone (AUT-487): the same reduce/claim/count the store-reading scan
|
|
228
|
+
* performs, with no per-build history reads. The operator query consumes this
|
|
229
|
+
* directly, and the terminal dashboards consume it through
|
|
230
|
+
* `sampleUnclaimedObservationCount` (AUT-524), while `scanUnclaimedObservations`
|
|
231
|
+
* keeps the full-scan shape its remaining callers depend on (the harvest
|
|
232
|
+
* runner's packet scan — the only source of observation payloads — and its
|
|
233
|
+
* gate re-confirmation; the dispatcher's harvest-launch gate itself evaluates
|
|
234
|
+
* from digests, AUT-521). Occurrences are keyed `{build, seq}`; payload ids
|
|
235
|
+
* are not assumed globally unique. */
|
|
236
|
+
export function unclaimedObservationCount(input: {
|
|
237
|
+
digests: Map<string, BuildDigest>
|
|
238
|
+
harvestEvents: RepositoryEvent[]
|
|
239
|
+
}): number {
|
|
240
|
+
const claimed = claimedOccurrenceKeys(reduceHarvest(input.harvestEvents))
|
|
241
|
+
let count = 0
|
|
242
|
+
for (const digest of input.digests.values()) {
|
|
243
|
+
for (const observation of digest.observations) {
|
|
244
|
+
if (!claimed.has(occurrenceKey({ build: digest.slug, seq: observation.seq }))) count += 1
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return count
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** The shared missing-record journal read (AUT-524): a repository whose
|
|
251
|
+
* journal record does not yet exist has an empty journal by definition, so
|
|
252
|
+
* the probe answers `[]` instead of letting `getRepoStateEvents` reject. The
|
|
253
|
+
* read writes nothing — a display path must not `ensureRepo` — while
|
|
254
|
+
* `scanUnclaimedObservations` keeps its materializing `ensureRepo` for its
|
|
255
|
+
* remaining callers.
|
|
256
|
+
*
|
|
257
|
+
* The journal read is bounded (AUT-489): every consumer — the terminal
|
|
258
|
+
* dashboards' observation-pressure sample, the operator query
|
|
259
|
+
* (operator/query.ts), the dispatcher's repository-wide controls and ready
|
|
260
|
+
* scans, and the operator sandbox service's journal reads — reduces durable
|
|
261
|
+
* event types plus the latest-run tail only, so the subset is
|
|
262
|
+
* replay-equivalent to a full journal replay. */
|
|
263
|
+
export async function readRepoEventsIfRecorded(
|
|
264
|
+
store: BuildStore,
|
|
265
|
+
repo: string,
|
|
266
|
+
): Promise<RepositoryEvent[]> {
|
|
267
|
+
return (await store.getRepo(repo)) === null ? [] : store.getRepoStateEvents(repo)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** The terminal dashboards' observation-pressure sample (AUT-487): build
|
|
271
|
+
* digests plus the repository journal, reduced to the unclaimed-observation
|
|
272
|
+
* count. Store traffic is flat in the finished-build count — one journal read
|
|
273
|
+
* plus one repo-scoped digest read, plus the journal-record probe inside
|
|
274
|
+
* `readRepoEventsIfRecorded`. Its missing-record treatment (AUT-524) is the
|
|
275
|
+
* shared helper's, above. */
|
|
276
|
+
export async function sampleUnclaimedObservationCount(
|
|
277
|
+
store: BuildStore,
|
|
278
|
+
repo: string,
|
|
279
|
+
): Promise<number> {
|
|
280
|
+
const [digests, harvestEvents] = await Promise.all([
|
|
281
|
+
store.getRepoBuildDigests(repo),
|
|
282
|
+
readRepoEventsIfRecorded(store, repo),
|
|
283
|
+
])
|
|
284
|
+
return unclaimedObservationCount({ digests, harvestEvents })
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Raw structured `observation.recorded` envelopes across this repository.
|
|
288
|
+
* The pair `{build, seq}` is the occurrence key; payload ids are not assumed
|
|
289
|
+
* globally unique. */
|
|
290
|
+
export async function scanUnclaimedObservations(
|
|
291
|
+
store: BuildStore,
|
|
292
|
+
repo: string,
|
|
293
|
+
): Promise<HarvestScanResult> {
|
|
294
|
+
await store.ensureRepo(repo)
|
|
295
|
+
// Bounded read (AUT-489): only harvest facts are consumed here; the
|
|
296
|
+
// per-build history reads stay as AUT-487 left them.
|
|
297
|
+
const harvestEvents = await store.getRepoStateEvents(repo)
|
|
298
|
+
const records = await store.listBuilds()
|
|
299
|
+
const eventsByBuild = new Map<string, AbEvent[]>()
|
|
300
|
+
for (const record of records) {
|
|
301
|
+
if (record.repo !== repo) continue
|
|
302
|
+
eventsByBuild.set(record.slug, await store.getEvents(record.slug))
|
|
303
|
+
}
|
|
304
|
+
return collectUnclaimedObservations({ repo, records, eventsByBuild, harvestEvents })
|
|
305
|
+
}
|
|
306
|
+
|
|
135
307
|
/** Distinct previously filed/joined proposal tickets, reconciled through the
|
|
136
308
|
* TicketSource's native lifecycle semantics. Resolved and missing entries stay
|
|
137
309
|
* in the packet as tombstones rather than disappearing and being re-filed. */
|