@defrex/autobuild 0.13.0 → 0.14.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 +19 -1
- package/bin/ab-dispatch-kernel.ts +4 -6
- package/docs/architecture.md +96 -70
- package/docs/hosted-service.md +2 -2
- package/docs/operator-api.md +1 -1
- package/docs/remote-store-protocol.md +84 -2
- package/package.json +4 -3
- package/packages/core/src/cli/attention.ts +18 -164
- package/packages/core/src/cli/build-control.ts +11 -15
- package/packages/core/src/cli/bulk-control.ts +3 -2
- package/packages/core/src/cli/context.ts +16 -78
- package/packages/core/src/cli/dashboard/model.ts +36 -22
- package/packages/core/src/cli/dispatch-frontend.ts +24 -67
- package/packages/core/src/cli/dispatch-process.ts +14 -7
- package/packages/core/src/cli/dispatch-repository-feed.ts +196 -0
- package/packages/core/src/cli/dispatch.ts +148 -79
- package/packages/core/src/cli/dispatcher-liveness.ts +26 -19
- package/packages/core/src/cli/harvest.ts +71 -39
- package/packages/core/src/cli/historical-build-state.ts +27 -0
- package/packages/core/src/cli/main.ts +3 -3
- package/packages/core/src/cli/pr-attachments.ts +10 -13
- package/packages/core/src/cli/pr-summary-publication.ts +27 -60
- package/packages/core/src/cli/pr-summary.ts +43 -13
- package/packages/core/src/cli/publication-settlement.ts +10 -20
- package/packages/core/src/cli/repository-status.ts +38 -2
- package/packages/core/src/cli/status.ts +62 -32
- package/packages/core/src/cli/terminals.ts +25 -142
- package/packages/core/src/cli/ticket.ts +2 -2
- package/packages/core/src/cli/wait.ts +91 -84
- package/packages/core/src/cli/watch.ts +60 -97
- package/packages/core/src/config/pipeline-source.ts +4 -3
- package/packages/core/src/events/repository.ts +7 -3
- package/packages/core/src/integration/harness.ts +1 -2
- package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +2429 -9
- package/packages/core/src/kernel/auto-merge.ts +19 -3
- package/packages/core/src/kernel/dispatcher-liveness.ts +10 -4
- package/packages/core/src/kernel/generators/repository-journals.ts +14 -3
- package/packages/core/src/kernel/harvest.ts +25 -23
- package/packages/core/src/kernel/log-index-value.ts +38 -0
- package/packages/core/src/kernel/read-facts.ts +637 -0
- package/packages/core/src/kernel/reducer-registry.ts +137 -3
- package/packages/core/src/kernel/repository-attention.ts +179 -0
- package/packages/core/src/kernel/service-facts.ts +135 -0
- package/packages/core/src/kernel/terminal-guard.ts +133 -0
- package/packages/core/src/operator/control.ts +5 -6
- package/packages/core/src/operator/index.ts +2 -0
- package/packages/core/src/operator/query.ts +37 -20
- package/packages/core/src/operator/read-view.ts +4 -13
- package/packages/core/src/operator/requests.ts +2 -14
- package/packages/core/src/operator/sandbox.ts +10 -9
- package/packages/core/src/operator/tickets.ts +2 -2
- package/packages/core/src/plugin-sdk/index.ts +13 -0
- package/packages/core/src/processes/build-child.ts +6 -6
- package/packages/core/src/processes/build-runner.ts +62 -70
- package/packages/core/src/processes/dispatch-failure-budget.ts +5 -4
- package/packages/core/src/processes/dispatcher.ts +113 -173
- package/packages/core/src/processes/harvest-execution-state.ts +15 -9
- package/packages/core/src/processes/harvest-runner.ts +34 -70
- package/packages/core/src/processes/harvest.ts +38 -15
- package/packages/core/src/processes/infrastructure-failure-budget.ts +13 -15
- package/packages/core/src/processes/orchestrator-tick.ts +2 -2
- package/packages/core/src/processes/publication-loss.ts +35 -21
- package/packages/core/src/processes/repo-view.ts +229 -1134
- package/packages/core/src/processes/tick-publication.ts +110 -0
- package/packages/core/src/store/adapter.ts +23 -0
- package/packages/core/src/store/build-scope.ts +17 -0
- package/packages/core/src/store/contract.ts +251 -0
- package/packages/core/src/store/local/schema.ts +1 -0
- package/packages/core/src/store/local/store.ts +179 -54
- package/packages/core/src/store/memory.ts +119 -29
- package/packages/core/src/store/phase-session.ts +17 -0
- package/packages/core/src/store/reduced-reads.ts +239 -0
- package/packages/core/src/store/reducer-value-shapes.json +14056 -0
- package/packages/core/src/store/reducer-value-validation.ts +96 -0
- package/packages/core/src/store/reducer-values.ts +145 -0
- package/packages/core/src/store/remote/client.ts +41 -0
- package/packages/core/src/store/remote/compatibility.ts +2 -0
- package/packages/core/src/store/remote/index.ts +19 -0
- package/packages/core/src/store/remote/protocol.ts +16 -0
- package/packages/core/src/store/remote/version.ts +3 -1
- package/packages/core/src/store/repo-state-events.ts +1 -1
- package/packages/core/src/store/repo-tick.ts +72 -0
- package/packages/core/src/store/session-handle.ts +19 -0
- package/packages/core/src/store/snapshots.ts +10 -0
- package/packages/core/src/store/ticket-assets.ts +4 -4
- package/packages/core/src/store/types.ts +39 -2
- package/packages/core/src/testing/dispatch-read-budget.ts +215 -0
- package/packages/core/src/testing/index.ts +8 -0
- package/packages/core/src/testing/reduced-read-budget.ts +462 -0
- package/skills/guide/SKILL.md +6 -1
- package/skills/guide/references/operator-api.md +1 -1
- package/skills/guide/references/remote-store-protocol.md +84 -2
- package/templates/hosted-service/package.json +2 -2
|
@@ -1,14 +1,8 @@
|
|
|
1
|
+
import { readBuildFacts } from '../store/reduced-reads'
|
|
1
2
|
import type { IdSource } from '../ids'
|
|
2
3
|
import { DISPATCHER, KERNEL } from '../events/envelope'
|
|
3
4
|
import type { Forge, WorkspacePublication } from '../ports/types'
|
|
4
5
|
import type { Exec } from '../ports/workspace/git-worktree'
|
|
5
|
-
import { openExecution } from '../processes/execution-settlement'
|
|
6
|
-
import {
|
|
7
|
-
latestUncompletedPublicationRequest,
|
|
8
|
-
publicationLostRecorded,
|
|
9
|
-
publicationRequestCompleted,
|
|
10
|
-
publicationRequestSettled,
|
|
11
|
-
} from '../processes/publication-state'
|
|
12
6
|
import { systemClock, type BuildStore, type Clock } from '../store/types'
|
|
13
7
|
import { completeFinalizePr } from './terminals'
|
|
14
8
|
|
|
@@ -41,11 +35,11 @@ export async function settlePendingPublication(
|
|
|
41
35
|
): Promise<void> {
|
|
42
36
|
const publication = deps.publication
|
|
43
37
|
if (publication === undefined) return
|
|
44
|
-
let events = await deps.store
|
|
45
|
-
const request =
|
|
38
|
+
let events = (await readBuildFacts(deps.store, slug)).value
|
|
39
|
+
const request = events.publication.latestUncompletedRequest()
|
|
46
40
|
if (request === undefined) return
|
|
47
41
|
|
|
48
|
-
const abandoned =
|
|
42
|
+
const abandoned = events.publication.requestSettled(request)
|
|
49
43
|
// Consulted for every uncompleted request, not only abandoned ones: a guest
|
|
50
44
|
// that pushed its branch before dying is owed its completion fact, not a
|
|
51
45
|
// re-push or a loss record.
|
|
@@ -59,7 +53,7 @@ export async function settlePendingPublication(
|
|
|
59
53
|
// returning silently — the exact drop that lost the validate-hosted-
|
|
60
54
|
// dispatcher generation-1 work. The finalize-step publish-failure path
|
|
61
55
|
// below is unchanged: it already settles loudly via its failed outcome.
|
|
62
|
-
if (!
|
|
56
|
+
if (!events.publication.lostRecorded(request)) {
|
|
63
57
|
await deps.store.append(slug, {
|
|
64
58
|
actor: DISPATCHER,
|
|
65
59
|
type: 'publication.lost',
|
|
@@ -85,12 +79,8 @@ export async function settlePendingPublication(
|
|
|
85
79
|
const leaseLive =
|
|
86
80
|
record?.lease !== undefined &&
|
|
87
81
|
new Date(record.lease.expiresAt).getTime() > clock().getTime()
|
|
88
|
-
if (
|
|
89
|
-
|
|
90
|
-
for (const event of events) {
|
|
91
|
-
if (event.type === 'workspace.provisioned') ref = event.payload.ref
|
|
92
|
-
else if (event.type === 'workspace.released') ref = undefined
|
|
93
|
-
}
|
|
82
|
+
if (events.open !== null && leaseLive) return
|
|
83
|
+
const ref = events.workspace?.ref
|
|
94
84
|
if (ref === undefined)
|
|
95
85
|
throw new Error(`build ${slug} has a publication request but no open workspace`)
|
|
96
86
|
await publication.publish({ ref, sha: request.payload.sha, branch: request.payload.branch })
|
|
@@ -115,8 +105,8 @@ export async function settlePendingPublication(
|
|
|
115
105
|
})
|
|
116
106
|
return
|
|
117
107
|
}
|
|
118
|
-
events = await deps.store
|
|
119
|
-
if (
|
|
108
|
+
events = (await readBuildFacts(deps.store, slug)).value
|
|
109
|
+
if (events.publication.requestCompleted(request)) return
|
|
120
110
|
|
|
121
111
|
if (request.payload.operation === 'implement') {
|
|
122
112
|
await deps.store.append(slug, {
|
|
@@ -157,7 +147,7 @@ export async function settlePendingPublication(
|
|
|
157
147
|
const newline = text.indexOf('\n')
|
|
158
148
|
const title = (newline === -1 ? text : text.slice(0, newline)).replace(/^#+\s*/, '').trim()
|
|
159
149
|
if (title === '') throw new Error(`empty PR title for ${slug}`)
|
|
160
|
-
const created = events.
|
|
150
|
+
const created = events.lastCreated
|
|
161
151
|
if (created?.type !== 'build.created') throw new Error(`missing build.created for ${slug}`)
|
|
162
152
|
|
|
163
153
|
await completeFinalizePr(
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readRepositoryFacts, type RepositoryReadFacts } from '../store/reduced-reads'
|
|
1
2
|
import {
|
|
2
3
|
formatDispatcherIdentity,
|
|
3
4
|
projectDispatcherLiveness,
|
|
@@ -99,6 +100,37 @@ export function projectRepositoryStatus(
|
|
|
99
100
|
}
|
|
100
101
|
}
|
|
101
102
|
|
|
103
|
+
export function repositoryStatusFromFacts(
|
|
104
|
+
repo: string,
|
|
105
|
+
facts: RepositoryReadFacts,
|
|
106
|
+
dispatcher: DispatcherLiveness,
|
|
107
|
+
): RepositoryStatus {
|
|
108
|
+
return {
|
|
109
|
+
repo,
|
|
110
|
+
dispatcher,
|
|
111
|
+
...facts.settings,
|
|
112
|
+
...(facts.health === undefined ? {} : { ticketSourceDegradation: facts.health }),
|
|
113
|
+
sandboxes: facts.sandboxes
|
|
114
|
+
.filter((state) => state.state !== 'released')
|
|
115
|
+
.map((state) => ({
|
|
116
|
+
operator: state.operator,
|
|
117
|
+
environmentId: state.environmentId,
|
|
118
|
+
provider: state.provider,
|
|
119
|
+
state: state.state === 'stopped' ? ('stopped' as const) : ('live' as const),
|
|
120
|
+
lastEvidenceAt: state.lastEvidenceTs,
|
|
121
|
+
})),
|
|
122
|
+
publications: [...facts.publications].reverse().map((event) => ({
|
|
123
|
+
operator: event.payload.operator,
|
|
124
|
+
branch: event.payload.branch,
|
|
125
|
+
sha: event.payload.sha,
|
|
126
|
+
prNumber: event.payload.pr.number,
|
|
127
|
+
prUrl: event.payload.pr.url,
|
|
128
|
+
...(event.payload.session !== undefined ? { session: event.payload.session } : {}),
|
|
129
|
+
at: event.ts,
|
|
130
|
+
})),
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
102
134
|
export function renderRepositoryStatus(status: RepositoryStatus): string[] {
|
|
103
135
|
const setting = (enabled: boolean): string => (enabled ? 'ON' : 'OFF')
|
|
104
136
|
const active = status.ticketSourceDegradation
|
|
@@ -147,8 +179,12 @@ export async function abRepositoryStatus(opts: RepositoryStatusOpts): Promise<vo
|
|
|
147
179
|
await withSessionlessStore(opts, async ({ store, repo }) => {
|
|
148
180
|
// A fresh store has no repository row. Do not call ensureRepo: this query's
|
|
149
181
|
// empty-stream defaults must remain a genuinely read-only operation.
|
|
150
|
-
const {
|
|
151
|
-
const status =
|
|
182
|
+
const { dispatcher } = await readDispatcherLiveness(store, repo, opts.now?.())
|
|
183
|
+
const status = repositoryStatusFromFacts(
|
|
184
|
+
repo,
|
|
185
|
+
(await readRepositoryFacts(store, repo)).value,
|
|
186
|
+
dispatcher,
|
|
187
|
+
)
|
|
152
188
|
if (opts.json === true) {
|
|
153
189
|
opts.stdout(JSON.stringify(status, null, 2))
|
|
154
190
|
return
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { readBuildDigests } from '../store/reduced-reads'
|
|
2
|
+
import { readBuildFacts } from '../store/reduced-reads'
|
|
3
|
+
import type { BuildFacts } from '../processes/repo-view'
|
|
1
4
|
/**
|
|
2
5
|
* `ab builds` and `ab build status <slug>` — the read-only query surface on
|
|
3
6
|
* build state (SPEC §8.2, §15.5). Their invocation forms require no session,
|
|
@@ -44,9 +47,9 @@ import {
|
|
|
44
47
|
} from '../processes/build-execution-state'
|
|
45
48
|
import type { PipelineSourceMeta } from '../config/pipeline-source'
|
|
46
49
|
import { openExecution } from '../processes/execution-settlement'
|
|
47
|
-
import {
|
|
50
|
+
import { currentAutoMergeDeferralFrom } from '../kernel/auto-merge'
|
|
48
51
|
import { autoMergeDisplay, type AutoMergeDisplay } from './dashboard/model'
|
|
49
|
-
import {
|
|
52
|
+
import { decideNextFromFacts } from '../kernel/engine'
|
|
50
53
|
import {
|
|
51
54
|
reduceBuild,
|
|
52
55
|
type BuildState,
|
|
@@ -271,7 +274,31 @@ export function detail(
|
|
|
271
274
|
decision?: BuildDecisionProjection,
|
|
272
275
|
streams?: readonly StreamRecord[],
|
|
273
276
|
): BuildDetail {
|
|
274
|
-
|
|
277
|
+
return detailFromFacts(
|
|
278
|
+
record,
|
|
279
|
+
{
|
|
280
|
+
state: reduceBuild(events),
|
|
281
|
+
observations: events
|
|
282
|
+
.filter((event) => event.type === 'observation.recorded')
|
|
283
|
+
.map((event) => event.payload),
|
|
284
|
+
open: openExecution(events),
|
|
285
|
+
},
|
|
286
|
+
now,
|
|
287
|
+
eventCount === undefined ? undefined : events.slice(-eventCount),
|
|
288
|
+
decision,
|
|
289
|
+
streams,
|
|
290
|
+
)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function detailFromFacts(
|
|
294
|
+
record: BuildRecord,
|
|
295
|
+
facts: Pick<BuildFacts, 'state' | 'open'> & { observations: BuildState['observations'] },
|
|
296
|
+
now: Date,
|
|
297
|
+
events?: AbEvent[],
|
|
298
|
+
decision?: BuildDecisionProjection,
|
|
299
|
+
streams?: readonly StreamRecord[],
|
|
300
|
+
): BuildDetail {
|
|
301
|
+
const state = facts.state
|
|
275
302
|
const streamStatus = new Map<string, { stream?: string; status: 'open' | 'closed' }>()
|
|
276
303
|
if (streams !== undefined) {
|
|
277
304
|
for (const record of streams) {
|
|
@@ -316,15 +343,13 @@ export function detail(
|
|
|
316
343
|
}),
|
|
317
344
|
// Observations are historical facts rather than a derived current-state
|
|
318
345
|
// opinion. Preserve event order and exact optional evidence fields.
|
|
319
|
-
observations:
|
|
320
|
-
|
|
321
|
-
.
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
...(event.payload.refs !== undefined ? { refs: event.payload.refs } : {}),
|
|
327
|
-
})),
|
|
346
|
+
observations: facts.observations.map((observation) => ({
|
|
347
|
+
id: observation.id,
|
|
348
|
+
kind: observation.kind,
|
|
349
|
+
summary: observation.summary,
|
|
350
|
+
...(observation.files !== undefined ? { files: observation.files } : {}),
|
|
351
|
+
...(observation.refs !== undefined ? { refs: observation.refs } : {}),
|
|
352
|
+
})),
|
|
328
353
|
...(state.setupFailure !== undefined
|
|
329
354
|
? {
|
|
330
355
|
setupFailure: {
|
|
@@ -338,7 +363,7 @@ export function detail(
|
|
|
338
363
|
...(state.executions.at(-1) !== undefined ? { execution: state.executions.at(-1)! } : {}),
|
|
339
364
|
// Durable supervision: a recorded execution with no following end is the
|
|
340
365
|
// source of truth for whether a guest may still be running.
|
|
341
|
-
executionOpen:
|
|
366
|
+
executionOpen: facts.open !== null,
|
|
342
367
|
...(state.infrastructureFailure !== undefined
|
|
343
368
|
? { infrastructureFailure: state.infrastructureFailure }
|
|
344
369
|
: {}),
|
|
@@ -364,7 +389,7 @@ export function detail(
|
|
|
364
389
|
: {}),
|
|
365
390
|
// getEvents returns seq-ordered events, so the newest n slice out already
|
|
366
391
|
// chronological — which is what the AC asks for.
|
|
367
|
-
...(
|
|
392
|
+
...(events !== undefined ? { events } : {}),
|
|
368
393
|
...(state.outcome !== undefined ? { outcome: state.outcome } : {}),
|
|
369
394
|
}
|
|
370
395
|
}
|
|
@@ -742,7 +767,7 @@ export async function abBuilds(opts: AbBuildsOpts): Promise<void> {
|
|
|
742
767
|
// round trip and the listing's store cost stays flat as finished builds
|
|
743
768
|
// accumulate. The `--all` scope includes terminal statuses, so it takes no
|
|
744
769
|
// digest read and skips nothing — byte-for-byte the old loop.
|
|
745
|
-
const digests = opts.all === true ? undefined : await store
|
|
770
|
+
const digests = opts.all === true ? undefined : await readBuildDigests(store, identity)
|
|
746
771
|
const summaries: BuildSummary[] = []
|
|
747
772
|
for (const record of records) {
|
|
748
773
|
if (digests !== undefined && record.repo === identity) {
|
|
@@ -766,7 +791,11 @@ export async function abBuilds(opts: AbBuildsOpts): Promise<void> {
|
|
|
766
791
|
// entry under `identity` — `getRepoBuildDigests` groups by `record.repo`
|
|
767
792
|
// exactly — so it keeps its per-build history read (correct, just not
|
|
768
793
|
// accelerated).
|
|
769
|
-
const summary =
|
|
794
|
+
const summary = summarizeFrom(
|
|
795
|
+
record,
|
|
796
|
+
(await readBuildFacts(store, record.slug)).value.state,
|
|
797
|
+
now,
|
|
798
|
+
)
|
|
770
799
|
if (wanted.has(summary.status)) summaries.push(summary)
|
|
771
800
|
}
|
|
772
801
|
summaries.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
|
|
@@ -797,7 +826,7 @@ export interface AbBuildStatusOpts extends StatusOpts {
|
|
|
797
826
|
events?: number
|
|
798
827
|
}
|
|
799
828
|
|
|
800
|
-
function
|
|
829
|
+
function _openWorkspacePath(events: AbEvent[]): string | undefined {
|
|
801
830
|
let path: string | undefined
|
|
802
831
|
for (const event of events) {
|
|
803
832
|
if (event.type === 'workspace.provisioned') {
|
|
@@ -812,15 +841,11 @@ function openWorkspacePath(events: AbEvent[]): string | undefined {
|
|
|
812
841
|
/** The kernel tail both config sources share, so the deposited artifact and
|
|
813
842
|
* the workspace file cannot drift in what they compute — the sources differ
|
|
814
843
|
* only in where the config comes from, never in what is decided with it. */
|
|
815
|
-
function decideFromConfig(
|
|
816
|
-
|
|
817
|
-
state: BuildState,
|
|
818
|
-
config: Config,
|
|
819
|
-
): BuildDecisionProjection {
|
|
820
|
-
const decision = decideNext(events, config)
|
|
844
|
+
function decideFromConfig(facts: BuildFacts, config: Config): BuildDecisionProjection {
|
|
845
|
+
const decision = decideNextFromFacts(facts.state, facts.log, config)
|
|
821
846
|
if (decision.kind !== 'wait') return { kind: 'runner-work' }
|
|
822
847
|
if (decision.reason !== 'awaiting-pr') return { kind: 'parked', reason: decision.reason }
|
|
823
|
-
const reason =
|
|
848
|
+
const reason = currentAutoMergeDeferralFrom(facts.deferral, facts.state)
|
|
824
849
|
return { kind: 'awaiting-pr', ...(reason !== undefined ? { reason } : {}) }
|
|
825
850
|
}
|
|
826
851
|
|
|
@@ -840,10 +865,10 @@ function decideFromConfig(
|
|
|
840
865
|
* the read-only command fail. */
|
|
841
866
|
async function projectBuildDecision(
|
|
842
867
|
slug: string,
|
|
843
|
-
|
|
868
|
+
facts: BuildFacts,
|
|
844
869
|
store: Pick<BuildStore, 'getArtifact'>,
|
|
845
870
|
): Promise<BuildDecisionProjection | undefined> {
|
|
846
|
-
const state =
|
|
871
|
+
const state = facts.state
|
|
847
872
|
const active =
|
|
848
873
|
state.status === 'running' || state.status === 'paused' || state.status === 'blocked'
|
|
849
874
|
if (!active) return undefined
|
|
@@ -858,7 +883,7 @@ async function projectBuildDecision(
|
|
|
858
883
|
const artifact = await store.getArtifact(slug, BUILD_EFFECTIVE_CONFIG_ARTIFACT)
|
|
859
884
|
if (artifact !== null) {
|
|
860
885
|
try {
|
|
861
|
-
return decideFromConfig(
|
|
886
|
+
return decideFromConfig(facts, parseEffectiveBuildConfig(artifact))
|
|
862
887
|
} catch (error) {
|
|
863
888
|
const message = error instanceof Error ? error.message : String(error)
|
|
864
889
|
return {
|
|
@@ -870,7 +895,7 @@ async function projectBuildDecision(
|
|
|
870
895
|
|
|
871
896
|
// Source 2 — the local workspace, exactly as before, only when no artifact
|
|
872
897
|
// is deposited.
|
|
873
|
-
const workspace =
|
|
898
|
+
const workspace = facts.workspace?.path ?? facts.workspace?.ref
|
|
874
899
|
if (workspace === undefined) {
|
|
875
900
|
return {
|
|
876
901
|
kind: 'unavailable',
|
|
@@ -879,7 +904,7 @@ async function projectBuildDecision(
|
|
|
879
904
|
}
|
|
880
905
|
|
|
881
906
|
try {
|
|
882
|
-
return decideFromConfig(
|
|
907
|
+
return decideFromConfig(facts, await loadConfig(join(workspace, 'autobuild.toml')))
|
|
883
908
|
} catch {
|
|
884
909
|
// The raw error deliberately stays here: ENOENT/ENOTDIR messages embed the
|
|
885
910
|
// recorded workspace path, which for a remote build is a guest location.
|
|
@@ -944,8 +969,13 @@ export async function abBuildStatus(opts: AbBuildStatusOpts): Promise<void> {
|
|
|
944
969
|
const now = (opts.now ?? (() => new Date()))()
|
|
945
970
|
await withAmbientReadStore(opts, async (context) => {
|
|
946
971
|
const record = await requireReadableBuild(opts, context)
|
|
947
|
-
const
|
|
948
|
-
const
|
|
972
|
+
const current = await readBuildFacts(context.store, opts.slug)
|
|
973
|
+
const facts = current.value
|
|
974
|
+
const events =
|
|
975
|
+
opts.events === undefined
|
|
976
|
+
? undefined
|
|
977
|
+
: await context.store.getEvents(opts.slug, Math.max(0, current.cursor - opts.events))
|
|
978
|
+
const decision = await projectBuildDecision(opts.slug, facts, context.store)
|
|
949
979
|
// Stream enrichment is presentation (SPEC §9): an unavailable read
|
|
950
980
|
// degrades to payload-carried ids without a status, never a failure.
|
|
951
981
|
let streams: StreamRecord[] | undefined
|
|
@@ -957,7 +987,7 @@ export async function abBuildStatus(opts: AbBuildStatusOpts): Promise<void> {
|
|
|
957
987
|
} catch {
|
|
958
988
|
streams = undefined
|
|
959
989
|
}
|
|
960
|
-
const projected =
|
|
990
|
+
const projected = detailFromFacts(record, facts, now, events, decision, streams)
|
|
961
991
|
// Operator visibility (SPEC §16.1): which autobuild.toml the build's
|
|
962
992
|
// pinned pipeline came from, alongside the effective-config revision.
|
|
963
993
|
const configArtifact = await context.store.getArtifact(
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readBuildFacts, readTerminalGuard, type CurrentBuildFacts } from '../store/reduced-reads'
|
|
1
2
|
/**
|
|
2
3
|
* The three terminal commands — `ab done`, `ab verdict`, `ab escalate`
|
|
3
4
|
* (SPEC §8.4 D5, §8.5 D6, §8.6 D7).
|
|
@@ -18,7 +19,7 @@
|
|
|
18
19
|
import { join } from 'node:path'
|
|
19
20
|
import { z } from 'zod'
|
|
20
21
|
import { loadConfig } from '../config/load'
|
|
21
|
-
import type {
|
|
22
|
+
import type { EventEnvelope } from '../events/catalog'
|
|
22
23
|
import { agentActor, KERNEL } from '../events/envelope'
|
|
23
24
|
import { isRemoteWorkspace } from '../events/workspace-remote'
|
|
24
25
|
import type { IdSource } from '../ids'
|
|
@@ -29,7 +30,6 @@ import {
|
|
|
29
30
|
} from '../kernel/auto-merge'
|
|
30
31
|
import { phaseSpecFor } from '../kernel/phases'
|
|
31
32
|
import { resolvePlanVerifySteps } from '../kernel/plan-verify-selection'
|
|
32
|
-
import { reduceBuild } from '../kernel/reducer'
|
|
33
33
|
import {
|
|
34
34
|
findingDraftSchema,
|
|
35
35
|
isVerifyPhase,
|
|
@@ -75,114 +75,9 @@ export interface TerminalDeps {
|
|
|
75
75
|
* round, so a still-in-flight CLI process from the failed session must not
|
|
76
76
|
* land a terminal the runner will misattribute to the retry).
|
|
77
77
|
*/
|
|
78
|
-
function assertNoPriorTerminal(
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
// across a spec.revised (decideNext ignores pre-restart results and
|
|
82
|
-
// re-runs the pipeline from plan), so a pre-restart verify.completed or
|
|
83
|
-
// finalize.completed must not shadow the rebuilt pipeline's terminal.
|
|
84
|
-
// Loop-phase rounds continue monotonically across restarts (engine
|
|
85
|
-
// LoopIndex.maxRoundEver) and never collide, so they match over the full
|
|
86
|
-
// log; the reconcile epilogue is restart-orthogonal (§15.7).
|
|
87
|
-
let restartSeq = 0
|
|
88
|
-
for (const event of events) {
|
|
89
|
-
if (event.type === 'spec.revised') restartSeq = event.seq
|
|
90
|
-
}
|
|
91
|
-
// reconcile.completed carries no attempt (§15.3); pair it with the latest
|
|
92
|
-
// reconcile.started seen before it.
|
|
93
|
-
let reconcileAttempt = 0
|
|
94
|
-
let sessionStartedSeq: number | undefined
|
|
95
|
-
for (const event of events) {
|
|
96
|
-
if (event.type === 'reconcile.started') reconcileAttempt = event.payload.attempt
|
|
97
|
-
if (event.type === 'session.started' && event.payload.session === env.session) {
|
|
98
|
-
sessionStartedSeq = event.seq
|
|
99
|
-
}
|
|
100
|
-
if (event.type === 'session.ended' && event.payload.session === env.session) {
|
|
101
|
-
throw new Error(
|
|
102
|
-
`terminal rejected (D5): session "${env.session}" already ended ` +
|
|
103
|
-
`(session.ended at seq ${event.seq}) — a session that ended without ` +
|
|
104
|
-
'a terminal was failed and retried; only the live retry may complete ' +
|
|
105
|
-
'this phase round (§8.4).',
|
|
106
|
-
)
|
|
107
|
-
}
|
|
108
|
-
if (
|
|
109
|
-
sessionStartedSeq !== undefined &&
|
|
110
|
-
event.type === 'phase.failed' &&
|
|
111
|
-
event.payload.phase === env.phase &&
|
|
112
|
-
event.payload.round === env.round &&
|
|
113
|
-
event.seq > sessionStartedSeq
|
|
114
|
-
) {
|
|
115
|
-
throw new Error(
|
|
116
|
-
`terminal rejected (D5): ${env.phase}@${env.round} already failed after ` +
|
|
117
|
-
`this session started (phase.failed at seq ${event.seq}) — the retry ` +
|
|
118
|
-
'session owns this round now (§8.4).',
|
|
119
|
-
)
|
|
120
|
-
}
|
|
121
|
-
if (
|
|
122
|
-
event.type === 'escalation.raised' &&
|
|
123
|
-
event.actor.kind === 'agent' &&
|
|
124
|
-
event.actor.session === env.session
|
|
125
|
-
) {
|
|
126
|
-
throw new Error(
|
|
127
|
-
`second terminal call rejected (D5): this session already escalated — ` +
|
|
128
|
-
`escalation.raised "${event.payload.id}" at seq ${event.seq}. ` +
|
|
129
|
-
'Every phase ends with exactly one terminal command (§8.4).',
|
|
130
|
-
)
|
|
131
|
-
}
|
|
132
|
-
if (
|
|
133
|
-
event.type === 'publication.requested' &&
|
|
134
|
-
event.actor.kind === 'agent' &&
|
|
135
|
-
event.actor.session === env.session &&
|
|
136
|
-
((env.phase === 'implement' &&
|
|
137
|
-
event.payload.operation === 'implement' &&
|
|
138
|
-
event.payload.round === env.round) ||
|
|
139
|
-
(env.phase === 'reconcile' && event.payload.operation === 'reconcile') ||
|
|
140
|
-
(env.phase === 'finalize' && event.payload.operation === 'finalize'))
|
|
141
|
-
) {
|
|
142
|
-
throw new Error(
|
|
143
|
-
`second terminal call rejected (D5): ${event.type} for ` +
|
|
144
|
-
`${env.phase}@${env.round} already recorded at seq ${event.seq}. ` +
|
|
145
|
-
'Remote publication is pending; do not run ab done again (§8.4).',
|
|
146
|
-
)
|
|
147
|
-
}
|
|
148
|
-
if (event.type !== spec.terminalEvent) continue
|
|
149
|
-
if (
|
|
150
|
-
(event.type === 'verify.completed' || event.type === 'finalize.completed') &&
|
|
151
|
-
event.seq <= restartSeq
|
|
152
|
-
) {
|
|
153
|
-
continue
|
|
154
|
-
}
|
|
155
|
-
if (!terminalMatchesRound(event, env, reconcileAttempt)) continue
|
|
156
|
-
throw new Error(
|
|
157
|
-
`second terminal call rejected (D5): ${event.type} for ` +
|
|
158
|
-
`${env.phase}@${env.round} already recorded at seq ${event.seq}. ` +
|
|
159
|
-
'Every phase ends with exactly one terminal command (§8.4).',
|
|
160
|
-
)
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
function terminalMatchesRound(event: AbEvent, env: CliEnv, reconcileAttempt: number): boolean {
|
|
165
|
-
switch (event.type) {
|
|
166
|
-
case 'plan.completed':
|
|
167
|
-
case 'plan-review.verdict':
|
|
168
|
-
case 'implement.completed':
|
|
169
|
-
case 'code-review.verdict':
|
|
170
|
-
return event.payload.round === env.round
|
|
171
|
-
case 'verify.completed':
|
|
172
|
-
return (
|
|
173
|
-
isVerifyPhase(env.phase) &&
|
|
174
|
-
event.payload.step === verifyStep(env.phase) &&
|
|
175
|
-
event.payload.attempt === env.round
|
|
176
|
-
)
|
|
177
|
-
case 'finalize.completed':
|
|
178
|
-
// finalize runs once per build (§5) — any prior completion counts.
|
|
179
|
-
return true
|
|
180
|
-
case 'reconcile.completed':
|
|
181
|
-
// No started event before it → conservative match (total over any log).
|
|
182
|
-
return reconcileAttempt === env.round || reconcileAttempt === 0
|
|
183
|
-
default:
|
|
184
|
-
return false
|
|
185
|
-
}
|
|
78
|
+
async function assertNoPriorTerminal(store: BuildStore, env: CliEnv): Promise<void> {
|
|
79
|
+
const result = await readTerminalGuard(store, env.build, env.phase, env.round, env.session)
|
|
80
|
+
if (result.diagnostic) throw new Error(result.diagnostic)
|
|
186
81
|
}
|
|
187
82
|
|
|
188
83
|
// ── Small helpers ────────────────────────────────────────────────────────────
|
|
@@ -240,28 +135,21 @@ async function assertCleanWorktree(deps: TerminalDeps): Promise<void> {
|
|
|
240
135
|
}
|
|
241
136
|
}
|
|
242
137
|
|
|
243
|
-
function baseBranchOf(events:
|
|
244
|
-
|
|
245
|
-
if (event.type === 'build.created') return event.payload.baseBranch
|
|
246
|
-
}
|
|
138
|
+
function baseBranchOf(events: CurrentBuildFacts): string {
|
|
139
|
+
if (events.created) return events.created.payload.baseBranch
|
|
247
140
|
throw new Error('this build has no build.created event — cannot resolve its baseBranch (§15.3)')
|
|
248
141
|
}
|
|
249
142
|
|
|
250
|
-
function requireImplementationProvisioning(events:
|
|
251
|
-
if (events.
|
|
143
|
+
function requireImplementationProvisioning(events: CurrentBuildFacts): void {
|
|
144
|
+
if (events.workspaceGenerations > 0) return
|
|
252
145
|
throw new Error(
|
|
253
146
|
"implement's 'ab done' requires workspace.provisioned provenance — " +
|
|
254
147
|
'this build has no workspace.provisioned event (§7.4, §15.3)',
|
|
255
148
|
)
|
|
256
149
|
}
|
|
257
150
|
|
|
258
|
-
function remotePublication(events:
|
|
259
|
-
|
|
260
|
-
for (const event of events) {
|
|
261
|
-
if (event.type === 'workspace.provisioned') remote = isRemoteWorkspace(event.payload)
|
|
262
|
-
else if (event.type === 'workspace.released') remote = false
|
|
263
|
-
}
|
|
264
|
-
return remote
|
|
151
|
+
function remotePublication(events: CurrentBuildFacts): boolean {
|
|
152
|
+
return events.workspace !== null && isRemoteWorkspace(events.workspace)
|
|
265
153
|
}
|
|
266
154
|
|
|
267
155
|
async function buildBranch(deps: TerminalDeps): Promise<string> {
|
|
@@ -281,7 +169,7 @@ async function buildBranch(deps: TerminalDeps): Promise<string> {
|
|
|
281
169
|
*/
|
|
282
170
|
async function implementationReviewBoundary(
|
|
283
171
|
deps: TerminalDeps,
|
|
284
|
-
events:
|
|
172
|
+
events: CurrentBuildFacts,
|
|
285
173
|
): Promise<{ branch: string; base: string; head: string }> {
|
|
286
174
|
const diagnostic = 'cannot establish implementation review boundary'
|
|
287
175
|
try {
|
|
@@ -384,7 +272,7 @@ export interface FinalizePrInput {
|
|
|
384
272
|
* requested head before entering this idempotent PR-side sequence. */
|
|
385
273
|
export async function completeFinalizePr(
|
|
386
274
|
deps: TerminalDeps,
|
|
387
|
-
events:
|
|
275
|
+
events: CurrentBuildFacts,
|
|
388
276
|
input: FinalizePrInput,
|
|
389
277
|
): Promise<EventEnvelope> {
|
|
390
278
|
const { env, store } = deps
|
|
@@ -404,8 +292,8 @@ export async function completeFinalizePr(
|
|
|
404
292
|
|
|
405
293
|
await preparePrAttachments(deps, events, pr.url)
|
|
406
294
|
|
|
407
|
-
const latest = await store
|
|
408
|
-
const autoMerge = pendingAutoMerge(
|
|
295
|
+
const latest = (await readBuildFacts(store, env.build)).value
|
|
296
|
+
const autoMerge = pendingAutoMerge(latest.state)
|
|
409
297
|
const autoMergeResult =
|
|
410
298
|
autoMerge === undefined
|
|
411
299
|
? undefined
|
|
@@ -452,8 +340,8 @@ export async function done(deps: TerminalDeps, opts: DoneOpts = {}): Promise<Eve
|
|
|
452
340
|
`${spec.kind} phase; use 'ab verdict' (§8.2)`,
|
|
453
341
|
)
|
|
454
342
|
}
|
|
455
|
-
const events = await store
|
|
456
|
-
assertNoPriorTerminal(
|
|
343
|
+
const events = (await readBuildFacts(store, env.build)).value
|
|
344
|
+
await assertNoPriorTerminal(store, env)
|
|
457
345
|
const actor = agentActor(env.phase, env.session)
|
|
458
346
|
|
|
459
347
|
switch (spec.name) {
|
|
@@ -466,12 +354,7 @@ export async function done(deps: TerminalDeps, opts: DoneOpts = {}): Promise<Eve
|
|
|
466
354
|
// still the already-reviewed plan, re-entering review as if revised.
|
|
467
355
|
const revs = await store.listArtifacts(env.build, 'plan')
|
|
468
356
|
const latest = revs[revs.length - 1]
|
|
469
|
-
|
|
470
|
-
for (const event of events) {
|
|
471
|
-
if (event.type === 'plan.completed') {
|
|
472
|
-
lastCited = Math.max(lastCited, event.payload.artifact.rev)
|
|
473
|
-
}
|
|
474
|
-
}
|
|
357
|
+
const lastCited = events.maxPlanRevision
|
|
475
358
|
if (latest === undefined || latest.revision <= lastCited) {
|
|
476
359
|
throw new Error(
|
|
477
360
|
`plan@${env.round} 'ab done' requires this round's plan deposit: found ` +
|
|
@@ -704,8 +587,8 @@ export async function verdict(deps: TerminalDeps, opts: VerdictOpts): Promise<Ev
|
|
|
704
587
|
`accept pass|fail|skip. ${env.phase} accepts: ${vocabulary.join('|')}`,
|
|
705
588
|
)
|
|
706
589
|
}
|
|
707
|
-
const events = await store
|
|
708
|
-
assertNoPriorTerminal(
|
|
590
|
+
const events = (await readBuildFacts(store, env.build)).value
|
|
591
|
+
await assertNoPriorTerminal(store, env)
|
|
709
592
|
const actor = agentActor(env.phase, env.session)
|
|
710
593
|
|
|
711
594
|
if (spec.kind === 'agent-verify') {
|
|
@@ -717,7 +600,7 @@ export async function verdict(deps: TerminalDeps, opts: VerdictOpts): Promise<Ev
|
|
|
717
600
|
async function reviewVerdict(
|
|
718
601
|
deps: TerminalDeps,
|
|
719
602
|
opts: VerdictOpts,
|
|
720
|
-
events:
|
|
603
|
+
events: CurrentBuildFacts,
|
|
721
604
|
actor: ReturnType<typeof agentActor>,
|
|
722
605
|
): Promise<EventEnvelope[]> {
|
|
723
606
|
const { env, store } = deps
|
|
@@ -816,7 +699,7 @@ export function renderFindingsIssues(issues: readonly z.core.$ZodIssue[]): strin
|
|
|
816
699
|
async function parseFindings(
|
|
817
700
|
deps: TerminalDeps,
|
|
818
701
|
path: string,
|
|
819
|
-
events:
|
|
702
|
+
events: CurrentBuildFacts,
|
|
820
703
|
): Promise<Finding[]> {
|
|
821
704
|
const raw = await readTextFile(path, '--findings')
|
|
822
705
|
let json: unknown
|
|
@@ -841,7 +724,7 @@ async function parseFindings(
|
|
|
841
724
|
// `persists` may only reference ids from prior rounds of THIS loop (§15.4;
|
|
842
725
|
// ids are deposit-stamped, so a reviewer can only ever see rounds it was
|
|
843
726
|
// shown — §8.3).
|
|
844
|
-
const state =
|
|
727
|
+
const state = events.state
|
|
845
728
|
const loop =
|
|
846
729
|
deps.env.phase === 'plan-review'
|
|
847
730
|
? state.reviewFindings.planReview
|
|
@@ -969,8 +852,8 @@ export async function escalate(
|
|
|
969
852
|
if (opts.question.trim() === '') {
|
|
970
853
|
throw new Error("'ab escalate' requires a question — it is what the human answers (§11)")
|
|
971
854
|
}
|
|
972
|
-
const
|
|
973
|
-
assertNoPriorTerminal(
|
|
855
|
+
const _events = (await readBuildFacts(store, env.build)).value
|
|
856
|
+
await assertNoPriorTerminal(store, env)
|
|
974
857
|
return store.append(env.build, {
|
|
975
858
|
actor: agentActor(env.phase, env.session),
|
|
976
859
|
type: 'escalation.raised',
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readBuildFacts } from '../store/reduced-reads'
|
|
1
2
|
import { exportTickets, importTickets } from '../ports/tickets/migration'
|
|
2
3
|
import { composeTicketAssets } from '../store/ticket-assets'
|
|
3
4
|
import { editTicket, type TicketEditorRunner } from './ticket-editor'
|
|
@@ -29,7 +30,6 @@ import type {
|
|
|
29
30
|
TicketStateInfo,
|
|
30
31
|
TicketUpdate,
|
|
31
32
|
} from '../ports/types'
|
|
32
|
-
import { reduceBuild } from '../kernel/reducer'
|
|
33
33
|
import type { BuildStore } from '../store/types'
|
|
34
34
|
import { spawnExec, type Exec } from '../ports/workspace/git-worktree'
|
|
35
35
|
import type { TicketAssetSummary } from '../store/ticket-assets'
|
|
@@ -478,7 +478,7 @@ async function activeBuildsFor(store: BuildStore, repo: string, id: string): Pro
|
|
|
478
478
|
const active: string[] = []
|
|
479
479
|
for (const record of await store.listBuilds()) {
|
|
480
480
|
if (record.repo !== repo || record.ticket?.id !== id) continue
|
|
481
|
-
const { status } =
|
|
481
|
+
const { status } = (await readBuildFacts(store, record.slug)).value.state
|
|
482
482
|
if (status !== 'done' && status !== 'aborted') active.push(record.slug)
|
|
483
483
|
}
|
|
484
484
|
return active
|