@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,55 @@
|
|
|
1
|
+
import type { BuildState } from '../kernel/reducer'
|
|
2
|
+
import type { BuildRecord } from '../store/types'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Event silence must be long enough to distinguish ordinary agent turns from
|
|
6
|
+
* durable progress that has fallen behind a renewing runner lease.
|
|
7
|
+
*/
|
|
8
|
+
export const PROGRESS_DIVERGENCE_MS = 60 * 60 * 1000
|
|
9
|
+
|
|
10
|
+
/** Exact, JSON-safe inputs for the presentation-only progress projection. */
|
|
11
|
+
export interface BuildProgress {
|
|
12
|
+
lastEventAt?: string
|
|
13
|
+
heartbeatAt?: string
|
|
14
|
+
leaseExpiresAt?: string
|
|
15
|
+
/** Reducer-terminal even when abort cleanup keeps a dashboard row visible. */
|
|
16
|
+
terminal: boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Project only retained facts. Relative ages and divergence depend on render time. */
|
|
20
|
+
export function buildProgress(record: BuildRecord, state: BuildState): BuildProgress {
|
|
21
|
+
return {
|
|
22
|
+
...(state.lastEvent !== undefined ? { lastEventAt: state.lastEvent.ts } : {}),
|
|
23
|
+
...(record.heartbeatAt !== undefined ? { heartbeatAt: record.heartbeatAt } : {}),
|
|
24
|
+
...(record.lease !== undefined ? { leaseExpiresAt: record.lease.expiresAt } : {}),
|
|
25
|
+
terminal: state.status === 'done' || state.status === 'aborted',
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function timestamp(value: string | undefined): number | undefined {
|
|
30
|
+
if (value === undefined) return undefined
|
|
31
|
+
const parsed = Date.parse(value)
|
|
32
|
+
return Number.isFinite(parsed) ? parsed : undefined
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* True only for a nonterminal build with a strictly live lease whose renewing
|
|
37
|
+
* heartbeat is at least one hour newer than its latest durable event.
|
|
38
|
+
*/
|
|
39
|
+
export function isDiverged(progress: BuildProgress, now: Date | number): boolean {
|
|
40
|
+
if (progress.terminal) return false
|
|
41
|
+
const lastEventAt = timestamp(progress.lastEventAt)
|
|
42
|
+
const heartbeatAt = timestamp(progress.heartbeatAt)
|
|
43
|
+
const leaseExpiresAt = timestamp(progress.leaseExpiresAt)
|
|
44
|
+
const nowMs = typeof now === 'number' ? now : now.getTime()
|
|
45
|
+
if (
|
|
46
|
+
lastEventAt === undefined ||
|
|
47
|
+
heartbeatAt === undefined ||
|
|
48
|
+
leaseExpiresAt === undefined ||
|
|
49
|
+
!Number.isFinite(nowMs) ||
|
|
50
|
+
leaseExpiresAt <= nowMs
|
|
51
|
+
) {
|
|
52
|
+
return false
|
|
53
|
+
}
|
|
54
|
+
return heartbeatAt - lastEventAt >= PROGRESS_DIVERGENCE_MS
|
|
55
|
+
}
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repository-wide quiescence: one keypress that parks every running build and
|
|
3
|
+
* stops intake, and one that reverses it.
|
|
4
|
+
*
|
|
5
|
+
* This is deliberately NOT a per-build control, so it does not live in
|
|
6
|
+
* `build-control.ts`. Two contracts differ at their core:
|
|
7
|
+
*
|
|
8
|
+
* - `controlBuild` throws a typed `BuildControlError` when its one target is
|
|
9
|
+
* inapplicable. A bulk walk crosses builds the operator never named, so an
|
|
10
|
+
* ineligible build is SKIPPED silently instead.
|
|
11
|
+
* - `controlBuild`'s `dashboard-pause` deliberately toggles, appending a
|
|
12
|
+
* `resume` to cancel a pending pause. The bulk control must NEVER cancel a
|
|
13
|
+
* pending pause — a build already on its way to `paused` is exactly the state
|
|
14
|
+
* the operator is asking for, so it is skipped, not reversed.
|
|
15
|
+
*
|
|
16
|
+
* Every per-build write is a compare-and-set against the reduced stream tail.
|
|
17
|
+
* The walk reduces a stream and then writes to it, and nothing serializes those
|
|
18
|
+
* two operations against the rest of the world: the in-process runner fleet is
|
|
19
|
+
* fire-and-forget, a second `ab dispatch` may hold a dashboard, and
|
|
20
|
+
* `ab pause <slug>` runs sessionless. An unconditional append could add a
|
|
21
|
+
* second entry to `pending.pause` (the reducer pushes rather than replaces, so
|
|
22
|
+
* "exactly one pending pause" is observable), or write a pause to a build a
|
|
23
|
+
* runner has already acknowledged to `paused`, or completed. So each build goes
|
|
24
|
+
* through `appendIfCurrent` and retries on a miss, re-reading and re-evaluating
|
|
25
|
+
* eligibility every attempt — the idiom `recordAutoMergeDeferralObservation`
|
|
26
|
+
* already establishes in this codebase.
|
|
27
|
+
*
|
|
28
|
+
* The PAUSE FACT is written FIRST and ABSOLUTELY, with intake immediately
|
|
29
|
+
* after it. `dispatcher.pause-set` is the repository's quiescence boundary —
|
|
30
|
+
* it is what holds every queued build across the dispatcher's recovery,
|
|
31
|
+
* startup-resume, lease-sweep, and dispatch stages — while intake gates only
|
|
32
|
+
* the ready-ticket scan. Ordering them this way gives the prefix property that
|
|
33
|
+
* matters: every surviving prefix of a pause-all leaves the repository at
|
|
34
|
+
* least as quiesced as the operator asked for, and no prefix can leave a
|
|
35
|
+
* queued build launchable once any write has landed. Under the reverse order,
|
|
36
|
+
* an intake write that landed before a failing pause write would leave exactly
|
|
37
|
+
* the hole this control exists to close — recovery, startup resume, and the
|
|
38
|
+
* sweep all ignore intake and would still hand a queued build a runner. Resume
|
|
39
|
+
* benefits symmetrically: a pause-cleared/intake-still-off prefix is the
|
|
40
|
+
* conservative half, where the reverse order would re-open intake while every
|
|
41
|
+
* build stayed held.
|
|
42
|
+
*
|
|
43
|
+
* Both are written ABSOLUTELY — an unconditional append rather than a
|
|
44
|
+
* read-then-write on a difference — because they are setters, not toggles:
|
|
45
|
+
* last-write-wins is race-free and is exactly what the operator asked for, at
|
|
46
|
+
* the cost of one redundant repository event when the value already held.
|
|
47
|
+
*
|
|
48
|
+
* This is ordering, not atomicity. The per-build walk that follows is still N
|
|
49
|
+
* separate CAS writes, so a partial bulk pause remains representable and a
|
|
50
|
+
* failure midway leaves some running builds un-requested. The recovery is a
|
|
51
|
+
* second keypress, which the CAS loop and the predicates make idempotent: a
|
|
52
|
+
* build that already carries a pending pause fails the same predicate and is
|
|
53
|
+
* skipped.
|
|
54
|
+
*
|
|
55
|
+
* Eligibility is expressed against `BuildState` rather than by importing
|
|
56
|
+
* `effectiveStatus` from `dashboard/model.ts`, whose contract is
|
|
57
|
+
* "DISPLAY-ONLY — nothing consults this"; a durable write path must not become
|
|
58
|
+
* its first consumer. The predicates are nevertheless defined to coincide with
|
|
59
|
+
* what the operator sees, and a unit test pins that agreement.
|
|
60
|
+
*/
|
|
61
|
+
import { humanActor, type Actor, type Via } from '../events/envelope'
|
|
62
|
+
import { reduceBuild, type BuildState } from '../kernel/reducer'
|
|
63
|
+
import type { Exec } from '../ports/workspace/git-worktree'
|
|
64
|
+
import type { BuildRecord, BuildStore } from '../store/types'
|
|
65
|
+
import { BuildControlError, buildControlUser } from './build-control'
|
|
66
|
+
import { withSessionlessStore, type StoreOpener } from './store-opening'
|
|
67
|
+
|
|
68
|
+
export type BulkDirection = 'pause' | 'resume'
|
|
69
|
+
|
|
70
|
+
export interface BulkControlSummary {
|
|
71
|
+
direction: BulkDirection
|
|
72
|
+
/** Slugs that received a durable request, in the order written. */
|
|
73
|
+
slugs: string[]
|
|
74
|
+
/** The durable repository-wide hold value this action wrote. */
|
|
75
|
+
paused: boolean
|
|
76
|
+
/** The durable intake value this action wrote. */
|
|
77
|
+
intake: boolean
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Pausable for the bulk control: actually running, with neither pause nor
|
|
82
|
+
* abort intent already in flight. The pending-pause exclusion is the "never
|
|
83
|
+
* cancels a pending pause / still exactly one pending pause" rule, and it also
|
|
84
|
+
* means a row displaying `PAUSING` is left alone. Abort intent dominates pause
|
|
85
|
+
* regardless of whether it came from an explicit request or an escalation
|
|
86
|
+
* answer, so a row displaying `ABORTING` is also left alone. `queued`, `paused`,
|
|
87
|
+
* `blocked`, `done`, and `aborted` all fail the first clause, so they are
|
|
88
|
+
* skipped with no error. A `running` build carrying a pending *resume* (a
|
|
89
|
+
* just-cancelled pause) is eligible: the reducer clears `pending.resume` when
|
|
90
|
+
* the pause request lands, which is the existing opposing-command
|
|
91
|
+
* supersession.
|
|
92
|
+
*/
|
|
93
|
+
export function bulkPausable(state: BuildState): boolean {
|
|
94
|
+
if (state.status !== 'running') return false
|
|
95
|
+
return !state.pendingCommands.some(
|
|
96
|
+
(command) => command.command === 'pause' || command.command === 'abort',
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Resumable for the bulk control: paused, unblocked, with no resume in flight.
|
|
102
|
+
* The escalation clause is what keeps bulk resume off display-`BLOCKED` builds:
|
|
103
|
+
* §15.5 gives `paused` precedence in the reducer, so a paused-and-blocked build
|
|
104
|
+
* reduces to `paused` while the dashboard shows `BLOCKED`. Clearing a blocker
|
|
105
|
+
* stays the per-build `r` prompt with its feedback field. Reducer-status
|
|
106
|
+
* `blocked` fails the first clause.
|
|
107
|
+
*/
|
|
108
|
+
export function bulkResumable(state: BuildState): boolean {
|
|
109
|
+
if (state.status !== 'paused') return false
|
|
110
|
+
if (state.openEscalations.length > 0) return false
|
|
111
|
+
return !state.pendingCommands.some((command) => command.command === 'resume')
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function eligible(state: BuildState, direction: BulkDirection): boolean {
|
|
115
|
+
return direction === 'pause' ? bulkPausable(state) : bulkResumable(state)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Request one direction against one build, compare-and-set. Returns whether a
|
|
120
|
+
* request was durably written.
|
|
121
|
+
*
|
|
122
|
+
* The read/reduce/evaluate lives INSIDE the loop on purpose: hoisting it would
|
|
123
|
+
* reintroduce exactly the stale-state defect the loop exists to remove. Any
|
|
124
|
+
* competing write that pauses, resumes, acknowledges, or terminates the build
|
|
125
|
+
* ends the loop by making the predicate false; only an unrelated append to a
|
|
126
|
+
* still-eligible build causes a true retry.
|
|
127
|
+
*/
|
|
128
|
+
async function requestOne(
|
|
129
|
+
store: BuildStore,
|
|
130
|
+
slug: string,
|
|
131
|
+
actor: Actor,
|
|
132
|
+
direction: BulkDirection,
|
|
133
|
+
): Promise<boolean> {
|
|
134
|
+
while (true) {
|
|
135
|
+
const state = reduceBuild(await store.getEvents(slug))
|
|
136
|
+
if (!eligible(state, direction)) return false
|
|
137
|
+
// The two branches are written out rather than selecting the event type
|
|
138
|
+
// into a variable so `appendIfCurrent`'s `T extends EventType` infers a
|
|
139
|
+
// concrete payload type in each — the shape `build-control.ts`'s own
|
|
140
|
+
// `appendCommand` switch uses.
|
|
141
|
+
const appended =
|
|
142
|
+
direction === 'pause'
|
|
143
|
+
? await store.appendIfCurrent(slug, state.lastSeq, {
|
|
144
|
+
actor,
|
|
145
|
+
type: 'build.pause-requested',
|
|
146
|
+
payload: {},
|
|
147
|
+
})
|
|
148
|
+
: await store.appendIfCurrent(slug, state.lastSeq, {
|
|
149
|
+
actor,
|
|
150
|
+
type: 'build.resume-requested',
|
|
151
|
+
payload: {},
|
|
152
|
+
})
|
|
153
|
+
if (appended !== null) return true
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export interface BulkControlOpts {
|
|
158
|
+
store: BuildStore
|
|
159
|
+
/** Canonical main-repository identity stored on BuildRecord.repo. */
|
|
160
|
+
repo: string
|
|
161
|
+
/** Explicit identity for API callers; CLI/dashboard may retain env. */
|
|
162
|
+
user?: string
|
|
163
|
+
env?: Record<string, string | undefined>
|
|
164
|
+
/** Delegated-write attribution marker threaded onto the actor. */
|
|
165
|
+
via?: Via
|
|
166
|
+
direction: BulkDirection
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** What a walk had already done when it failed. Every field is derived from the
|
|
170
|
+
* walk's own position, so a failure report cannot disagree with what was
|
|
171
|
+
* actually attempted. */
|
|
172
|
+
export interface BulkControlProgress {
|
|
173
|
+
direction: BulkDirection
|
|
174
|
+
/** The repository-wide hold value the walk was asked to write. */
|
|
175
|
+
paused: boolean
|
|
176
|
+
pausedWritten: boolean
|
|
177
|
+
/** The intake value the walk was asked to write. */
|
|
178
|
+
intake: boolean
|
|
179
|
+
intakeWritten: boolean
|
|
180
|
+
/** Slugs that received a durable request before the failure, in write order. */
|
|
181
|
+
slugs: string[]
|
|
182
|
+
/** The build whose request failed; absent when the walk failed before any. */
|
|
183
|
+
failedSlug?: string
|
|
184
|
+
/** Candidate slugs the walk never reached. */
|
|
185
|
+
remaining: string[]
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* A walk that failed partway, carrying what it had already written.
|
|
190
|
+
*
|
|
191
|
+
* `message` is deliberately EXACTLY the cause's message: the dashboard's
|
|
192
|
+
* `queueAction` catch renders `dashboard bulk-pause action failed:
|
|
193
|
+
* ${error.message}`, and that announcement must stay byte-identical. A surface
|
|
194
|
+
* that wants the partial progress reads `progress` instead.
|
|
195
|
+
*/
|
|
196
|
+
export class BulkWalkError extends Error {
|
|
197
|
+
readonly progress: BulkControlProgress
|
|
198
|
+
|
|
199
|
+
constructor(cause: unknown, progress: BulkControlProgress) {
|
|
200
|
+
super(cause instanceof Error ? cause.message : String(cause), { cause })
|
|
201
|
+
this.name = 'BulkWalkError'
|
|
202
|
+
this.progress = progress
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** One operator-facing line describing what a bulk action durably did. */
|
|
207
|
+
export function bulkControlReport(summary: BulkControlSummary): string {
|
|
208
|
+
const label = summary.direction === 'pause' ? 'pause all' : 'resume all'
|
|
209
|
+
// Clauses follow write order. The hold clause is not decorative: in the
|
|
210
|
+
// zero-build case it is the only thing the action did, and it is the fact
|
|
211
|
+
// that makes the control trustworthy.
|
|
212
|
+
const hold = summary.paused ? 'queued builds held' : 'queued builds released'
|
|
213
|
+
const intake = `intake ${summary.intake ? 'ON' : 'OFF'}`
|
|
214
|
+
if (summary.slugs.length === 0) {
|
|
215
|
+
const nothing = summary.direction === 'pause' ? 'no pausable builds' : 'no paused builds'
|
|
216
|
+
return `${label}: ${nothing}; ${hold}; ${intake}`
|
|
217
|
+
}
|
|
218
|
+
const count = summary.slugs.length === 1 ? '1 build' : `${summary.slugs.length} builds`
|
|
219
|
+
const verb = summary.direction === 'pause' ? 'pause requested' : 'resume requested'
|
|
220
|
+
return `${label}: ${verb} for ${count}; ${hold}; ${intake}`
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Put the repository into (or out of) quiescence. The repository-wide hold is
|
|
225
|
+
* set first, then intake, then every eligible build is requested one at a time.
|
|
226
|
+
*
|
|
227
|
+
* A per-build write that fails outright leaves both repository facts already
|
|
228
|
+
* set and some builds requested. That is not transactional and the store offers
|
|
229
|
+
* no way to make it so; the recovery is a second keypress, which the CAS loop
|
|
230
|
+
* and the predicates make idempotent — nothing is double-paused. The failure is
|
|
231
|
+
* rethrown as a `BulkWalkError` carrying that partial progress, so a surface
|
|
232
|
+
* with somewhere to print it can name what did not complete — including which
|
|
233
|
+
* of the two repository facts landed, since the prefix property above is only
|
|
234
|
+
* legible to an operator who is told where the walk stopped.
|
|
235
|
+
*/
|
|
236
|
+
export async function bulkControlRepository(opts: BulkControlOpts): Promise<BulkControlSummary> {
|
|
237
|
+
const { store, repo, direction } = opts
|
|
238
|
+
const actor = humanActor(opts.user?.trim() || buildControlUser(opts.env ?? {}), opts.via)
|
|
239
|
+
const paused = direction === 'pause'
|
|
240
|
+
const intake = direction === 'resume'
|
|
241
|
+
|
|
242
|
+
let pausedWritten = false
|
|
243
|
+
let intakeWritten = false
|
|
244
|
+
let records: BuildRecord[] = []
|
|
245
|
+
let failedIndex: number | undefined
|
|
246
|
+
const slugs: string[] = []
|
|
247
|
+
|
|
248
|
+
try {
|
|
249
|
+
// `getRepoEvents`/`appendRepo` throw on a repository row no tick has created
|
|
250
|
+
// yet, and a keypress can precede the first tick. Mirrors toggleHarvestGate.
|
|
251
|
+
await store.ensureRepo(repo)
|
|
252
|
+
// The quiescence boundary, first — see the module doc's prefix property.
|
|
253
|
+
await store.appendRepo(repo, {
|
|
254
|
+
actor,
|
|
255
|
+
type: 'dispatcher.pause-set',
|
|
256
|
+
payload: { enabled: paused },
|
|
257
|
+
})
|
|
258
|
+
pausedWritten = true
|
|
259
|
+
await store.appendRepo(repo, {
|
|
260
|
+
actor,
|
|
261
|
+
type: 'dispatcher.intake-set',
|
|
262
|
+
payload: { enabled: intake },
|
|
263
|
+
})
|
|
264
|
+
intakeWritten = true
|
|
265
|
+
|
|
266
|
+
records = (await store.listBuilds())
|
|
267
|
+
.filter((record) => record.repo === repo)
|
|
268
|
+
// Slug order matches the dashboard's row order and makes the summary
|
|
269
|
+
// deterministic.
|
|
270
|
+
.sort((a, b) => a.slug.localeCompare(b.slug))
|
|
271
|
+
|
|
272
|
+
for (let index = 0; index < records.length; index += 1) {
|
|
273
|
+
const record = records[index]!
|
|
274
|
+
// Marked before the attempt and cleared after it, so the loop index is the
|
|
275
|
+
// single source for both `failedSlug` and `remaining`.
|
|
276
|
+
failedIndex = index
|
|
277
|
+
if (await requestOne(store, record.slug, actor, direction)) slugs.push(record.slug)
|
|
278
|
+
failedIndex = undefined
|
|
279
|
+
}
|
|
280
|
+
} catch (error) {
|
|
281
|
+
const failed = failedIndex === undefined ? undefined : records[failedIndex]
|
|
282
|
+
throw new BulkWalkError(error, {
|
|
283
|
+
direction,
|
|
284
|
+
paused,
|
|
285
|
+
pausedWritten,
|
|
286
|
+
intake,
|
|
287
|
+
intakeWritten,
|
|
288
|
+
slugs,
|
|
289
|
+
...(failed !== undefined ? { failedSlug: failed.slug } : {}),
|
|
290
|
+
remaining:
|
|
291
|
+
failedIndex === undefined ? [] : records.slice(failedIndex + 1).map((later) => later.slug),
|
|
292
|
+
})
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return { direction, slugs, paused, intake }
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* A phase agent may not quiesce the repository from inside its own build.
|
|
300
|
+
*
|
|
301
|
+
* `refuseOwnSessionControl` blocks a phase from controlling its own build, and a
|
|
302
|
+
* phase session's build is `running` by definition — so it is inside a pause
|
|
303
|
+
* walk, and `--all` without this guard would be a one-flag bypass of a stated
|
|
304
|
+
* product rule. Uniform across directions, like the per-build rule. No caller
|
|
305
|
+
* the repository-wide form exists for (a script, a deploy hook, a cron job, a
|
|
306
|
+
* non-TTY host) carries `AB_SESSION`; only a runner-spawned phase does.
|
|
307
|
+
*/
|
|
308
|
+
export function refuseOwnSessionBulkControl(
|
|
309
|
+
direction: BulkDirection,
|
|
310
|
+
env: Record<string, string | undefined>,
|
|
311
|
+
): void {
|
|
312
|
+
const session = env.AB_SESSION?.trim()
|
|
313
|
+
const build = env.AB_BUILD?.trim()
|
|
314
|
+
if (session === undefined || session === '') return
|
|
315
|
+
if (build === undefined || build === '') return
|
|
316
|
+
throw new BuildControlError(
|
|
317
|
+
'own-session',
|
|
318
|
+
`cannot ${direction} every build from build "${build}"'s own phase session ` +
|
|
319
|
+
'(AB_SESSION/AB_BUILD conflict); run this command outside that build session',
|
|
320
|
+
)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export interface AbBulkControlOpts {
|
|
324
|
+
targetRepo: string
|
|
325
|
+
env: Record<string, string | undefined>
|
|
326
|
+
exec: Exec
|
|
327
|
+
direction: BulkDirection
|
|
328
|
+
/** Explicit `--store`; selection remains --store > AB_STORE > repo-local. */
|
|
329
|
+
storeRef?: string
|
|
330
|
+
/** Injectable adapter seam for unit tests. */
|
|
331
|
+
openStore?: StoreOpener
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Sessionless command shell: resolve repository/store, walk, always close.
|
|
335
|
+
* The exact mirror of `abBuildControl`, over the bulk contract. */
|
|
336
|
+
export async function abBulkControl(opts: AbBulkControlOpts): Promise<BulkControlSummary> {
|
|
337
|
+
// Before opening a store, as in abBuildControl: the conflict is ambient and
|
|
338
|
+
// no read is needed to know the attempt is forbidden.
|
|
339
|
+
refuseOwnSessionBulkControl(opts.direction, opts.env)
|
|
340
|
+
|
|
341
|
+
return withSessionlessStore(opts, ({ store, repo }) =>
|
|
342
|
+
bulkControlRepository({ store, repo, env: opts.env, direction: opts.direction }),
|
|
343
|
+
)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Operator-facing success report: the shared announcement line the dashboard
|
|
347
|
+
* also prints, then one line per requested build in the exact shape
|
|
348
|
+
* `ab pause <slug>` already prints, so a script parsing per-build output parses
|
|
349
|
+
* this unchanged. */
|
|
350
|
+
export function bulkControlLines(summary: BulkControlSummary): string[] {
|
|
351
|
+
return [
|
|
352
|
+
bulkControlReport(summary),
|
|
353
|
+
...summary.slugs.map((slug) => `build ${slug}: ${summary.direction} requested`),
|
|
354
|
+
]
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Operator-facing failure report: what the walk wrote, where it stopped, and
|
|
358
|
+
* what it never reached. */
|
|
359
|
+
export function bulkFailureLines(error: BulkWalkError): string[] {
|
|
360
|
+
const { direction, paused, pausedWritten, intake, intakeWritten, slugs, failedSlug, remaining } =
|
|
361
|
+
error.progress
|
|
362
|
+
const label = direction === 'pause' ? 'pause all' : 'resume all'
|
|
363
|
+
// Write order again, and the hold clause carries the same words as the
|
|
364
|
+
// success report. It is the clause that tells the operator whether the
|
|
365
|
+
// prefix that survived is the quiesced one: a failure with the hold written
|
|
366
|
+
// still holds every queued build, and a failure before it changed nothing.
|
|
367
|
+
const holdLine = pausedWritten
|
|
368
|
+
? paused
|
|
369
|
+
? 'queued builds held'
|
|
370
|
+
: 'queued builds released'
|
|
371
|
+
: 'hold not written'
|
|
372
|
+
const intakeLine = intakeWritten
|
|
373
|
+
? `intake ${intake ? 'ON' : 'OFF'} written`
|
|
374
|
+
: 'intake not written'
|
|
375
|
+
|
|
376
|
+
const lines = [`${label}: failed; ${holdLine}; ${intakeLine}`]
|
|
377
|
+
if (slugs.length > 0) lines.push(`requested: ${slugs.join(', ')}`)
|
|
378
|
+
lines.push(
|
|
379
|
+
failedSlug === undefined
|
|
380
|
+
? `failed before any build — ${error.message}`
|
|
381
|
+
: `failed at: ${failedSlug} — ${error.message}`,
|
|
382
|
+
)
|
|
383
|
+
if (remaining.length > 0) lines.push(`not attempted: ${remaining.join(', ')}`)
|
|
384
|
+
return lines
|
|
385
|
+
}
|