@defrex/autobuild 0.8.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/LICENSE +201 -0
- package/README.md +192 -0
- package/SPEC.md +2269 -0
- package/bin/ab-build-runner.ts +47 -0
- package/bin/ab-dev.ts +27 -0
- package/bin/ab-dispatch-kernel.ts +92 -0
- package/bin/ab-harvest-runner.ts +29 -0
- package/bin/ab-init-probe.ts +19 -0
- package/bin/ab-process-group-reaper.ts +32 -0
- package/bin/ab.ts +6 -0
- package/bin/agent/ab +3 -0
- package/docs/answering-blocked-builds.md +89 -0
- package/docs/architecture.md +726 -0
- package/docs/assets/headline-wide.png +0 -0
- package/docs/configuration.md +1623 -0
- package/docs/hosted-dispatcher-cutover-validation.md +178 -0
- package/docs/hosted-dispatcher.md +204 -0
- package/docs/mcp.md +119 -0
- package/docs/operator-api.md +186 -0
- package/docs/remote-store-protocol.md +972 -0
- package/docs/remote-ticket-protocol.md +79 -0
- package/docs/setup.md +289 -0
- package/docs/spec-standard.md +63 -0
- package/docs/ticket-dependencies.md +59 -0
- package/docs/vercel-sandbox-rollout-probe.md +7 -0
- package/package.json +135 -0
- package/packages/core/src/cli/args.ts +74 -0
- package/packages/core/src/cli/artifact.ts +182 -0
- package/packages/core/src/cli/binary.ts +198 -0
- package/packages/core/src/cli/build-control.ts +789 -0
- package/packages/core/src/cli/build-progress.ts +55 -0
- package/packages/core/src/cli/bulk-control.ts +385 -0
- package/packages/core/src/cli/context.ts +470 -0
- package/packages/core/src/cli/dashboard/actions.ts +59 -0
- package/packages/core/src/cli/dashboard/cells.ts +92 -0
- package/packages/core/src/cli/dashboard/composer.ts +230 -0
- package/packages/core/src/cli/dashboard/detail.ts +100 -0
- package/packages/core/src/cli/dashboard/frame-image.ts +373 -0
- package/packages/core/src/cli/dashboard/live.ts +152 -0
- package/packages/core/src/cli/dashboard/model.ts +1173 -0
- package/packages/core/src/cli/dashboard/poll.ts +196 -0
- package/packages/core/src/cli/dashboard/render.ts +1568 -0
- package/packages/core/src/cli/dashboard/selection.ts +66 -0
- package/packages/core/src/cli/dashboard/session-feed.ts +244 -0
- package/packages/core/src/cli/dashboard/session-view.ts +506 -0
- package/packages/core/src/cli/dashboard/transcript.ts +108 -0
- package/packages/core/src/cli/dev.ts +46 -0
- package/packages/core/src/cli/dispatch-frontend.ts +1045 -0
- package/packages/core/src/cli/dispatch-process.ts +259 -0
- package/packages/core/src/cli/dispatch.ts +3692 -0
- package/packages/core/src/cli/dotenv.ts +48 -0
- package/packages/core/src/cli/env.ts +206 -0
- package/packages/core/src/cli/harvest.ts +698 -0
- package/packages/core/src/cli/help.ts +713 -0
- package/packages/core/src/cli/init-agent.ts +106 -0
- package/packages/core/src/cli/init-validation.ts +583 -0
- package/packages/core/src/cli/init.ts +756 -0
- package/packages/core/src/cli/installation.ts +333 -0
- package/packages/core/src/cli/keyboard.ts +261 -0
- package/packages/core/src/cli/main.ts +1399 -0
- package/packages/core/src/cli/mcp.ts +279 -0
- package/packages/core/src/cli/models.ts +65 -0
- package/packages/core/src/cli/observe.ts +55 -0
- package/packages/core/src/cli/plugin.ts +227 -0
- package/packages/core/src/cli/pr-attachments.ts +138 -0
- package/packages/core/src/cli/pr-summary.ts +124 -0
- package/packages/core/src/cli/publication-settlement.ts +188 -0
- package/packages/core/src/cli/repo-state.ts +213 -0
- package/packages/core/src/cli/repository-status.ts +80 -0
- package/packages/core/src/cli/self-update.ts +443 -0
- package/packages/core/src/cli/status.ts +895 -0
- package/packages/core/src/cli/store-opening.contract.ts +289 -0
- package/packages/core/src/cli/store-opening.ts +167 -0
- package/packages/core/src/cli/store-ref.ts +33 -0
- package/packages/core/src/cli/terminal-restore.ts +174 -0
- package/packages/core/src/cli/terminal.ts +361 -0
- package/packages/core/src/cli/terminals.ts +995 -0
- package/packages/core/src/cli/testkit.ts +194 -0
- package/packages/core/src/cli/ticket.ts +484 -0
- package/packages/core/src/cli/upgrade-agent.ts +180 -0
- package/packages/core/src/cli/upgrade-commit.ts +468 -0
- package/packages/core/src/cli/upgrade-notice.ts +91 -0
- package/packages/core/src/cli/upgrade-progress.ts +110 -0
- package/packages/core/src/cli/upgrade.ts +888 -0
- package/packages/core/src/cli/wait.ts +587 -0
- package/packages/core/src/cli/watch.ts +932 -0
- package/packages/core/src/config/live.ts +255 -0
- package/packages/core/src/config/load.ts +98 -0
- package/packages/core/src/config/roles.ts +438 -0
- package/packages/core/src/config/schema.ts +774 -0
- package/packages/core/src/distribution.ts +24 -0
- package/packages/core/src/events/catalog.ts +201 -0
- package/packages/core/src/events/envelope.ts +59 -0
- package/packages/core/src/events/payloads.ts +618 -0
- package/packages/core/src/events/repository.ts +559 -0
- package/packages/core/src/events/sessions.ts +180 -0
- package/packages/core/src/harvest/schema.ts +147 -0
- package/packages/core/src/ids.ts +45 -0
- package/packages/core/src/integration/harness.ts +681 -0
- package/packages/core/src/kernel/auto-merge.ts +259 -0
- package/packages/core/src/kernel/converge.ts +117 -0
- package/packages/core/src/kernel/dispatch-settings.ts +60 -0
- package/packages/core/src/kernel/dispatch-status.ts +141 -0
- package/packages/core/src/kernel/engine.ts +1047 -0
- package/packages/core/src/kernel/harvest.ts +823 -0
- package/packages/core/src/kernel/origin.ts +69 -0
- package/packages/core/src/kernel/phases.ts +196 -0
- package/packages/core/src/kernel/plan-verify-selection.ts +105 -0
- package/packages/core/src/kernel/pr-attachments.ts +82 -0
- package/packages/core/src/kernel/reducer.ts +752 -0
- package/packages/core/src/kernel/stall.ts +155 -0
- package/packages/core/src/kernel/verify-applicability.ts +52 -0
- package/packages/core/src/markdown.ts +67 -0
- package/packages/core/src/ontology.ts +288 -0
- package/packages/core/src/open-map.ts +203 -0
- package/packages/core/src/operator/annotations.ts +187 -0
- package/packages/core/src/operator/client.ts +311 -0
- package/packages/core/src/operator/control.ts +117 -0
- package/packages/core/src/operator/index.ts +62 -0
- package/packages/core/src/operator/presentation.ts +22 -0
- package/packages/core/src/operator/protocol.ts +98 -0
- package/packages/core/src/operator/query.ts +201 -0
- package/packages/core/src/operator/registry.ts +1043 -0
- package/packages/core/src/operator/requests.ts +138 -0
- package/packages/core/src/operator/sandbox.ts +458 -0
- package/packages/core/src/operator/server.ts +616 -0
- package/packages/core/src/operator/sessions.ts +223 -0
- package/packages/core/src/operator/tickets.ts +177 -0
- package/packages/core/src/plugin-sdk/index.ts +174 -0
- package/packages/core/src/plugins/contract-entry.ts +74 -0
- package/packages/core/src/plugins/contract-env.ts +3 -0
- package/packages/core/src/plugins/load.ts +259 -0
- package/packages/core/src/plugins/manifest.ts +236 -0
- package/packages/core/src/plugins/registry.ts +274 -0
- package/packages/core/src/plugins/runtimes.ts +75 -0
- package/packages/core/src/ports/forge/contract.ts +258 -0
- package/packages/core/src/ports/forge/create.ts +74 -0
- package/packages/core/src/ports/forge/fake.ts +488 -0
- package/packages/core/src/ports/forge/github-pr-attachments.ts +351 -0
- package/packages/core/src/ports/forge/github-transport.ts +352 -0
- package/packages/core/src/ports/forge/github.ts +1162 -0
- package/packages/core/src/ports/forge/local-git.ts +568 -0
- package/packages/core/src/ports/runner/claude.ts +873 -0
- package/packages/core/src/ports/runner/cli-stream.ts +90 -0
- package/packages/core/src/ports/runner/codex.ts +873 -0
- package/packages/core/src/ports/runner/contract.ts +492 -0
- package/packages/core/src/ports/runner/fake.ts +213 -0
- package/packages/core/src/ports/runner/json-record.ts +2 -0
- package/packages/core/src/ports/runner/live-contract-fixture.ts +176 -0
- package/packages/core/src/ports/runner/one-shot.ts +29 -0
- package/packages/core/src/ports/runner/pi-bridge-core.ts +136 -0
- package/packages/core/src/ports/runner/pi-bridge.js +6 -0
- package/packages/core/src/ports/runner/pi-rpc.ts +454 -0
- package/packages/core/src/ports/runner/pi.ts +617 -0
- package/packages/core/src/ports/runner/production.ts +74 -0
- package/packages/core/src/ports/runner/provider-error.ts +121 -0
- package/packages/core/src/ports/runner/routing.ts +234 -0
- package/packages/core/src/ports/runner/runtime.ts +214 -0
- package/packages/core/src/ports/runner/session-env.ts +34 -0
- package/packages/core/src/ports/runner/stream-parts.ts +103 -0
- package/packages/core/src/ports/tickets/contract.ts +570 -0
- package/packages/core/src/ports/tickets/create.ts +135 -0
- package/packages/core/src/ports/tickets/fake.ts +215 -0
- package/packages/core/src/ports/tickets/file.ts +559 -0
- package/packages/core/src/ports/tickets/linear.ts +811 -0
- package/packages/core/src/ports/tickets/operations.ts +161 -0
- package/packages/core/src/ports/tickets/remote-index.ts +13 -0
- package/packages/core/src/ports/tickets/remote-protocol.ts +80 -0
- package/packages/core/src/ports/tickets/remote-server.ts +185 -0
- package/packages/core/src/ports/tickets/remote.ts +130 -0
- package/packages/core/src/ports/tickets/update.ts +51 -0
- package/packages/core/src/ports/types.ts +528 -0
- package/packages/core/src/ports/workspace/build-execution.ts +123 -0
- package/packages/core/src/ports/workspace/contract.ts +136 -0
- package/packages/core/src/ports/workspace/create.ts +132 -0
- package/packages/core/src/ports/workspace/distribution-archive.ts +317 -0
- package/packages/core/src/ports/workspace/fake.ts +432 -0
- package/packages/core/src/ports/workspace/git-worktree.ts +848 -0
- package/packages/core/src/ports/workspace/harvest-execution.ts +111 -0
- package/packages/core/src/ports/workspace/in-process-build-execution.ts +30 -0
- package/packages/core/src/ports/workspace/local-build-execution.ts +88 -0
- package/packages/core/src/ports/workspace/operator-sandbox.ts +142 -0
- package/packages/core/src/ports/workspace/phase-scratch.ts +156 -0
- package/packages/core/src/ports/workspace/vercel-sandbox.ts +2541 -0
- package/packages/core/src/processes/build-child-exit.ts +78 -0
- package/packages/core/src/processes/build-child.ts +119 -0
- package/packages/core/src/processes/build-execution-state.ts +95 -0
- package/packages/core/src/processes/build-parent-watch.ts +35 -0
- package/packages/core/src/processes/build-runner.ts +2614 -0
- package/packages/core/src/processes/dispatcher.ts +2825 -0
- package/packages/core/src/processes/execution-settlement.ts +171 -0
- package/packages/core/src/processes/harvest-child.ts +115 -0
- package/packages/core/src/processes/harvest-execution-state.ts +121 -0
- package/packages/core/src/processes/harvest-runner.ts +1656 -0
- package/packages/core/src/processes/harvest.ts +595 -0
- package/packages/core/src/processes/infrastructure-failure-budget.ts +146 -0
- package/packages/core/src/processes/phase-failure-budget.ts +52 -0
- package/packages/core/src/processes/process-group-reaper.ts +35 -0
- package/packages/core/src/processes/process-group.ts +65 -0
- package/packages/core/src/processes/publication-loss.ts +62 -0
- package/packages/core/src/processes/publication-state.ts +93 -0
- package/packages/core/src/processes/sandbox-state.ts +101 -0
- package/packages/core/src/registry.ts +24 -0
- package/packages/core/src/sessions/reducer.ts +185 -0
- package/packages/core/src/skills.ts +7 -0
- package/packages/core/src/spec-standard.ts +47 -0
- package/packages/core/src/store/adapter.ts +80 -0
- package/packages/core/src/store/build-scope.ts +272 -0
- package/packages/core/src/store/contract.ts +2581 -0
- package/packages/core/src/store/local/blobs.ts +38 -0
- package/packages/core/src/store/local/schema.ts +180 -0
- package/packages/core/src/store/local/store.ts +1713 -0
- package/packages/core/src/store/memory.ts +1138 -0
- package/packages/core/src/store/phase-session.ts +425 -0
- package/packages/core/src/store/remote/client.ts +780 -0
- package/packages/core/src/store/remote/index.ts +12 -0
- package/packages/core/src/store/remote/protocol.ts +348 -0
- package/packages/core/src/store/remote/server.ts +923 -0
- package/packages/core/src/store/remote/token.ts +137 -0
- package/packages/core/src/store/remote/version.ts +10 -0
- package/packages/core/src/store/retention.ts +91 -0
- package/packages/core/src/store/session-handle.ts +265 -0
- package/packages/core/src/store/streams/assemble.ts +79 -0
- package/packages/core/src/store/streams/lock.ts +42 -0
- package/packages/core/src/store/streams/session-writer.ts +306 -0
- package/packages/core/src/store/streams/types.ts +171 -0
- package/packages/core/src/store/streams/wait.ts +127 -0
- package/packages/core/src/store/subscribe.ts +105 -0
- package/packages/core/src/store/types.ts +466 -0
- package/packages/core/src/testing/fixed.ts +44 -0
- package/packages/core/src/testing/store-failures.ts +32 -0
- package/packages/core/src/zod-issues.ts +91 -0
- package/skills/code-review/SKILL.md +100 -0
- package/skills/finalize/SKILL.md +44 -0
- package/skills/guide/SKILL.md +1781 -0
- package/skills/guide/references/operator-api.md +164 -0
- package/skills/guide/references/plugin-authoring.md +354 -0
- package/skills/guide/references/remote-store-protocol.md +972 -0
- package/skills/guide/references/remote-ticket-protocol.md +79 -0
- package/skills/guide/references/setup.md +289 -0
- package/skills/guide/references/spec-standard.md +63 -0
- package/skills/guide/references/ticket-dependencies.md +59 -0
- package/skills/harvest/SKILL.md +66 -0
- package/skills/harvest-review/SKILL.md +63 -0
- package/skills/implement/SKILL.md +82 -0
- package/skills/plan/SKILL.md +123 -0
- package/skills/plan-review/SKILL.md +121 -0
- package/skills/reconcile/SKILL.md +65 -0
- package/skills/spec/SKILL.md +132 -0
- package/skills/tickets/SKILL.md +139 -0
- package/templates/autobuild.toml +36 -0
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
/** Deterministic halves of observation harvest: scan, dedup, validation, and
|
|
2
|
+
* proposal rendering. Agent judgment is deliberately absent from this file. */
|
|
3
|
+
import type { ArtifactRef } from '../ontology'
|
|
4
|
+
import {
|
|
5
|
+
harvestProposalSetSchema,
|
|
6
|
+
harvestScanPacketSchema,
|
|
7
|
+
occurrenceKey,
|
|
8
|
+
type HarvestBlockerProvenance,
|
|
9
|
+
type HarvestDisposition,
|
|
10
|
+
type HarvestLedgerTicket,
|
|
11
|
+
type HarvestObservation,
|
|
12
|
+
type HarvestOriginatingTicket,
|
|
13
|
+
type HarvestPendingProposal,
|
|
14
|
+
type HarvestProposal,
|
|
15
|
+
type HarvestProposalSet,
|
|
16
|
+
type HarvestScanPacket,
|
|
17
|
+
type HarvestTrigger,
|
|
18
|
+
type OccurrenceKey,
|
|
19
|
+
} from '../harvest/schema'
|
|
20
|
+
import {
|
|
21
|
+
claimedOccurrenceKeys,
|
|
22
|
+
proposalArtifactForRound,
|
|
23
|
+
reduceHarvest,
|
|
24
|
+
type HarvestRunState,
|
|
25
|
+
type HarvestState,
|
|
26
|
+
} from '../kernel/harvest'
|
|
27
|
+
import type { TicketSource } from '../ports/types'
|
|
28
|
+
import { specConformance } from '../spec-standard'
|
|
29
|
+
import { contentHash, toBytes, type BuildStore } from '../store/types'
|
|
30
|
+
|
|
31
|
+
export const HARVEST_SCAN_ARTIFACT = 'harvest-scan'
|
|
32
|
+
export const HARVEST_PROPOSALS_ARTIFACT = 'harvest-proposals'
|
|
33
|
+
export const HARVEST_REVIEW_ARTIFACT = 'harvest-review'
|
|
34
|
+
export const HARVEST_REPORT_ARTIFACT = 'harvest-report'
|
|
35
|
+
export const HARVEST_TRANSCRIPT_ARTIFACT = 'harvest-transcript'
|
|
36
|
+
|
|
37
|
+
export interface HarvestMerge {
|
|
38
|
+
build: string
|
|
39
|
+
ts: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface HarvestScanResult {
|
|
43
|
+
observations: HarvestObservation[]
|
|
44
|
+
/** One durable merge fact per build, collected during the existing event-stream reads. */
|
|
45
|
+
merges: HarvestMerge[]
|
|
46
|
+
state: HarvestState
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface HarvestPressure {
|
|
50
|
+
observationCount: number
|
|
51
|
+
drift: number
|
|
52
|
+
trigger?: HarvestTrigger
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Pure two-dimensional Harvest gate. Drift is measured from the oldest
|
|
56
|
+
* unclaimed observation and excludes that observation's own build. */
|
|
57
|
+
export function evaluateHarvestPressure(
|
|
58
|
+
scan: Pick<HarvestScanResult, 'observations' | 'merges'>,
|
|
59
|
+
policy: { harvestThreshold: number; harvestMaxDrift: number },
|
|
60
|
+
): HarvestPressure {
|
|
61
|
+
const observationCount = scan.observations.length
|
|
62
|
+
if (observationCount === 0) return { observationCount, drift: 0 }
|
|
63
|
+
|
|
64
|
+
const oldest = scan.observations.reduce((candidate, observation) =>
|
|
65
|
+
observation.ts < candidate.ts ? observation : candidate,
|
|
66
|
+
)
|
|
67
|
+
const mergedBuilds = new Set<string>()
|
|
68
|
+
for (const merge of scan.merges) {
|
|
69
|
+
if (merge.build === oldest.occurrence.build || merge.ts <= oldest.ts) continue
|
|
70
|
+
mergedBuilds.add(merge.build)
|
|
71
|
+
}
|
|
72
|
+
const drift = mergedBuilds.size
|
|
73
|
+
const countTriggered = observationCount >= policy.harvestThreshold
|
|
74
|
+
const driftTriggered = policy.harvestMaxDrift > 0 && drift >= policy.harvestMaxDrift
|
|
75
|
+
const trigger =
|
|
76
|
+
countTriggered && driftTriggered
|
|
77
|
+
? 'both'
|
|
78
|
+
: countTriggered
|
|
79
|
+
? 'count'
|
|
80
|
+
: driftTriggered
|
|
81
|
+
? 'drift'
|
|
82
|
+
: undefined
|
|
83
|
+
return {
|
|
84
|
+
observationCount,
|
|
85
|
+
drift,
|
|
86
|
+
...(trigger !== undefined ? { trigger } : {}),
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Raw structured `observation.recorded` envelopes across this repository.
|
|
91
|
+
* The pair `{build, seq}` is the occurrence key; payload ids are not assumed
|
|
92
|
+
* globally unique. */
|
|
93
|
+
export async function scanUnclaimedObservations(
|
|
94
|
+
store: BuildStore,
|
|
95
|
+
repo: string,
|
|
96
|
+
): Promise<HarvestScanResult> {
|
|
97
|
+
await store.ensureRepo(repo)
|
|
98
|
+
const harvestEvents = await store.getRepoEvents(repo)
|
|
99
|
+
const state = reduceHarvest(harvestEvents)
|
|
100
|
+
const claimed = claimedOccurrenceKeys(state)
|
|
101
|
+
const observations: HarvestObservation[] = []
|
|
102
|
+
const merges: HarvestMerge[] = []
|
|
103
|
+
|
|
104
|
+
for (const record of await store.listBuilds()) {
|
|
105
|
+
if (record.repo !== repo) continue
|
|
106
|
+
const events = await store.getEvents(record.slug)
|
|
107
|
+
for (const event of events) {
|
|
108
|
+
if (event.type === 'pr.merged') {
|
|
109
|
+
merges.push({ build: record.slug, ts: event.ts })
|
|
110
|
+
continue
|
|
111
|
+
}
|
|
112
|
+
if (event.type !== 'observation.recorded') continue
|
|
113
|
+
const occurrence = { build: record.slug, seq: event.seq }
|
|
114
|
+
if (claimed.has(occurrenceKey(occurrence))) continue
|
|
115
|
+
observations.push({
|
|
116
|
+
occurrence,
|
|
117
|
+
id: event.payload.id,
|
|
118
|
+
kind: event.payload.kind,
|
|
119
|
+
summary: event.payload.summary,
|
|
120
|
+
...(event.payload.files !== undefined ? { files: [...event.payload.files] } : {}),
|
|
121
|
+
...(event.payload.refs !== undefined ? { refs: [...event.payload.refs] } : {}),
|
|
122
|
+
ts: event.ts,
|
|
123
|
+
...(record.ticket !== undefined ? { ticket: structuredClone(record.ticket) } : {}),
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
observations.sort(
|
|
129
|
+
(a, b) =>
|
|
130
|
+
a.occurrence.build.localeCompare(b.occurrence.build) || a.occurrence.seq - b.occurrence.seq,
|
|
131
|
+
)
|
|
132
|
+
return { observations, merges, state }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Distinct previously filed/joined proposal tickets, reconciled through the
|
|
136
|
+
* TicketSource's native lifecycle semantics. Resolved and missing entries stay
|
|
137
|
+
* in the packet as tombstones rather than disappearing and being re-filed. */
|
|
138
|
+
export async function reconcileHarvestLedger(
|
|
139
|
+
state: HarvestState,
|
|
140
|
+
tickets: TicketSource,
|
|
141
|
+
): Promise<HarvestLedgerTicket[]> {
|
|
142
|
+
const byProposal = new Map<
|
|
143
|
+
string,
|
|
144
|
+
{ proposalKey: string; ticket: NonNullable<(typeof state.ledger)[number]['ticket']> }
|
|
145
|
+
>()
|
|
146
|
+
for (const entry of state.ledger) {
|
|
147
|
+
if (entry.proposalKey === undefined || entry.ticket === undefined) continue
|
|
148
|
+
byProposal.set(entry.proposalKey, {
|
|
149
|
+
proposalKey: entry.proposalKey,
|
|
150
|
+
ticket: entry.ticket,
|
|
151
|
+
})
|
|
152
|
+
}
|
|
153
|
+
const entries = [...byProposal.values()].sort((a, b) =>
|
|
154
|
+
a.proposalKey.localeCompare(b.proposalKey),
|
|
155
|
+
)
|
|
156
|
+
if (entries.length === 0) return []
|
|
157
|
+
|
|
158
|
+
const ids = entries.map((entry) => entry.ticket.id)
|
|
159
|
+
const states = await tickets.dependencyStates(ids)
|
|
160
|
+
const out: HarvestLedgerTicket[] = []
|
|
161
|
+
for (let index = 0; index < entries.length; index += 1) {
|
|
162
|
+
const entry = entries[index]!
|
|
163
|
+
const dependency = states[index] ?? {
|
|
164
|
+
id: entry.ticket.id,
|
|
165
|
+
exists: false,
|
|
166
|
+
resolved: false,
|
|
167
|
+
blockedBy: [],
|
|
168
|
+
}
|
|
169
|
+
const current = dependency.exists ? await tickets.get(entry.ticket.id) : null
|
|
170
|
+
out.push({
|
|
171
|
+
proposalKey: entry.proposalKey,
|
|
172
|
+
ticket: structuredClone(entry.ticket),
|
|
173
|
+
exists: dependency.exists,
|
|
174
|
+
resolved: dependency.resolved,
|
|
175
|
+
...(current?.title !== undefined ? { title: current.title } : {}),
|
|
176
|
+
...(current?.body !== undefined ? { body: current.body } : {}),
|
|
177
|
+
})
|
|
178
|
+
}
|
|
179
|
+
return out
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Distinct observation-origin tickets in first-seen order. Lifecycle state is
|
|
183
|
+
* informational at scan time; filing refreshes matching refs before create. */
|
|
184
|
+
export async function reconcileOriginatingTickets(
|
|
185
|
+
observations: HarvestObservation[],
|
|
186
|
+
tickets: TicketSource,
|
|
187
|
+
): Promise<HarvestOriginatingTicket[]> {
|
|
188
|
+
const distinct = new Map<string, NonNullable<HarvestObservation['ticket']>>()
|
|
189
|
+
for (const observation of observations) {
|
|
190
|
+
if (observation.ticket === undefined) continue
|
|
191
|
+
const key = `${observation.ticket.source}:${observation.ticket.id}`
|
|
192
|
+
if (!distinct.has(key)) distinct.set(key, structuredClone(observation.ticket))
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const refs = [...distinct.values()]
|
|
196
|
+
const matchingIds = refs
|
|
197
|
+
.filter((ticket) => ticket.source === tickets.name)
|
|
198
|
+
.map((ticket) => ticket.id)
|
|
199
|
+
const states = matchingIds.length > 0 ? await tickets.dependencyStates(matchingIds) : []
|
|
200
|
+
const byId = new Map(states.map((state) => [state.id, state]))
|
|
201
|
+
|
|
202
|
+
return refs.map((ticket) => {
|
|
203
|
+
const sourceMatches = ticket.source === tickets.name
|
|
204
|
+
const state = sourceMatches ? byId.get(ticket.id) : undefined
|
|
205
|
+
return {
|
|
206
|
+
ticket,
|
|
207
|
+
sourceMatches,
|
|
208
|
+
exists: state?.exists ?? false,
|
|
209
|
+
resolved: state?.resolved ?? false,
|
|
210
|
+
}
|
|
211
|
+
})
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export async function makeHarvestScanPacket(opts: {
|
|
215
|
+
store: BuildStore
|
|
216
|
+
tickets: TicketSource
|
|
217
|
+
repo: string
|
|
218
|
+
run: string
|
|
219
|
+
observations: HarvestObservation[]
|
|
220
|
+
state: HarvestState
|
|
221
|
+
}): Promise<HarvestScanPacket> {
|
|
222
|
+
return harvestScanPacketSchema.parse({
|
|
223
|
+
repo: opts.repo,
|
|
224
|
+
run: opts.run,
|
|
225
|
+
observations: opts.observations,
|
|
226
|
+
originatingTickets: await reconcileOriginatingTickets(opts.observations, opts.tickets),
|
|
227
|
+
ledger: await reconcileHarvestLedger(opts.state, opts.tickets),
|
|
228
|
+
})
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function artifactRef(meta: { kind: string; revision: number }): ArtifactRef {
|
|
232
|
+
return { kind: meta.kind, rev: meta.revision }
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export interface CoverageResult {
|
|
236
|
+
ok: boolean
|
|
237
|
+
errors: string[]
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Every claimed observation must occur exactly once across create/join/
|
|
241
|
+
* suppress outcomes; no unclaimed key may be smuggled into an artifact. */
|
|
242
|
+
export function validateProposalCoverage(
|
|
243
|
+
set: HarvestProposalSet,
|
|
244
|
+
claimed: OccurrenceKey[],
|
|
245
|
+
): CoverageResult {
|
|
246
|
+
const expected = new Set(claimed.map(occurrenceKey))
|
|
247
|
+
const counts = new Map<string, number>()
|
|
248
|
+
for (const proposal of set.proposals) {
|
|
249
|
+
for (const occurrence of proposal.observations) {
|
|
250
|
+
const key = occurrenceKey(occurrence)
|
|
251
|
+
counts.set(key, (counts.get(key) ?? 0) + 1)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
const errors: string[] = []
|
|
255
|
+
for (const key of expected) {
|
|
256
|
+
const count = counts.get(key) ?? 0
|
|
257
|
+
if (count === 0) errors.push(`claimed observation ${key} is not covered`)
|
|
258
|
+
else if (count > 1) errors.push(`claimed observation ${key} is covered ${count} times`)
|
|
259
|
+
}
|
|
260
|
+
for (const key of counts.keys()) {
|
|
261
|
+
if (!expected.has(key)) errors.push(`proposal covers unclaimed observation ${key}`)
|
|
262
|
+
}
|
|
263
|
+
return { ok: errors.length === 0, errors }
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Parse an approved proposal artifact and re-assert its partition at the
|
|
267
|
+
* deterministic filing boundary. */
|
|
268
|
+
export function parseApprovedProposalSet(
|
|
269
|
+
raw: string,
|
|
270
|
+
claimed: OccurrenceKey[],
|
|
271
|
+
): HarvestProposalSet {
|
|
272
|
+
let json: unknown
|
|
273
|
+
try {
|
|
274
|
+
json = JSON.parse(raw)
|
|
275
|
+
} catch (error) {
|
|
276
|
+
throw new Error(
|
|
277
|
+
`approved harvest proposal artifact is not JSON: ${
|
|
278
|
+
error instanceof Error ? error.message : String(error)
|
|
279
|
+
}`,
|
|
280
|
+
)
|
|
281
|
+
}
|
|
282
|
+
const parsed = harvestProposalSetSchema.safeParse(json)
|
|
283
|
+
if (!parsed.success) {
|
|
284
|
+
throw new Error(
|
|
285
|
+
`approved harvest proposal artifact does not match the schema: ${parsed.error.message}`,
|
|
286
|
+
)
|
|
287
|
+
}
|
|
288
|
+
const coverage = validateProposalCoverage(parsed.data, claimed)
|
|
289
|
+
if (!coverage.ok) {
|
|
290
|
+
throw new Error(`approved harvest proposals are not a partition: ${coverage.errors.join('; ')}`)
|
|
291
|
+
}
|
|
292
|
+
return parsed.data
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Stable proposal identity: semantic cluster membership, independent of prose
|
|
296
|
+
* edits, review rounds, and the separately reserved external-create UUID. */
|
|
297
|
+
export function harvestProposalKey(proposal: HarvestProposal): string {
|
|
298
|
+
const members = proposal.observations.map(occurrenceKey).sort().join('\n')
|
|
299
|
+
return `harvest-${contentHash(toBytes(members)).slice(0, 24)}`
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Resolve the authoritative blocker union immediately before create. Agent
|
|
303
|
+
* declarations retain strict validation; observation origins are best-effort
|
|
304
|
+
* prerequisites and contribute only while they still exist and are unresolved. */
|
|
305
|
+
export interface HarvestCreateBlockers {
|
|
306
|
+
blockedBy: string[]
|
|
307
|
+
provenance: HarvestBlockerProvenance
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export async function resolveHarvestCreateBlockers(
|
|
311
|
+
proposal: Extract<HarvestProposal, { action: 'create' }>,
|
|
312
|
+
observations: HarvestObservation[],
|
|
313
|
+
tickets: TicketSource,
|
|
314
|
+
): Promise<HarvestCreateBlockers> {
|
|
315
|
+
const declared = [...new Set(proposal.blockedBy ?? [])]
|
|
316
|
+
const byOccurrence = new Map(
|
|
317
|
+
observations.map((observation) => [occurrenceKey(observation.occurrence), observation]),
|
|
318
|
+
)
|
|
319
|
+
const originIds: string[] = []
|
|
320
|
+
const seenOrigins = new Set<string>()
|
|
321
|
+
for (const occurrence of proposal.observations) {
|
|
322
|
+
const observation = byOccurrence.get(occurrenceKey(occurrence))
|
|
323
|
+
if (observation === undefined) {
|
|
324
|
+
throw new Error(
|
|
325
|
+
`cannot resolve blockers for harvest proposal "${proposal.title}": ` +
|
|
326
|
+
`missing scan observation ${occurrenceKey(occurrence)}`,
|
|
327
|
+
)
|
|
328
|
+
}
|
|
329
|
+
const origin = observation.ticket
|
|
330
|
+
if (origin === undefined || origin.source !== tickets.name || seenOrigins.has(origin.id)) {
|
|
331
|
+
continue
|
|
332
|
+
}
|
|
333
|
+
seenOrigins.add(origin.id)
|
|
334
|
+
originIds.push(origin.id)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const candidates = [...new Set([...declared, ...originIds])]
|
|
338
|
+
if (candidates.length === 0) {
|
|
339
|
+
return { blockedBy: [], provenance: { declared, derived: [] } }
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
let states: Awaited<ReturnType<TicketSource['dependencyStates']>>
|
|
343
|
+
try {
|
|
344
|
+
states = await tickets.dependencyStates(candidates)
|
|
345
|
+
} catch (error) {
|
|
346
|
+
throw new Error(
|
|
347
|
+
`cannot file harvest proposal "${proposal.title}" through ticket source ` +
|
|
348
|
+
`"${tickets.name}": blocker validation failed for ${candidates.map((id) => `"${id}"`).join(', ')}: ` +
|
|
349
|
+
`${error instanceof Error ? error.message : String(error)}`,
|
|
350
|
+
{ cause: error },
|
|
351
|
+
)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const byId = new Map(states.map((state) => [state.id, state]))
|
|
355
|
+
const invalid = declared.filter((id) => byId.get(id)?.exists !== true)
|
|
356
|
+
if (invalid.length > 0) {
|
|
357
|
+
throw new Error(
|
|
358
|
+
`cannot file harvest proposal "${proposal.title}" through ticket source ` +
|
|
359
|
+
`"${tickets.name}": unknown or invalid blocker ${invalid.map((id) => `"${id}"`).join(', ')}`,
|
|
360
|
+
)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const derived = originIds.filter((id) => {
|
|
364
|
+
const state = byId.get(id)
|
|
365
|
+
return state?.exists === true && !state.resolved
|
|
366
|
+
})
|
|
367
|
+
return {
|
|
368
|
+
blockedBy: [...new Set([...declared, ...derived])],
|
|
369
|
+
provenance: { declared, derived },
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export interface HarvestExhaustionPartition {
|
|
374
|
+
releasedObservations: OccurrenceKey[]
|
|
375
|
+
committedDispositions: HarvestDisposition[]
|
|
376
|
+
pendingProposals: HarvestPendingProposal[]
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Determine the only safe give-up partition from frozen repository facts.
|
|
380
|
+
* No provider is queried and no external side effect occurs here: filed facts,
|
|
381
|
+
* the approved proposal artifact, and its original scan packet are the entire
|
|
382
|
+
* authority.
|
|
383
|
+
*
|
|
384
|
+
* Store read failures propagate so a transient outage can be retried. Once a
|
|
385
|
+
* read succeeds, malformed/missing artifacts and unclassifiable proposal
|
|
386
|
+
* members fail safe as pending work instead of making exhaustion itself a hot
|
|
387
|
+
* loop. */
|
|
388
|
+
export async function partitionHarvestExhaustion(opts: {
|
|
389
|
+
store: BuildStore
|
|
390
|
+
repo: string
|
|
391
|
+
run: HarvestRunState
|
|
392
|
+
}): Promise<HarvestExhaustionPartition> {
|
|
393
|
+
const { store, repo, run } = opts
|
|
394
|
+
const releaseWholeSnapshot = (): HarvestExhaustionPartition => ({
|
|
395
|
+
releasedObservations: structuredClone(run.observations),
|
|
396
|
+
committedDispositions: [],
|
|
397
|
+
pendingProposals: [],
|
|
398
|
+
})
|
|
399
|
+
const approval = [...run.reviews].reverse().find((review) => review.verdict === 'approve')
|
|
400
|
+
if (approval === undefined) return releaseWholeSnapshot()
|
|
401
|
+
|
|
402
|
+
const approved = proposalArtifactForRound(run, approval.round)
|
|
403
|
+
if (approved === undefined) return releaseWholeSnapshot()
|
|
404
|
+
// A rejected read is transient and must remain retryable. A successful
|
|
405
|
+
// missing result is durable corruption, so release rather than relaunching
|
|
406
|
+
// this same rejecting exhaustion settlement forever.
|
|
407
|
+
const artifact = await store.getRepoArtifact(repo, approved.kind, approved.rev)
|
|
408
|
+
if (artifact === null) return releaseWholeSnapshot()
|
|
409
|
+
|
|
410
|
+
let set: HarvestProposalSet
|
|
411
|
+
try {
|
|
412
|
+
set = parseApprovedProposalSet(new TextDecoder().decode(artifact.content), run.observations)
|
|
413
|
+
} catch {
|
|
414
|
+
return releaseWholeSnapshot()
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// The scan packet is needed only to prove a join is still a valid frozen
|
|
418
|
+
// disposition. As above, transport/read errors propagate; missing,
|
|
419
|
+
// malformed, or mismatched content simply makes joins pending. Creates with
|
|
420
|
+
// durable filing facts and suppressions remain independently classifiable.
|
|
421
|
+
const scanArtifact = await store.getRepoArtifact(repo, run.scan.kind, run.scan.rev)
|
|
422
|
+
let packet: HarvestScanPacket | undefined
|
|
423
|
+
if (scanArtifact !== null) {
|
|
424
|
+
let raw: unknown
|
|
425
|
+
try {
|
|
426
|
+
raw = JSON.parse(new TextDecoder().decode(scanArtifact.content))
|
|
427
|
+
} catch {
|
|
428
|
+
raw = undefined
|
|
429
|
+
}
|
|
430
|
+
const parsed = harvestScanPacketSchema.safeParse(raw)
|
|
431
|
+
if (parsed.success && parsed.data.run === run.run) {
|
|
432
|
+
const packetOccurrences = new Set(
|
|
433
|
+
parsed.data.observations.map((item) => occurrenceKey(item.occurrence)),
|
|
434
|
+
)
|
|
435
|
+
if (
|
|
436
|
+
packetOccurrences.size === run.observations.length &&
|
|
437
|
+
run.observations.every((item) => packetOccurrences.has(occurrenceKey(item)))
|
|
438
|
+
) {
|
|
439
|
+
packet = parsed.data
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const knownLedger = new Map(
|
|
445
|
+
(packet?.ledger ?? []).map((entry) => [`${entry.ticket.source}:${entry.ticket.id}`, entry]),
|
|
446
|
+
)
|
|
447
|
+
const filed = new Map(run.filed.map((entry) => [entry.proposalKey, entry.ticket]))
|
|
448
|
+
const proposalKeys = new Set<string>()
|
|
449
|
+
for (const proposal of set.proposals) {
|
|
450
|
+
const proposalKey = harvestProposalKey(proposal)
|
|
451
|
+
if (proposalKeys.has(proposalKey)) return releaseWholeSnapshot()
|
|
452
|
+
proposalKeys.add(proposalKey)
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const releasedObservations: OccurrenceKey[] = []
|
|
456
|
+
const committedDispositions: HarvestDisposition[] = []
|
|
457
|
+
const pendingProposals: HarvestPendingProposal[] = []
|
|
458
|
+
const releaseProposal = (proposal: HarvestProposal): void => {
|
|
459
|
+
const proposalKey = harvestProposalKey(proposal)
|
|
460
|
+
releasedObservations.push(...structuredClone(proposal.observations))
|
|
461
|
+
pendingProposals.push({
|
|
462
|
+
proposalKey,
|
|
463
|
+
action: proposal.action,
|
|
464
|
+
observations: structuredClone(proposal.observations),
|
|
465
|
+
})
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
for (const proposal of set.proposals) {
|
|
469
|
+
if (proposal.action === 'create') {
|
|
470
|
+
const proposalKey = harvestProposalKey(proposal)
|
|
471
|
+
const ticket = filed.get(proposalKey)
|
|
472
|
+
if (ticket === undefined) {
|
|
473
|
+
releaseProposal(proposal)
|
|
474
|
+
} else {
|
|
475
|
+
for (const occurrence of proposal.observations) {
|
|
476
|
+
committedDispositions.push({
|
|
477
|
+
occurrence: { ...occurrence },
|
|
478
|
+
action: 'filed',
|
|
479
|
+
proposalKey,
|
|
480
|
+
ticket: structuredClone(ticket),
|
|
481
|
+
})
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
continue
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
if (proposal.action === 'join') {
|
|
488
|
+
const known = knownLedger.get(`${proposal.ticket.source}:${proposal.ticket.id}`)
|
|
489
|
+
if (known === undefined || !known.exists || known.resolved) {
|
|
490
|
+
releaseProposal(proposal)
|
|
491
|
+
continue
|
|
492
|
+
}
|
|
493
|
+
for (const occurrence of proposal.observations) {
|
|
494
|
+
committedDispositions.push({
|
|
495
|
+
occurrence: { ...occurrence },
|
|
496
|
+
action: 'joined',
|
|
497
|
+
proposalKey: known.proposalKey,
|
|
498
|
+
ticket: structuredClone(known.ticket),
|
|
499
|
+
reason: proposal.reason,
|
|
500
|
+
})
|
|
501
|
+
}
|
|
502
|
+
continue
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const proposalKey = harvestProposalKey(proposal)
|
|
506
|
+
for (const occurrence of proposal.observations) {
|
|
507
|
+
committedDispositions.push({
|
|
508
|
+
occurrence: { ...occurrence },
|
|
509
|
+
action: 'suppressed',
|
|
510
|
+
proposalKey,
|
|
511
|
+
reason: proposal.reason,
|
|
512
|
+
})
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Coverage was revalidated above, so this is a defensive fail-safe against a
|
|
517
|
+
// future classifier bug rather than a reachable content error.
|
|
518
|
+
const expected = new Set(run.observations.map(occurrenceKey))
|
|
519
|
+
const seen = new Set<string>()
|
|
520
|
+
for (const occurrence of [
|
|
521
|
+
...committedDispositions.map((item) => item.occurrence),
|
|
522
|
+
...releasedObservations,
|
|
523
|
+
]) {
|
|
524
|
+
const key = occurrenceKey(occurrence)
|
|
525
|
+
if (!expected.has(key) || seen.has(key)) return releaseWholeSnapshot()
|
|
526
|
+
seen.add(key)
|
|
527
|
+
}
|
|
528
|
+
if (seen.size !== expected.size) return releaseWholeSnapshot()
|
|
529
|
+
|
|
530
|
+
return {
|
|
531
|
+
releasedObservations,
|
|
532
|
+
committedDispositions,
|
|
533
|
+
pendingProposals,
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
export function renderHarvestProposal(
|
|
538
|
+
proposal: Extract<HarvestProposal, { action: 'create' }>,
|
|
539
|
+
observations: HarvestObservation[],
|
|
540
|
+
): string {
|
|
541
|
+
const byKey = new Map(
|
|
542
|
+
observations.map((observation) => [occurrenceKey(observation.occurrence), observation]),
|
|
543
|
+
)
|
|
544
|
+
const evidence = proposal.observations.map((key) => {
|
|
545
|
+
const observation = byKey.get(occurrenceKey(key))
|
|
546
|
+
if (!observation) {
|
|
547
|
+
throw new Error(
|
|
548
|
+
`cannot render proposal "${proposal.title}": missing claimed observation ${occurrenceKey(key)}`,
|
|
549
|
+
)
|
|
550
|
+
}
|
|
551
|
+
return (
|
|
552
|
+
`- build \`${key.build}\`, event seq ${key.seq}, observation ` +
|
|
553
|
+
`\`${observation.id}\`: ${observation.summary}`
|
|
554
|
+
)
|
|
555
|
+
})
|
|
556
|
+
const body = [
|
|
557
|
+
`# ${proposal.title}`,
|
|
558
|
+
'',
|
|
559
|
+
'## What and why',
|
|
560
|
+
'',
|
|
561
|
+
proposal.whatWhy,
|
|
562
|
+
'',
|
|
563
|
+
'## Acceptance criteria',
|
|
564
|
+
'',
|
|
565
|
+
...proposal.acceptanceCriteria.map((criterion) => `- ${criterion}`),
|
|
566
|
+
'',
|
|
567
|
+
'## Out of scope',
|
|
568
|
+
'',
|
|
569
|
+
...proposal.outOfScope.map((item) => `- ${item}`),
|
|
570
|
+
'',
|
|
571
|
+
'## Evidence',
|
|
572
|
+
'',
|
|
573
|
+
...evidence,
|
|
574
|
+
'',
|
|
575
|
+
].join('\n')
|
|
576
|
+
const conformance = specConformance(body)
|
|
577
|
+
if (!conformance.conforms) {
|
|
578
|
+
throw new Error(
|
|
579
|
+
`rendered harvest proposal "${proposal.title}" does not conform to the spec standard: ${conformance.missing.join('; ')}`,
|
|
580
|
+
)
|
|
581
|
+
}
|
|
582
|
+
return body
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
export async function loadScanPacket(
|
|
586
|
+
store: BuildStore,
|
|
587
|
+
repo: string,
|
|
588
|
+
ref: ArtifactRef,
|
|
589
|
+
): Promise<HarvestScanPacket> {
|
|
590
|
+
const artifact = await store.getRepoArtifact(repo, ref.kind, ref.rev)
|
|
591
|
+
if (!artifact) {
|
|
592
|
+
throw new Error(`missing harvest scan artifact ${ref.kind}@${ref.rev}`)
|
|
593
|
+
}
|
|
594
|
+
return harvestScanPacketSchema.parse(JSON.parse(new TextDecoder().decode(artifact.content)))
|
|
595
|
+
}
|