@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,789 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable operator controls for one build.
|
|
3
|
+
*
|
|
4
|
+
* The dashboard and the sessionless CLI both call this module: it owns the
|
|
5
|
+
* event-reduced active-build checks, human attribution, and event ordering so
|
|
6
|
+
* the two surfaces cannot drift. Controls only append requests/answers; the
|
|
7
|
+
* normal runner and dispatcher consume them.
|
|
8
|
+
*/
|
|
9
|
+
import type { AbEvent } from '../events/catalog'
|
|
10
|
+
import { humanActor, KERNEL, type Via } from '../events/envelope'
|
|
11
|
+
import { reduceBuild, type BuildState, type OpenEscalation } from '../kernel/reducer'
|
|
12
|
+
import type { ArtifactRef, BuildOutcome, BuildStatus, TicketRef } from '../ontology'
|
|
13
|
+
import type { Exec } from '../ports/workspace/git-worktree'
|
|
14
|
+
import { specConformance } from '../spec-standard'
|
|
15
|
+
import type { BuildStore } from '../store/types'
|
|
16
|
+
import { withSessionlessStore, type StoreOpener } from './store-opening'
|
|
17
|
+
|
|
18
|
+
const ACTIVE_STATUSES: readonly BuildStatus[] = ['running', 'paused', 'blocked']
|
|
19
|
+
|
|
20
|
+
/** Retry carries no phase guidance, but the event schema still requires a
|
|
21
|
+
* nonempty audit answer. The reducer/materializer route on `resolution`, so
|
|
22
|
+
* this text is never presented to the next phase as guidance. */
|
|
23
|
+
export const BARE_RETRY_ANSWER = 'Operator requested a bare retry with no feedback'
|
|
24
|
+
export const DISMISSAL_ANSWER = 'Operator dismissed the cited findings with no feedback'
|
|
25
|
+
export const SPEC_REVISION_ANSWER = 'Operator replaced the spec; the build restarts from plan'
|
|
26
|
+
|
|
27
|
+
/** Both forms are lazy: completing an earlier authorization must not open the
|
|
28
|
+
* replacement source supplied to the retrying invocation. */
|
|
29
|
+
export type SpecBodySource =
|
|
30
|
+
| { kind: 'supplied'; origin: string; read: () => Promise<string> }
|
|
31
|
+
| { kind: 'ticket' }
|
|
32
|
+
|
|
33
|
+
export type AnswerResolutionRequest =
|
|
34
|
+
| { kind: 'dismiss-finding' }
|
|
35
|
+
| { kind: 'revise-spec'; body: SpecBodySource }
|
|
36
|
+
|
|
37
|
+
export type BuildControlAction =
|
|
38
|
+
| { kind: 'pause' }
|
|
39
|
+
| { kind: 'resume' }
|
|
40
|
+
| { kind: 'abort' }
|
|
41
|
+
| { kind: 'discard' }
|
|
42
|
+
| { kind: 'auto-merge-on' }
|
|
43
|
+
| { kind: 'auto-merge-off' }
|
|
44
|
+
| { kind: 'dashboard-pause' }
|
|
45
|
+
| { kind: 'dashboard-resume' }
|
|
46
|
+
| { kind: 'toggle-auto-merge' }
|
|
47
|
+
| {
|
|
48
|
+
kind: 'answer'
|
|
49
|
+
text?: string
|
|
50
|
+
/** Omitted preserves the historical nonblank-guidance/blank-retry rule. */
|
|
51
|
+
resolve?: AnswerResolutionRequest
|
|
52
|
+
/** Absolute cap for the review loop named by an open round-limit escalation. */
|
|
53
|
+
reviewRoundCeiling?: number
|
|
54
|
+
/** Dashboard prompts answer only blockers captured when the field
|
|
55
|
+
* opened. Omitted by the CLI, which answers every currently open one. */
|
|
56
|
+
escalationIds?: readonly string[]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type BuildControlCommand =
|
|
60
|
+
| 'pause'
|
|
61
|
+
| 'resume'
|
|
62
|
+
| 'abort'
|
|
63
|
+
| 'discard'
|
|
64
|
+
| 'auto-merge-on'
|
|
65
|
+
| 'auto-merge-off'
|
|
66
|
+
|
|
67
|
+
export type BuildControlResult =
|
|
68
|
+
| {
|
|
69
|
+
kind: 'command'
|
|
70
|
+
slug: string
|
|
71
|
+
command: BuildControlCommand
|
|
72
|
+
event: AbEvent
|
|
73
|
+
}
|
|
74
|
+
| {
|
|
75
|
+
kind: 'answer-required'
|
|
76
|
+
slug: string
|
|
77
|
+
escalationIds: string[]
|
|
78
|
+
}
|
|
79
|
+
| {
|
|
80
|
+
kind: 'answered'
|
|
81
|
+
slug: string
|
|
82
|
+
count: number
|
|
83
|
+
resolution: 'guidance' | 'retry' | 'dismiss-finding' | 'revise-spec'
|
|
84
|
+
resumed: boolean
|
|
85
|
+
specRev?: number
|
|
86
|
+
authorizedEarlier?: boolean
|
|
87
|
+
remainingOpen?: number
|
|
88
|
+
reviewRoundCeiling?: { loop: 'plan' | 'code'; value: number }
|
|
89
|
+
/** A durable post-append fact says this build cannot restart. */
|
|
90
|
+
terminalSignal?: TerminalSignal
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export type TerminalSignal =
|
|
94
|
+
| { kind: 'terminal-status'; status: 'done' | 'aborted'; outcome?: BuildOutcome }
|
|
95
|
+
| { kind: 'abort-requested' }
|
|
96
|
+
| { kind: 'pr-ended'; state: 'merged' | 'closed' }
|
|
97
|
+
|
|
98
|
+
export type BuildControlErrorCode =
|
|
99
|
+
| 'not-found'
|
|
100
|
+
| 'wrong-repository'
|
|
101
|
+
| 'inactive'
|
|
102
|
+
| 'no-open-escalations'
|
|
103
|
+
| 'own-session'
|
|
104
|
+
| 'spec-nonconforming'
|
|
105
|
+
| 'no-cited-findings'
|
|
106
|
+
| 'ticket-unavailable'
|
|
107
|
+
| 'missing-authorization'
|
|
108
|
+
| 'no-longer-active'
|
|
109
|
+
| 'abort-pending'
|
|
110
|
+
| 'review-round-ceiling-unavailable'
|
|
111
|
+
| 'incompatible-answer-options'
|
|
112
|
+
|
|
113
|
+
/** Typed codes let the dashboard turn stale selections/prompts into warnings
|
|
114
|
+
* while the CLI can surface the same underlying conflict as a command error. */
|
|
115
|
+
export class BuildControlError extends Error {
|
|
116
|
+
readonly code: BuildControlErrorCode
|
|
117
|
+
|
|
118
|
+
constructor(code: BuildControlErrorCode, message: string) {
|
|
119
|
+
super(message)
|
|
120
|
+
this.name = 'BuildControlError'
|
|
121
|
+
this.code = code
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Preserve the dashboard's attribution rule for both operator surfaces. */
|
|
126
|
+
export function buildControlUser(env: Record<string, string | undefined>): string {
|
|
127
|
+
for (const name of ['USER', 'USERNAME']) {
|
|
128
|
+
const value = env[name]?.trim()
|
|
129
|
+
if (value !== undefined && value !== '') return value
|
|
130
|
+
}
|
|
131
|
+
return 'dashboard'
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function activeState(slug: string, state: BuildState): void {
|
|
135
|
+
if (ACTIVE_STATUSES.includes(state.status)) return
|
|
136
|
+
throw new BuildControlError(
|
|
137
|
+
'inactive',
|
|
138
|
+
`build "${slug}" is not active (status: ${state.status}); ` +
|
|
139
|
+
'build controls require running, paused, or blocked',
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function appendCommand(
|
|
144
|
+
store: BuildStore,
|
|
145
|
+
slug: string,
|
|
146
|
+
user: string,
|
|
147
|
+
command: BuildControlCommand,
|
|
148
|
+
via?: Via,
|
|
149
|
+
): Promise<BuildControlResult> {
|
|
150
|
+
const actor = humanActor(user, via)
|
|
151
|
+
let event: AbEvent
|
|
152
|
+
switch (command) {
|
|
153
|
+
case 'pause':
|
|
154
|
+
event = await store.append(slug, {
|
|
155
|
+
actor,
|
|
156
|
+
type: 'build.pause-requested',
|
|
157
|
+
payload: {},
|
|
158
|
+
})
|
|
159
|
+
break
|
|
160
|
+
case 'resume':
|
|
161
|
+
event = await store.append(slug, {
|
|
162
|
+
actor,
|
|
163
|
+
type: 'build.resume-requested',
|
|
164
|
+
payload: {},
|
|
165
|
+
})
|
|
166
|
+
break
|
|
167
|
+
case 'abort':
|
|
168
|
+
event = await store.append(slug, {
|
|
169
|
+
actor,
|
|
170
|
+
type: 'build.abort-requested',
|
|
171
|
+
payload: {},
|
|
172
|
+
})
|
|
173
|
+
break
|
|
174
|
+
case 'discard':
|
|
175
|
+
event = await store.append(slug, {
|
|
176
|
+
actor,
|
|
177
|
+
type: 'build.discard-requested',
|
|
178
|
+
payload: {},
|
|
179
|
+
})
|
|
180
|
+
break
|
|
181
|
+
case 'auto-merge-on':
|
|
182
|
+
event = await store.append(slug, {
|
|
183
|
+
actor,
|
|
184
|
+
type: 'build.auto-merge-requested',
|
|
185
|
+
payload: {},
|
|
186
|
+
})
|
|
187
|
+
break
|
|
188
|
+
case 'auto-merge-off':
|
|
189
|
+
event = await store.append(slug, {
|
|
190
|
+
actor,
|
|
191
|
+
type: 'build.auto-merge-cancelled',
|
|
192
|
+
payload: {},
|
|
193
|
+
})
|
|
194
|
+
break
|
|
195
|
+
}
|
|
196
|
+
return { kind: 'command', slug, command, event }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function selectedOpen(state: BuildState, captured?: Set<string>): OpenEscalation[] {
|
|
200
|
+
return captured === undefined
|
|
201
|
+
? state.openEscalations
|
|
202
|
+
: state.openEscalations.filter((item) => captured.has(item.id))
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function reviewRoundLimitEscalation(
|
|
206
|
+
slug: string,
|
|
207
|
+
open: OpenEscalation[],
|
|
208
|
+
): OpenEscalation & { phase: 'plan-review' | 'code-review' } {
|
|
209
|
+
const eligible = open.filter(
|
|
210
|
+
(item): item is OpenEscalation & { phase: 'plan-review' | 'code-review' } =>
|
|
211
|
+
item.source === 'policy' &&
|
|
212
|
+
item.policyCause === 'review-round-limit' &&
|
|
213
|
+
(item.phase === 'plan-review' || item.phase === 'code-review'),
|
|
214
|
+
)
|
|
215
|
+
if (eligible.length !== 1) {
|
|
216
|
+
throw new BuildControlError(
|
|
217
|
+
'review-round-ceiling-unavailable',
|
|
218
|
+
`cannot set a review round ceiling for build "${slug}": ` +
|
|
219
|
+
'exactly one open review-round-limit escalation is required; nothing was recorded',
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
return eligible[0]!
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function hasPendingAbort(state: BuildState): boolean {
|
|
226
|
+
return state.pendingCommands.some((command) => command.command === 'abort')
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function abortPending(slug: string): BuildControlError {
|
|
230
|
+
return new BuildControlError(
|
|
231
|
+
'abort-pending',
|
|
232
|
+
`build "${slug}" is aborting; nothing was recorded. Abort is not revocable — ` +
|
|
233
|
+
'after cleanup returns the ticket to Triage, dispatch it as a fresh build',
|
|
234
|
+
)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function terminalSignal(state: BuildState): TerminalSignal | undefined {
|
|
238
|
+
if (state.status === 'done' || state.status === 'aborted') {
|
|
239
|
+
return {
|
|
240
|
+
kind: 'terminal-status',
|
|
241
|
+
status: state.status,
|
|
242
|
+
...(state.outcome !== undefined ? { outcome: state.outcome } : {}),
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (hasPendingAbort(state)) return { kind: 'abort-requested' }
|
|
246
|
+
if (state.prState === 'merged' || state.prState === 'closed') {
|
|
247
|
+
return { kind: 'pr-ended', state: state.prState }
|
|
248
|
+
}
|
|
249
|
+
return undefined
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export type ReviseDecision =
|
|
253
|
+
| { kind: 'refuse'; code: BuildControlErrorCode; message: string }
|
|
254
|
+
| {
|
|
255
|
+
kind: 'complete'
|
|
256
|
+
artifact: ArtifactRef
|
|
257
|
+
open: OpenEscalation[]
|
|
258
|
+
escalationSeq: number
|
|
259
|
+
}
|
|
260
|
+
| { kind: 'fresh'; open: OpenEscalation[]; escalationSeq: number }
|
|
261
|
+
|
|
262
|
+
/** Pure restart decision, run once before body preparation and once against a
|
|
263
|
+
* fresh reduction immediately before publication. */
|
|
264
|
+
export function decideRevise(
|
|
265
|
+
slug: string,
|
|
266
|
+
state: BuildState,
|
|
267
|
+
captured?: Set<string>,
|
|
268
|
+
): ReviseDecision {
|
|
269
|
+
const open = selectedOpen(state, captured)
|
|
270
|
+
const pending = state.answeredEscalations.filter(
|
|
271
|
+
(item) => item.resolution === 'revise-spec' && item.answeredSeq > state.restartSince,
|
|
272
|
+
)
|
|
273
|
+
const authorized = pending[0]
|
|
274
|
+
|
|
275
|
+
if (hasPendingAbort(state)) {
|
|
276
|
+
return { kind: 'refuse', code: 'abort-pending', message: abortPending(slug).message }
|
|
277
|
+
}
|
|
278
|
+
if (authorized !== undefined) {
|
|
279
|
+
if (authorized.artifact === undefined) {
|
|
280
|
+
return {
|
|
281
|
+
kind: 'refuse',
|
|
282
|
+
code: 'missing-authorization',
|
|
283
|
+
message:
|
|
284
|
+
`build "${slug}" has a pending spec revision from escalation ` +
|
|
285
|
+
`"${authorized.id}", but its recorded answer names no replacement body. ` +
|
|
286
|
+
'Autobuild will not infer authorization from the newest deposited spec: the spec is ' +
|
|
287
|
+
'the contract for every downstream approval. The build remains parked; use ' +
|
|
288
|
+
`ab abort ${slug} and dispatch the ticket fresh to exit this state.`,
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
const escalationSeq = Math.max(...[...open, ...pending].map((item) => item.seq))
|
|
292
|
+
return { kind: 'complete', artifact: authorized.artifact, open, escalationSeq }
|
|
293
|
+
}
|
|
294
|
+
if (open.length === 0) {
|
|
295
|
+
const paused =
|
|
296
|
+
state.status === 'paused'
|
|
297
|
+
? ` If a revision already landed, the build remains paused; run ab resume ${slug}.`
|
|
298
|
+
: ''
|
|
299
|
+
return {
|
|
300
|
+
kind: 'refuse',
|
|
301
|
+
code: 'no-open-escalations',
|
|
302
|
+
message:
|
|
303
|
+
`build "${slug}" has no open escalation for a spec revision (status: ${state.status}). ` +
|
|
304
|
+
'The spec is the build contract and changes only through an escalation raised by a phase; ' +
|
|
305
|
+
`the phase must use ab escalate first.${paused}`,
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return {
|
|
309
|
+
kind: 'fresh',
|
|
310
|
+
open,
|
|
311
|
+
escalationSeq: Math.max(...open.map((item) => item.seq)),
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function throwRefusal(decision: Extract<ReviseDecision, { kind: 'refuse' }>): never {
|
|
316
|
+
throw new BuildControlError(decision.code, decision.message)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function assertStillActive(slug: string, state: BuildState): void {
|
|
320
|
+
if (ACTIVE_STATUSES.includes(state.status)) return
|
|
321
|
+
throw new BuildControlError(
|
|
322
|
+
'no-longer-active',
|
|
323
|
+
`build "${slug}" became ${state.status}${
|
|
324
|
+
state.outcome !== undefined ? ` (${state.outcome})` : ''
|
|
325
|
+
} while the answer was being prepared; nothing was appended. ` +
|
|
326
|
+
'Any deposited replacement revision is inert because no spec event cites it.',
|
|
327
|
+
)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function knownFindingIds(state: BuildState): Set<string> {
|
|
331
|
+
return new Set(
|
|
332
|
+
[...state.reviewFindings.planReview.flat(), ...state.reviewFindings.codeReview.flat()].map(
|
|
333
|
+
(finding) => finding.id,
|
|
334
|
+
),
|
|
335
|
+
)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function dismissPartition(
|
|
339
|
+
state: BuildState,
|
|
340
|
+
captured?: Set<string>,
|
|
341
|
+
): {
|
|
342
|
+
eligible: OpenEscalation[]
|
|
343
|
+
ineligible: OpenEscalation[]
|
|
344
|
+
} {
|
|
345
|
+
const known = knownFindingIds(state)
|
|
346
|
+
const open = selectedOpen(state, captured)
|
|
347
|
+
return {
|
|
348
|
+
eligible: open.filter((item) => (item.refs ?? []).some((ref) => known.has(ref))),
|
|
349
|
+
ineligible: open.filter((item) => !(item.refs ?? []).some((ref) => known.has(ref))),
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function noCitedFindings(slug: string, open: OpenEscalation[]): BuildControlError {
|
|
354
|
+
const refs = [...new Set(open.flatMap((item) => item.refs ?? []))]
|
|
355
|
+
const detail =
|
|
356
|
+
refs.length === 0
|
|
357
|
+
? 'the selected escalation(s) cite no references'
|
|
358
|
+
: `their references are not review finding ids: ${refs.join(', ')}`
|
|
359
|
+
return new BuildControlError(
|
|
360
|
+
'no-cited-findings',
|
|
361
|
+
`cannot dismiss findings for build "${slug}": ${detail}. ` +
|
|
362
|
+
`Use ab answer ${slug} <text> for guidance, or ab answer ${slug} for a bare retry.`,
|
|
363
|
+
)
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export interface ControlBuildOpts {
|
|
367
|
+
store: BuildStore
|
|
368
|
+
/** Canonical main-repository identity stored on BuildRecord.repo. */
|
|
369
|
+
repo: string
|
|
370
|
+
slug: string
|
|
371
|
+
/** Explicit identity for non-CLI callers. CLI adapters may supply env. */
|
|
372
|
+
user?: string
|
|
373
|
+
env?: Record<string, string | undefined>
|
|
374
|
+
/** Delegated-write attribution marker threaded onto every event actor. */
|
|
375
|
+
via?: Via
|
|
376
|
+
action: BuildControlAction
|
|
377
|
+
/** Injected only for --revise-spec-from-ticket. */
|
|
378
|
+
readTicketBody?: (ref: TicketRef) => Promise<string>
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Apply one control against a freshly reduced build log. */
|
|
382
|
+
export async function controlBuild(opts: ControlBuildOpts): Promise<BuildControlResult> {
|
|
383
|
+
const record = await opts.store.getBuild(opts.slug)
|
|
384
|
+
if (record === null) {
|
|
385
|
+
throw new BuildControlError(
|
|
386
|
+
'not-found',
|
|
387
|
+
`no build "${opts.slug}" in this store; run 'ab builds --all' to list builds`,
|
|
388
|
+
)
|
|
389
|
+
}
|
|
390
|
+
if (record.repo !== opts.repo) {
|
|
391
|
+
throw new BuildControlError(
|
|
392
|
+
'wrong-repository',
|
|
393
|
+
`build "${opts.slug}" belongs to repository "${record.repo}", ` + `not "${opts.repo}"`,
|
|
394
|
+
)
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const events = await opts.store.getEvents(opts.slug)
|
|
398
|
+
const state = reduceBuild(events)
|
|
399
|
+
const user = opts.user?.trim() || buildControlUser(opts.env ?? {})
|
|
400
|
+
|
|
401
|
+
if (opts.action.kind === 'discard') {
|
|
402
|
+
if (state.status !== 'queued') {
|
|
403
|
+
throw new BuildControlError(
|
|
404
|
+
'inactive',
|
|
405
|
+
`build "${opts.slug}" cannot be discarded (status: ${state.status}); discard requires queued`,
|
|
406
|
+
)
|
|
407
|
+
}
|
|
408
|
+
const existing = events.findLast((event) => event.type === 'build.discard-requested')
|
|
409
|
+
if (existing !== undefined) {
|
|
410
|
+
return { kind: 'command', slug: opts.slug, command: 'discard', event: existing }
|
|
411
|
+
}
|
|
412
|
+
return appendCommand(opts.store, opts.slug, user, 'discard', opts.via)
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (opts.action.kind === 'abort') {
|
|
416
|
+
if (state.status !== 'queued') activeState(opts.slug, state)
|
|
417
|
+
const existing = events.findLast((event) => event.type === 'build.abort-requested')
|
|
418
|
+
if (existing !== undefined) {
|
|
419
|
+
return { kind: 'command', slug: opts.slug, command: 'abort', event: existing }
|
|
420
|
+
}
|
|
421
|
+
return appendCommand(opts.store, opts.slug, user, 'abort', opts.via)
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
activeState(opts.slug, state)
|
|
425
|
+
switch (opts.action.kind) {
|
|
426
|
+
case 'pause':
|
|
427
|
+
case 'resume':
|
|
428
|
+
case 'auto-merge-on':
|
|
429
|
+
case 'auto-merge-off':
|
|
430
|
+
return appendCommand(opts.store, opts.slug, user, opts.action.kind, opts.via)
|
|
431
|
+
|
|
432
|
+
case 'dashboard-pause': {
|
|
433
|
+
const pendingPause = state.pendingCommands.some((command) => command.command === 'pause')
|
|
434
|
+
if (state.status !== 'running') {
|
|
435
|
+
throw new BuildControlError(
|
|
436
|
+
'inactive',
|
|
437
|
+
`build "${opts.slug}" cannot use dashboard pause (status: ${state.status}); ` +
|
|
438
|
+
'dashboard pause requires running or a pending pause',
|
|
439
|
+
)
|
|
440
|
+
}
|
|
441
|
+
// The reducer already makes an opposing command supersede an
|
|
442
|
+
// unacknowledged request. Reuse that durable rule to cancel PAUSING.
|
|
443
|
+
return appendCommand(opts.store, opts.slug, user, pendingPause ? 'resume' : 'pause', opts.via)
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
case 'dashboard-resume': {
|
|
447
|
+
// A blocker takes precedence over pause state in the operator flow. The
|
|
448
|
+
// prompt itself is process-local and writes nothing until submitted.
|
|
449
|
+
if (state.openEscalations.length > 0) {
|
|
450
|
+
return {
|
|
451
|
+
kind: 'answer-required',
|
|
452
|
+
slug: opts.slug,
|
|
453
|
+
escalationIds: state.openEscalations.map((item) => item.id),
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
const pendingResume = state.pendingCommands.some((command) => command.command === 'resume')
|
|
457
|
+
if (state.status !== 'paused' || pendingResume) {
|
|
458
|
+
throw new BuildControlError(
|
|
459
|
+
'inactive',
|
|
460
|
+
`build "${opts.slug}" cannot use dashboard resume (status: ${state.status}); ` +
|
|
461
|
+
'dashboard resume requires paused with no pending resume',
|
|
462
|
+
)
|
|
463
|
+
}
|
|
464
|
+
return appendCommand(opts.store, opts.slug, user, 'resume', opts.via)
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
case 'toggle-auto-merge':
|
|
468
|
+
return appendCommand(
|
|
469
|
+
opts.store,
|
|
470
|
+
opts.slug,
|
|
471
|
+
user,
|
|
472
|
+
state.autoMerge.requested ? 'auto-merge-off' : 'auto-merge-on',
|
|
473
|
+
opts.via,
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
case 'answer': {
|
|
477
|
+
const captured =
|
|
478
|
+
opts.action.escalationIds === undefined ? undefined : new Set(opts.action.escalationIds)
|
|
479
|
+
const guidance = (opts.action.text ?? '').trim()
|
|
480
|
+
const actor = humanActor(user, opts.via)
|
|
481
|
+
const requestedCeiling = opts.action.reviewRoundCeiling
|
|
482
|
+
if (
|
|
483
|
+
requestedCeiling !== undefined &&
|
|
484
|
+
(!Number.isInteger(requestedCeiling) || requestedCeiling <= 0)
|
|
485
|
+
) {
|
|
486
|
+
throw new BuildControlError(
|
|
487
|
+
'review-round-ceiling-unavailable',
|
|
488
|
+
'review round ceiling must be a positive integer; nothing was recorded',
|
|
489
|
+
)
|
|
490
|
+
}
|
|
491
|
+
if (requestedCeiling !== undefined && opts.action.resolve?.kind === 'revise-spec') {
|
|
492
|
+
throw new BuildControlError(
|
|
493
|
+
'incompatible-answer-options',
|
|
494
|
+
'cannot combine a review round ceiling with a spec revision: revision resets the loop round budget on its own; nothing was recorded',
|
|
495
|
+
)
|
|
496
|
+
}
|
|
497
|
+
if (requestedCeiling !== undefined && opts.action.resolve?.kind === 'dismiss-finding') {
|
|
498
|
+
throw new BuildControlError(
|
|
499
|
+
'incompatible-answer-options',
|
|
500
|
+
'cannot combine a review round ceiling with --dismiss; use guidance or a bare retry to answer the review-round-limit escalation; nothing was recorded',
|
|
501
|
+
)
|
|
502
|
+
}
|
|
503
|
+
const ceilingEscalation =
|
|
504
|
+
requestedCeiling === undefined
|
|
505
|
+
? undefined
|
|
506
|
+
: reviewRoundLimitEscalation(opts.slug, selectedOpen(state, captured))
|
|
507
|
+
|
|
508
|
+
if (opts.action.resolve?.kind === 'revise-spec') {
|
|
509
|
+
const initial = decideRevise(opts.slug, state, captured)
|
|
510
|
+
if (initial.kind === 'refuse') throwRefusal(initial)
|
|
511
|
+
|
|
512
|
+
let artifact: ArtifactRef
|
|
513
|
+
let authorizedEarlier = initial.kind === 'complete'
|
|
514
|
+
if (initial.kind === 'complete') {
|
|
515
|
+
// Completion rule: recorded authorization wins before the caller's
|
|
516
|
+
// lazy body source is opened, regardless of remaining blockers.
|
|
517
|
+
artifact = initial.artifact
|
|
518
|
+
} else {
|
|
519
|
+
let body: string
|
|
520
|
+
const source = opts.action.resolve.body
|
|
521
|
+
if (source.kind === 'supplied') {
|
|
522
|
+
body = await source.read()
|
|
523
|
+
} else {
|
|
524
|
+
if (record.ticket === undefined) {
|
|
525
|
+
throw new BuildControlError(
|
|
526
|
+
'ticket-unavailable',
|
|
527
|
+
`build "${opts.slug}" has no recorded ticket to re-import`,
|
|
528
|
+
)
|
|
529
|
+
}
|
|
530
|
+
if (opts.readTicketBody === undefined) {
|
|
531
|
+
throw new BuildControlError(
|
|
532
|
+
'ticket-unavailable',
|
|
533
|
+
'ticket body reader is unavailable for --revise-spec-from-ticket',
|
|
534
|
+
)
|
|
535
|
+
}
|
|
536
|
+
body = await opts.readTicketBody(record.ticket)
|
|
537
|
+
}
|
|
538
|
+
const conformance = specConformance(body)
|
|
539
|
+
if (!conformance.conforms) {
|
|
540
|
+
throw new BuildControlError(
|
|
541
|
+
'spec-nonconforming',
|
|
542
|
+
`replacement spec from ${source.kind === 'supplied' ? source.origin : 'the build ticket'} ` +
|
|
543
|
+
`does not conform: missing ${conformance.missing.join('; ')}; ` +
|
|
544
|
+
'nothing was recorded and the build remains blocked',
|
|
545
|
+
)
|
|
546
|
+
}
|
|
547
|
+
// A deposit without a later human authorization is deliberately inert:
|
|
548
|
+
// every consumer reads the revision anchored by a spec.* event.
|
|
549
|
+
const deposited = await opts.store.putArtifact(opts.slug, {
|
|
550
|
+
kind: 'spec',
|
|
551
|
+
content: body,
|
|
552
|
+
...(record.ticket !== undefined
|
|
553
|
+
? { metadata: { ticket: record.ticket.id, source: record.ticket.source } }
|
|
554
|
+
: {}),
|
|
555
|
+
})
|
|
556
|
+
artifact = { kind: 'spec', rev: deposited.revision }
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Re-read at the last durable moment. The PR janitor and abort
|
|
560
|
+
// acknowledgement may still write while a blocked build is parked.
|
|
561
|
+
const freshState = reduceBuild(await opts.store.getEvents(opts.slug))
|
|
562
|
+
assertStillActive(opts.slug, freshState)
|
|
563
|
+
const decision = decideRevise(opts.slug, freshState, captured)
|
|
564
|
+
if (decision.kind === 'refuse') throwRefusal(decision)
|
|
565
|
+
if (decision.kind === 'complete') {
|
|
566
|
+
artifact = decision.artifact
|
|
567
|
+
authorizedEarlier = true
|
|
568
|
+
}
|
|
569
|
+
const answer = guidance === '' ? SPEC_REVISION_ANSWER : guidance
|
|
570
|
+
const alsoPaused = freshState.status === 'paused'
|
|
571
|
+
|
|
572
|
+
// Answers must precede spec.revised: the engine parks an answered
|
|
573
|
+
// revise-spec escalation at awaiting-spec until this kernel fact lands.
|
|
574
|
+
for (const escalation of decision.open) {
|
|
575
|
+
await opts.store.append(opts.slug, {
|
|
576
|
+
actor,
|
|
577
|
+
type: 'escalation.answered',
|
|
578
|
+
payload: {
|
|
579
|
+
id: escalation.id,
|
|
580
|
+
answer,
|
|
581
|
+
resolution: 'revise-spec',
|
|
582
|
+
artifact,
|
|
583
|
+
},
|
|
584
|
+
})
|
|
585
|
+
}
|
|
586
|
+
await opts.store.append(opts.slug, {
|
|
587
|
+
actor: KERNEL,
|
|
588
|
+
type: 'spec.revised',
|
|
589
|
+
payload: { artifact, escalation: decision.escalationSeq },
|
|
590
|
+
})
|
|
591
|
+
if (alsoPaused) {
|
|
592
|
+
await opts.store.append(opts.slug, {
|
|
593
|
+
actor,
|
|
594
|
+
type: 'build.resume-requested',
|
|
595
|
+
payload: {},
|
|
596
|
+
})
|
|
597
|
+
}
|
|
598
|
+
const after = reduceBuild(await opts.store.getEvents(opts.slug))
|
|
599
|
+
const signal = terminalSignal(after)
|
|
600
|
+
return {
|
|
601
|
+
kind: 'answered',
|
|
602
|
+
slug: opts.slug,
|
|
603
|
+
count: decision.open.length,
|
|
604
|
+
resolution: 'revise-spec',
|
|
605
|
+
resumed: alsoPaused,
|
|
606
|
+
...(after.specRev !== undefined ? { specRev: after.specRev } : {}),
|
|
607
|
+
...(authorizedEarlier ? { authorizedEarlier: true } : {}),
|
|
608
|
+
...(signal !== undefined ? { terminalSignal: signal } : {}),
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
if (opts.action.resolve?.kind === 'dismiss-finding') {
|
|
613
|
+
if (hasPendingAbort(state)) throw abortPending(opts.slug)
|
|
614
|
+
const initialOpen = selectedOpen(state, captured)
|
|
615
|
+
if (initialOpen.length === 0) {
|
|
616
|
+
throw new BuildControlError(
|
|
617
|
+
'no-open-escalations',
|
|
618
|
+
captured === undefined
|
|
619
|
+
? `build "${opts.slug}" has no open escalations to answer`
|
|
620
|
+
: `build "${opts.slug}" is no longer blocked by the captured escalation(s)`,
|
|
621
|
+
)
|
|
622
|
+
}
|
|
623
|
+
const initial = dismissPartition(state, captured)
|
|
624
|
+
if (initial.eligible.length === 0) throw noCitedFindings(opts.slug, initialOpen)
|
|
625
|
+
|
|
626
|
+
const freshState = reduceBuild(await opts.store.getEvents(opts.slug))
|
|
627
|
+
assertStillActive(opts.slug, freshState)
|
|
628
|
+
if (hasPendingAbort(freshState)) throw abortPending(opts.slug)
|
|
629
|
+
const freshOpen = selectedOpen(freshState, captured)
|
|
630
|
+
if (freshOpen.length === 0) {
|
|
631
|
+
throw new BuildControlError(
|
|
632
|
+
'no-open-escalations',
|
|
633
|
+
`build "${opts.slug}" has no open escalations to answer`,
|
|
634
|
+
)
|
|
635
|
+
}
|
|
636
|
+
const partition = dismissPartition(freshState, captured)
|
|
637
|
+
if (partition.eligible.length === 0) throw noCitedFindings(opts.slug, freshOpen)
|
|
638
|
+
const answer = guidance === '' ? DISMISSAL_ANSWER : guidance
|
|
639
|
+
for (const escalation of partition.eligible) {
|
|
640
|
+
await opts.store.append(opts.slug, {
|
|
641
|
+
actor,
|
|
642
|
+
type: 'escalation.answered',
|
|
643
|
+
payload: { id: escalation.id, answer, resolution: 'dismiss-finding' },
|
|
644
|
+
})
|
|
645
|
+
}
|
|
646
|
+
const resumed = freshState.status === 'paused' && partition.ineligible.length === 0
|
|
647
|
+
if (resumed) {
|
|
648
|
+
await opts.store.append(opts.slug, {
|
|
649
|
+
actor,
|
|
650
|
+
type: 'build.resume-requested',
|
|
651
|
+
payload: {},
|
|
652
|
+
})
|
|
653
|
+
}
|
|
654
|
+
const after = reduceBuild(await opts.store.getEvents(opts.slug))
|
|
655
|
+
const signal = terminalSignal(after)
|
|
656
|
+
return {
|
|
657
|
+
kind: 'answered',
|
|
658
|
+
slug: opts.slug,
|
|
659
|
+
count: partition.eligible.length,
|
|
660
|
+
resolution: 'dismiss-finding',
|
|
661
|
+
resumed,
|
|
662
|
+
remainingOpen: partition.ineligible.length,
|
|
663
|
+
...(signal !== undefined ? { terminalSignal: signal } : {}),
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
const open = selectedOpen(state, captured)
|
|
668
|
+
if (open.length === 0) {
|
|
669
|
+
throw new BuildControlError(
|
|
670
|
+
'no-open-escalations',
|
|
671
|
+
captured === undefined
|
|
672
|
+
? `build "${opts.slug}" has no open escalations to answer`
|
|
673
|
+
: `build "${opts.slug}" is no longer blocked by the captured escalation(s)`,
|
|
674
|
+
)
|
|
675
|
+
}
|
|
676
|
+
const resolution = guidance === '' ? 'retry' : 'guidance'
|
|
677
|
+
const answer = guidance === '' ? BARE_RETRY_ANSWER : guidance
|
|
678
|
+
const alsoPaused = state.status === 'paused'
|
|
679
|
+
// Appends are intentionally one-at-a-time. If an append fails midway, a
|
|
680
|
+
// retry re-reduces the log and filters already answered ids out.
|
|
681
|
+
for (const escalation of open) {
|
|
682
|
+
await opts.store.append(opts.slug, {
|
|
683
|
+
actor,
|
|
684
|
+
type: 'escalation.answered',
|
|
685
|
+
payload: {
|
|
686
|
+
id: escalation.id,
|
|
687
|
+
answer,
|
|
688
|
+
resolution,
|
|
689
|
+
...(ceilingEscalation?.id === escalation.id
|
|
690
|
+
? { reviewRoundCeiling: requestedCeiling }
|
|
691
|
+
: {}),
|
|
692
|
+
},
|
|
693
|
+
})
|
|
694
|
+
}
|
|
695
|
+
if (alsoPaused) {
|
|
696
|
+
await opts.store.append(opts.slug, {
|
|
697
|
+
actor,
|
|
698
|
+
type: 'build.resume-requested',
|
|
699
|
+
payload: {},
|
|
700
|
+
})
|
|
701
|
+
}
|
|
702
|
+
return {
|
|
703
|
+
kind: 'answered',
|
|
704
|
+
slug: opts.slug,
|
|
705
|
+
count: open.length,
|
|
706
|
+
resolution,
|
|
707
|
+
resumed: alsoPaused,
|
|
708
|
+
...(ceilingEscalation !== undefined && requestedCeiling !== undefined
|
|
709
|
+
? {
|
|
710
|
+
reviewRoundCeiling: {
|
|
711
|
+
loop:
|
|
712
|
+
ceilingEscalation.phase === 'plan-review' ? ('plan' as const) : ('code' as const),
|
|
713
|
+
value: requestedCeiling,
|
|
714
|
+
},
|
|
715
|
+
}
|
|
716
|
+
: {}),
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function actionLabel(action: BuildControlAction): string {
|
|
723
|
+
switch (action.kind) {
|
|
724
|
+
case 'auto-merge-on':
|
|
725
|
+
return 'enable auto-merge for'
|
|
726
|
+
case 'auto-merge-off':
|
|
727
|
+
return 'disable auto-merge for'
|
|
728
|
+
case 'toggle-auto-merge':
|
|
729
|
+
return 'toggle auto-merge for'
|
|
730
|
+
case 'dashboard-pause':
|
|
731
|
+
return 'pause or cancel pause for'
|
|
732
|
+
case 'dashboard-resume':
|
|
733
|
+
return 'resume'
|
|
734
|
+
case 'discard':
|
|
735
|
+
return 'discard'
|
|
736
|
+
case 'answer':
|
|
737
|
+
return 'answer'
|
|
738
|
+
default:
|
|
739
|
+
return action.kind
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/** A phase agent may operate on another build, but never control its own. */
|
|
744
|
+
export function refuseOwnSessionControl(
|
|
745
|
+
slug: string,
|
|
746
|
+
action: BuildControlAction,
|
|
747
|
+
env: Record<string, string | undefined>,
|
|
748
|
+
): void {
|
|
749
|
+
const session = env.AB_SESSION?.trim()
|
|
750
|
+
const build = env.AB_BUILD?.trim()
|
|
751
|
+
if (session === undefined || session === '' || build !== slug) return
|
|
752
|
+
throw new BuildControlError(
|
|
753
|
+
'own-session',
|
|
754
|
+
`cannot ${actionLabel(action)} build "${slug}" from its own phase session ` +
|
|
755
|
+
'(AB_SESSION/AB_BUILD conflict); run this command outside that build session',
|
|
756
|
+
)
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
export interface AbBuildControlOpts {
|
|
760
|
+
targetRepo: string
|
|
761
|
+
env: Record<string, string | undefined>
|
|
762
|
+
exec: Exec
|
|
763
|
+
slug: string
|
|
764
|
+
action: BuildControlAction
|
|
765
|
+
/** Explicit `--store`; selection remains --store > AB_STORE > repo-local. */
|
|
766
|
+
storeRef?: string
|
|
767
|
+
/** Injected only for --revise-spec-from-ticket. */
|
|
768
|
+
readTicketBody?: (ref: TicketRef) => Promise<string>
|
|
769
|
+
/** Injectable adapter seam for unit tests. */
|
|
770
|
+
openStore?: StoreOpener
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/** Sessionless command shell: resolve repository/store, control, always close. */
|
|
774
|
+
export async function abBuildControl(opts: AbBuildControlOpts): Promise<BuildControlResult> {
|
|
775
|
+
// Do this before opening a store: the conflict is ambient and no read is
|
|
776
|
+
// needed to know that an own-phase control attempt is forbidden.
|
|
777
|
+
refuseOwnSessionControl(opts.slug, opts.action, opts.env)
|
|
778
|
+
|
|
779
|
+
return withSessionlessStore(opts, ({ store, repo }) =>
|
|
780
|
+
controlBuild({
|
|
781
|
+
store,
|
|
782
|
+
repo,
|
|
783
|
+
slug: opts.slug,
|
|
784
|
+
env: opts.env,
|
|
785
|
+
action: opts.action,
|
|
786
|
+
...(opts.readTicketBody !== undefined ? { readTicketBody: opts.readTicketBody } : {}),
|
|
787
|
+
}),
|
|
788
|
+
)
|
|
789
|
+
}
|