@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,895 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ab builds` and `ab build status <slug>` — the read-only query surface on
|
|
3
|
+
* build state (SPEC §8.2, §15.5). Their invocation forms require no session,
|
|
4
|
+
* while a complete ambient phase identity narrows Store authority; `ab build
|
|
5
|
+
* status` additionally extends that identity to authorize reading the
|
|
6
|
+
* session's OWN build from a differently located checkout (see the gate
|
|
7
|
+
* comment on `abBuildStatus`).
|
|
8
|
+
*
|
|
9
|
+
* The shape is one decision: **the projection is pure, the IO is a thin
|
|
10
|
+
* shell.** The functions below map build facts plus an optional build-config
|
|
11
|
+
* decision to plain data (`BuildSummary`, `BuildDetail`), and the renderers map
|
|
12
|
+
* that data to `string[]`. The command entry points only resolve a store, fetch
|
|
13
|
+
* the build-owned config when needed, project, and print. So `--json` IS the
|
|
14
|
+
* projection object and the human text is a second renderer over the same
|
|
15
|
+
* value — there is no "compute for text" path that can disagree with the JSON.
|
|
16
|
+
*
|
|
17
|
+
* Effective status comes from `reduceBuild` — the authoritative event-derived
|
|
18
|
+
* projection (§15.5, §3.4). This is NOT a second read model: it reduces the
|
|
19
|
+
* log and reads the projection's fields, never re-deriving status from
|
|
20
|
+
* records, worktrees, or OS processes.
|
|
21
|
+
*
|
|
22
|
+
* **Status, durable progress, and lease health are separate axes.**
|
|
23
|
+
* `reduceBuild` reports both effective status and the latest durable event;
|
|
24
|
+
* mutable `lease`/`heartbeatAt` columns report runner liveness (§15.2.6).
|
|
25
|
+
* A runner can therefore stay live while its event log is silent. The shared
|
|
26
|
+
* presentation helper exposes that divergence without folding it into status
|
|
27
|
+
* or lease health.
|
|
28
|
+
*
|
|
29
|
+
* Output carries no ANSI, ever: §16 puts themes and colors out of scope, and
|
|
30
|
+
* "understandable without relying solely on color" plus "--json without ANSI
|
|
31
|
+
* formatting" are both satisfied by emitting none rather than by gating on a
|
|
32
|
+
* TTY check.
|
|
33
|
+
*/
|
|
34
|
+
import { join } from 'node:path'
|
|
35
|
+
import type { AbEvent } from '../events/catalog'
|
|
36
|
+
import type { EventPayload } from '../events/payloads'
|
|
37
|
+
import type { Actor } from '../events/envelope'
|
|
38
|
+
import { loadConfig } from '../config/load'
|
|
39
|
+
import type { Config } from '../config/schema'
|
|
40
|
+
import {
|
|
41
|
+
BUILD_EFFECTIVE_CONFIG_ARTIFACT,
|
|
42
|
+
parseEffectiveBuildConfig,
|
|
43
|
+
} from '../processes/build-execution-state'
|
|
44
|
+
import { openExecution } from '../processes/execution-settlement'
|
|
45
|
+
import { currentAutoMergeDeferral } from '../kernel/auto-merge'
|
|
46
|
+
import { decideNext } from '../kernel/engine'
|
|
47
|
+
import {
|
|
48
|
+
reduceBuild,
|
|
49
|
+
type BuildState,
|
|
50
|
+
type OpenEscalation,
|
|
51
|
+
type OpenSession,
|
|
52
|
+
type PrLifecycle,
|
|
53
|
+
} from '../kernel/reducer'
|
|
54
|
+
import type {
|
|
55
|
+
BuildOutcome,
|
|
56
|
+
BuildStatus,
|
|
57
|
+
ObservationKind,
|
|
58
|
+
Phase,
|
|
59
|
+
TicketRef,
|
|
60
|
+
VerifyOutcome,
|
|
61
|
+
} from '../ontology'
|
|
62
|
+
import type { Exec } from '../ports/workspace/git-worktree'
|
|
63
|
+
import type { BuildRecord, BuildStore, StreamRecord, StreamScope } from '../store/types'
|
|
64
|
+
import { buildProgress, isDiverged, type BuildProgress } from './build-progress'
|
|
65
|
+
import { normalizeGitRemoteUrl } from '../kernel/origin'
|
|
66
|
+
import { buildInRepository, resolveMainRepo } from './repo-state'
|
|
67
|
+
import { withAmbientReadStore, type StoreOpener } from './store-opening'
|
|
68
|
+
|
|
69
|
+
/** Backward-compatible name for callers/tests; repository resolution is shared. */
|
|
70
|
+
export const currentRepo = resolveMainRepo
|
|
71
|
+
|
|
72
|
+
// ── The projection ───────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Runner liveness, from the mutable lease columns (§15.2.6) — NOT a status.
|
|
76
|
+
* `none` is deliberately its own value rather than a weaker `expired`: a build
|
|
77
|
+
* that has not yet claimed its first lease reads this way, and it is the
|
|
78
|
+
* common case for a freshly launched runner.
|
|
79
|
+
*/
|
|
80
|
+
export type LeaseHealth = 'held' | 'expired' | 'none'
|
|
81
|
+
|
|
82
|
+
export interface LeaseInfo {
|
|
83
|
+
health: LeaseHealth
|
|
84
|
+
holder?: string
|
|
85
|
+
expiresAt?: string
|
|
86
|
+
heartbeatAt?: string
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface BuildSummary {
|
|
90
|
+
slug: string
|
|
91
|
+
ticket?: TicketRef
|
|
92
|
+
/** The reducer's effective status (§15.5) — never mixed with lease health. */
|
|
93
|
+
status: BuildStatus
|
|
94
|
+
phase?: Phase
|
|
95
|
+
round?: number
|
|
96
|
+
attempt?: number
|
|
97
|
+
pr?: { number: number; url: string; state?: PrLifecycle }
|
|
98
|
+
updatedAt: string
|
|
99
|
+
lease: LeaseInfo
|
|
100
|
+
/** Durable-event progress, separate from mutable lease renewal. */
|
|
101
|
+
progress: BuildProgress
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface VerifyProgress {
|
|
105
|
+
/** Full-log attempt high-water retained for status-output compatibility. */
|
|
106
|
+
attempt: number
|
|
107
|
+
/** Current cycle only — selected by sequence boundary, never attempt. */
|
|
108
|
+
steps: { step: string; outcome: VerifyOutcome; reason?: string }[]
|
|
109
|
+
currentStep?: string
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface SetupFailureDetail {
|
|
113
|
+
attempt: number
|
|
114
|
+
command: string
|
|
115
|
+
exitStatus: number | null
|
|
116
|
+
output: string
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface BuildObservation {
|
|
120
|
+
id: string
|
|
121
|
+
kind: ObservationKind
|
|
122
|
+
summary: string
|
|
123
|
+
files?: string[]
|
|
124
|
+
refs?: string[]
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export type BuildDecisionProjection =
|
|
128
|
+
| { kind: 'awaiting-pr'; reason?: string }
|
|
129
|
+
| { kind: 'repository-completion'; prState: Extract<PrLifecycle, 'merged' | 'closed'> }
|
|
130
|
+
| { kind: 'parked'; reason: string }
|
|
131
|
+
| { kind: 'runner-work' }
|
|
132
|
+
| { kind: 'unavailable'; diagnostic: string }
|
|
133
|
+
|
|
134
|
+
export interface BuildDetail extends BuildSummary {
|
|
135
|
+
/** Current work-owner decision. Open PRs use the build-owned config; ended
|
|
136
|
+
* PRs project the repository completion path directly from durable state. */
|
|
137
|
+
decision?: BuildDecisionProjection
|
|
138
|
+
openEscalations: OpenEscalation[]
|
|
139
|
+
/** Per-loop review overrides for the current spec; omitted when none are set. */
|
|
140
|
+
reviewRoundCeilings?: { plan?: number; code?: number }
|
|
141
|
+
openSessions: StatusOpenSession[]
|
|
142
|
+
observations: BuildObservation[]
|
|
143
|
+
/** Current setup failure only; a later successful attachment clears it. */
|
|
144
|
+
setupFailure?: SetupFailureDetail
|
|
145
|
+
/** True when the latest recorded execution has no following
|
|
146
|
+
* `execution.ended` — the guest may still be running under durable
|
|
147
|
+
* supervision even with no dispatcher process alive. */
|
|
148
|
+
executionOpen?: boolean
|
|
149
|
+
/** Latest provider execution and current lifecycle failure. Full history is
|
|
150
|
+
* available with --events. */
|
|
151
|
+
execution?: EventPayload<'execution.started'> & { seq: number }
|
|
152
|
+
infrastructureFailure?: EventPayload<'infrastructure.failed'> & { seq: number }
|
|
153
|
+
cleanupPending: boolean
|
|
154
|
+
verify: VerifyProgress
|
|
155
|
+
lastEvent?: { type: string; seq: number; ts: string; actor: Actor }
|
|
156
|
+
/** Present only with `--events <n>`: the newest n, chronological. */
|
|
157
|
+
events?: AbEvent[]
|
|
158
|
+
outcome?: BuildOutcome
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* `none` when there is no lease; `expired` when it has run out; else `held`.
|
|
163
|
+
*
|
|
164
|
+
* The boundary matches `Dispatcher.leaseSweep` (src/processes/dispatcher.ts),
|
|
165
|
+
* whose has-lease branch is `if (expiresAt > now) continue // healthy` — so
|
|
166
|
+
* `expiresAt === now` is expired in both places and the sweep and this report
|
|
167
|
+
* agree.
|
|
168
|
+
*
|
|
169
|
+
* That agreement is on the has-lease branch only. `leaseSweep` also grants an
|
|
170
|
+
* ABSENT lease a first-claim grace window (`updatedAt` within `leaseTtlMs`)
|
|
171
|
+
* before it acts, while this reports `none` regardless. The divergence is
|
|
172
|
+
* intended: `none` is a distinct word from `expired`, and teaching this
|
|
173
|
+
* function the grace window would put a dispatcher policy knob (`leaseTtlMs`)
|
|
174
|
+
* into a read-only reporter. It is documented in the guide instead.
|
|
175
|
+
*/
|
|
176
|
+
export function leaseHealth(record: BuildRecord, now: Date): LeaseHealth {
|
|
177
|
+
if (record.lease === undefined) return 'none'
|
|
178
|
+
return new Date(record.lease.expiresAt).getTime() <= now.getTime() ? 'expired' : 'held'
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function leaseInfo(record: BuildRecord, now: Date): LeaseInfo {
|
|
182
|
+
return {
|
|
183
|
+
health: leaseHealth(record, now),
|
|
184
|
+
...(record.lease !== undefined
|
|
185
|
+
? { holder: record.lease.holder, expiresAt: record.lease.expiresAt }
|
|
186
|
+
: {}),
|
|
187
|
+
...(record.heartbeatAt !== undefined ? { heartbeatAt: record.heartbeatAt } : {}),
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The summary over an ALREADY-reduced state — so `detail` reduces the log
|
|
193
|
+
* once and builds both projections from it, rather than reducing twice.
|
|
194
|
+
*/
|
|
195
|
+
function summarizeFrom(record: BuildRecord, state: BuildState, now: Date): BuildSummary {
|
|
196
|
+
// `phase`, `round`, and `attempt` describe ONE phase occurrence, so they come
|
|
197
|
+
// from ONE source: the in-flight phase, else the last completed one — the
|
|
198
|
+
// same `currentPhase ?? lastCompletedPhase` the reducer uses to derive
|
|
199
|
+
// `state.phase`. Reading `phase` from that pair while reading round/attempt
|
|
200
|
+
// from `currentPhase` alone made the three disagree the moment a phase
|
|
201
|
+
// completed: `verify.completed` clears `currentPhase`, so between two verify
|
|
202
|
+
// steps a build on attempt 3 reported `verify:unit r1` — the attempt gone and
|
|
203
|
+
// the loop round stamped onto a phase that carries none. That reads as "first
|
|
204
|
+
// pass through verify" while verify is actually thrashing on its third.
|
|
205
|
+
//
|
|
206
|
+
// PhaseContext is authoritative about which axis a phase HAS (§15.3): loop
|
|
207
|
+
// phases carry `round`, verify and reconcile carry `attempt`, finalize
|
|
208
|
+
// carries neither. Taking both straight from it is what makes "current round
|
|
209
|
+
// or attempt WHEN APPLICABLE" fall out, instead of being re-decided here.
|
|
210
|
+
const active = state.currentPhase ?? state.lastCompletedPhase
|
|
211
|
+
const round = active?.round
|
|
212
|
+
const attempt = active?.attempt
|
|
213
|
+
return {
|
|
214
|
+
slug: record.slug,
|
|
215
|
+
// record.ticket is a TicketRef already carrying id/title/url — no artifact
|
|
216
|
+
// read and no ticket-provider call (live ticket state is out of scope).
|
|
217
|
+
...(record.ticket !== undefined ? { ticket: record.ticket } : {}),
|
|
218
|
+
status: state.status,
|
|
219
|
+
...(active?.phase !== undefined ? { phase: active.phase } : {}),
|
|
220
|
+
...(round !== undefined ? { round } : {}),
|
|
221
|
+
...(attempt !== undefined ? { attempt } : {}),
|
|
222
|
+
...(state.pr !== undefined
|
|
223
|
+
? {
|
|
224
|
+
pr: {
|
|
225
|
+
number: state.pr.number,
|
|
226
|
+
url: state.pr.url,
|
|
227
|
+
...(state.prState !== undefined ? { state: state.prState } : {}),
|
|
228
|
+
},
|
|
229
|
+
}
|
|
230
|
+
: {}),
|
|
231
|
+
updatedAt: record.updatedAt,
|
|
232
|
+
lease: leaseInfo(record, now),
|
|
233
|
+
progress: buildProgress(record, state),
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* One build's summary. `round`/`attempt` come from the in-flight phase when
|
|
239
|
+
* there is one, so "current round or attempt when applicable" reflects what is
|
|
240
|
+
* happening now rather than the last thing that finished.
|
|
241
|
+
*/
|
|
242
|
+
export function summarize(record: BuildRecord, events: AbEvent[], now: Date): BuildSummary {
|
|
243
|
+
return summarizeFrom(record, reduceBuild(events), now)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Summary plus the detail fields; `eventCount` appends the newest n events. */
|
|
247
|
+
/** One open session enriched with its live-view stream status (SPEC §9). */
|
|
248
|
+
export interface StatusOpenSession extends OpenSession {
|
|
249
|
+
/** Open until the stream closes; the store's records are authoritative. */
|
|
250
|
+
streamStatus?: 'open' | 'closed'
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function detail(
|
|
254
|
+
record: BuildRecord,
|
|
255
|
+
events: AbEvent[],
|
|
256
|
+
now: Date,
|
|
257
|
+
eventCount?: number,
|
|
258
|
+
decision?: BuildDecisionProjection,
|
|
259
|
+
streams?: readonly StreamRecord[],
|
|
260
|
+
): BuildDetail {
|
|
261
|
+
const state = reduceBuild(events)
|
|
262
|
+
const streamStatus = new Map<string, { stream?: string; status: 'open' | 'closed' }>()
|
|
263
|
+
if (streams !== undefined) {
|
|
264
|
+
for (const record of streams) {
|
|
265
|
+
if (record.scope.kind !== 'build') continue
|
|
266
|
+
streamStatus.set(record.label, { stream: record.id, status: record.status })
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
const summary = summarizeFrom(record, state, now)
|
|
270
|
+
// §15.6-A: only the CURRENT cycle's results describe the current code.
|
|
271
|
+
// Without this filter an earlier cycle's passes read as current — wrong, and
|
|
272
|
+
// wrong in the reassuring direction.
|
|
273
|
+
//
|
|
274
|
+
// The cycle test is `seq > verify.cycleSince`, never result attempt equality:
|
|
275
|
+
// `maxAttemptSeen` is the full-log high-water and still names the failed
|
|
276
|
+
// cycle in the window before the next `verify.started`. `cycleSince` is the
|
|
277
|
+
// authoritative boundary the reducer and engine.ts already agree on (max of
|
|
278
|
+
// restart, code-review approve, and reconcile) — reading it here is what
|
|
279
|
+
// keeps this command's report equal to the projection used elsewhere rather
|
|
280
|
+
// than a second, drifting opinion.
|
|
281
|
+
const steps = state.verify.results
|
|
282
|
+
.filter((result) => result.seq > state.verify.cycleSince)
|
|
283
|
+
.map((result) => ({
|
|
284
|
+
step: result.step,
|
|
285
|
+
outcome: result.outcome,
|
|
286
|
+
...(result.reason !== undefined ? { reason: result.reason } : {}),
|
|
287
|
+
}))
|
|
288
|
+
return {
|
|
289
|
+
...summary,
|
|
290
|
+
...(decision !== undefined ? { decision } : {}),
|
|
291
|
+
openEscalations: state.openEscalations,
|
|
292
|
+
...(state.reviewRoundCeilings.plan !== undefined || state.reviewRoundCeilings.code !== undefined
|
|
293
|
+
? { reviewRoundCeilings: { ...state.reviewRoundCeilings } }
|
|
294
|
+
: {}),
|
|
295
|
+
openSessions: state.sessions.open.map((session) => {
|
|
296
|
+
const record = streamStatus.get(`session:${session.session}`)
|
|
297
|
+
return {
|
|
298
|
+
...session,
|
|
299
|
+
...(record?.stream !== undefined ? { stream: record.stream } : {}),
|
|
300
|
+
...(record !== undefined ? { streamStatus: record.status } : {}),
|
|
301
|
+
}
|
|
302
|
+
}),
|
|
303
|
+
// Observations are historical facts rather than a derived current-state
|
|
304
|
+
// opinion. Preserve event order and exact optional evidence fields.
|
|
305
|
+
observations: events
|
|
306
|
+
.filter((event) => event.type === 'observation.recorded')
|
|
307
|
+
.map((event) => ({
|
|
308
|
+
id: event.payload.id,
|
|
309
|
+
kind: event.payload.kind,
|
|
310
|
+
summary: event.payload.summary,
|
|
311
|
+
...(event.payload.files !== undefined ? { files: event.payload.files } : {}),
|
|
312
|
+
...(event.payload.refs !== undefined ? { refs: event.payload.refs } : {}),
|
|
313
|
+
})),
|
|
314
|
+
...(state.setupFailure !== undefined
|
|
315
|
+
? {
|
|
316
|
+
setupFailure: {
|
|
317
|
+
attempt: state.setupFailure.attempt,
|
|
318
|
+
command: state.setupFailure.command,
|
|
319
|
+
exitStatus: state.setupFailure.exitStatus,
|
|
320
|
+
output: state.setupFailure.output,
|
|
321
|
+
},
|
|
322
|
+
}
|
|
323
|
+
: {}),
|
|
324
|
+
...(state.executions.at(-1) !== undefined ? { execution: state.executions.at(-1)! } : {}),
|
|
325
|
+
// Durable supervision: a recorded execution with no following end is the
|
|
326
|
+
// source of truth for whether a guest may still be running.
|
|
327
|
+
executionOpen: openExecution(events) !== null,
|
|
328
|
+
...(state.infrastructureFailure !== undefined
|
|
329
|
+
? { infrastructureFailure: state.infrastructureFailure }
|
|
330
|
+
: {}),
|
|
331
|
+
cleanupPending:
|
|
332
|
+
state.infrastructureFailure?.cleanupPending === true ||
|
|
333
|
+
state.cleanupAttempts.at(-1)?.outcome === 'unknown',
|
|
334
|
+
verify: {
|
|
335
|
+
// Preserve the status API's display counter while sourcing it explicitly
|
|
336
|
+
// from the reducer's full-log high-water, never from cycle membership.
|
|
337
|
+
attempt: state.verify.maxAttemptSeen,
|
|
338
|
+
steps,
|
|
339
|
+
...(state.verify.currentStep !== undefined ? { currentStep: state.verify.currentStep } : {}),
|
|
340
|
+
},
|
|
341
|
+
...(state.lastEvent !== undefined
|
|
342
|
+
? {
|
|
343
|
+
lastEvent: {
|
|
344
|
+
type: state.lastEvent.type,
|
|
345
|
+
seq: state.lastEvent.seq,
|
|
346
|
+
ts: state.lastEvent.ts,
|
|
347
|
+
actor: state.lastEvent.actor,
|
|
348
|
+
},
|
|
349
|
+
}
|
|
350
|
+
: {}),
|
|
351
|
+
// getEvents returns seq-ordered events, so the newest n slice out already
|
|
352
|
+
// chronological — which is what the AC asks for.
|
|
353
|
+
...(eventCount !== undefined ? { events: events.slice(-eventCount) } : {}),
|
|
354
|
+
...(state.outcome !== undefined ? { outcome: state.outcome } : {}),
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ── Renderers ────────────────────────────────────────────────────────────────
|
|
359
|
+
|
|
360
|
+
/** Lease health as a literal word — never a color (§16: colors out of scope). */
|
|
361
|
+
function healthWord(health: LeaseHealth): string {
|
|
362
|
+
return health === 'none' ? 'no-lease' : health
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Preserve printable text while making terminal control bytes inert. Newlines
|
|
366
|
+
* are split by the caller so multiline setup output remains readable. */
|
|
367
|
+
function safeStatusText(value: string): string {
|
|
368
|
+
let displayed = ''
|
|
369
|
+
for (const char of value) {
|
|
370
|
+
const code = char.codePointAt(0)!
|
|
371
|
+
displayed += code >= 0x20 && code !== 0x7f ? char : `\\u{${code.toString(16)}}`
|
|
372
|
+
}
|
|
373
|
+
return displayed
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** `r<round>` / `a<attempt>` suffixed onto the phase — the loop vs verify axis. */
|
|
377
|
+
function phaseCell(summary: BuildSummary): string {
|
|
378
|
+
if (summary.phase === undefined) return '—'
|
|
379
|
+
if (summary.attempt !== undefined) return `${summary.phase} a${summary.attempt}`
|
|
380
|
+
if (summary.round !== undefined) return `${summary.phase} r${summary.round}`
|
|
381
|
+
return summary.phase
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** `#7 open https://…` — state AND link, per the summary's required fields. */
|
|
385
|
+
function prCell(summary: BuildSummary): string {
|
|
386
|
+
if (summary.pr === undefined) return '—'
|
|
387
|
+
const state = summary.pr.state !== undefined ? ` ${summary.pr.state}` : ''
|
|
388
|
+
return `#${summary.pr.number}${state} ${summary.pr.url}`
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** Long enough to recognize the ticket, short enough to keep rows scannable. */
|
|
392
|
+
const TITLE_WIDTH = 40
|
|
393
|
+
|
|
394
|
+
/** `AUT-6 Interactive build dashboard…` — id AND title, per the same list. */
|
|
395
|
+
function ticketCell(summary: BuildSummary): string {
|
|
396
|
+
const ticket = summary.ticket
|
|
397
|
+
if (ticket === undefined) return '—'
|
|
398
|
+
if (ticket.title === undefined || ticket.title === '') return ticket.id
|
|
399
|
+
const title =
|
|
400
|
+
ticket.title.length > TITLE_WIDTH ? `${ticket.title.slice(0, TITLE_WIDTH - 1)}…` : ticket.title
|
|
401
|
+
return `${ticket.id} ${title}`
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** Coarse relative age — a status read wants "how stale", not a timestamp. */
|
|
405
|
+
export function relativeTime(iso: string, now: Date): string {
|
|
406
|
+
const then = new Date(iso).getTime()
|
|
407
|
+
if (Number.isNaN(then)) return iso
|
|
408
|
+
const seconds = Math.round((now.getTime() - then) / 1000)
|
|
409
|
+
if (seconds < 0) return 'just now'
|
|
410
|
+
if (seconds < 60) return `${seconds}s ago`
|
|
411
|
+
const minutes = Math.floor(seconds / 60)
|
|
412
|
+
if (minutes < 60) return `${minutes}m ago`
|
|
413
|
+
const hours = Math.floor(minutes / 60)
|
|
414
|
+
if (hours < 24) return `${hours}h ago`
|
|
415
|
+
return `${Math.floor(hours / 24)}d ago`
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Delegated-write attribution (§15.1): a human actor carrying a via marker
|
|
420
|
+
* renders as `human (via session os_…)` / `human (via mcp client)`; plain
|
|
421
|
+
* actors render unchanged. The JSON output and the operator API's build
|
|
422
|
+
* detail serialize the actor object verbatim, so the marker reaches those
|
|
423
|
+
* surfaces without transformation.
|
|
424
|
+
*/
|
|
425
|
+
function viaMarker(actor: Actor): string {
|
|
426
|
+
if (actor.kind !== 'human' || actor.via === undefined) return actor.kind
|
|
427
|
+
return `human (via ${actor.via.kind === 'session' ? `session ${actor.via.id}` : `mcp ${actor.via.client}`})`
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function padColumns(rows: string[][]): string[] {
|
|
431
|
+
const widths: number[] = []
|
|
432
|
+
for (const row of rows) {
|
|
433
|
+
row.forEach((cell, i) => {
|
|
434
|
+
widths[i] = Math.max(widths[i] ?? 0, cell.length)
|
|
435
|
+
})
|
|
436
|
+
}
|
|
437
|
+
return rows.map((row) =>
|
|
438
|
+
row
|
|
439
|
+
.map((cell, i) => (i === row.length - 1 ? cell : cell.padEnd(widths[i] ?? 0)))
|
|
440
|
+
.join(' ')
|
|
441
|
+
.trimEnd(),
|
|
442
|
+
)
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* One aligned row per build. `emptyNote` names the filter in effect when there
|
|
447
|
+
* is nothing to show, so an empty result is an honest answer rather than a
|
|
448
|
+
* blank that reads like a bug.
|
|
449
|
+
*/
|
|
450
|
+
export function renderSummaries(summaries: BuildSummary[], now: Date, emptyNote: string): string[] {
|
|
451
|
+
if (summaries.length === 0) return [emptyNote]
|
|
452
|
+
// The two free-text columns (ticket title, PR link) go last, where their
|
|
453
|
+
// width costs no alignment on the scannable columns to their left.
|
|
454
|
+
const rows: string[][] = [
|
|
455
|
+
['BUILD', 'STATUS', 'PHASE', 'LEASE', 'HEARTBEAT', 'PROGRESS', 'TICKET', 'PR'],
|
|
456
|
+
]
|
|
457
|
+
for (const summary of summaries) {
|
|
458
|
+
const progressAge =
|
|
459
|
+
summary.progress.lastEventAt === undefined
|
|
460
|
+
? '—'
|
|
461
|
+
: relativeTime(summary.progress.lastEventAt, now)
|
|
462
|
+
rows.push([
|
|
463
|
+
summary.slug,
|
|
464
|
+
summary.status,
|
|
465
|
+
phaseCell(summary),
|
|
466
|
+
healthWord(summary.lease.health),
|
|
467
|
+
summary.progress.heartbeatAt === undefined
|
|
468
|
+
? '—'
|
|
469
|
+
: relativeTime(summary.progress.heartbeatAt, now),
|
|
470
|
+
`${progressAge}${isDiverged(summary.progress, now) ? ' diverged' : ''}`,
|
|
471
|
+
ticketCell(summary),
|
|
472
|
+
prCell(summary),
|
|
473
|
+
])
|
|
474
|
+
}
|
|
475
|
+
return padColumns(rows)
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** Labeled sections, each omitted when absent ("when each is available"). */
|
|
479
|
+
export function renderDetail(d: BuildDetail, now: Date): string[] {
|
|
480
|
+
const lines: string[] = [`build ${d.slug}`]
|
|
481
|
+
if (d.ticket !== undefined) {
|
|
482
|
+
const title = d.ticket.title !== undefined ? ` — ${d.ticket.title}` : ''
|
|
483
|
+
lines.push(` ticket: ${d.ticket.id}${title}`)
|
|
484
|
+
if (d.ticket.url !== undefined) lines.push(` ticket url: ${d.ticket.url}`)
|
|
485
|
+
}
|
|
486
|
+
lines.push(` status: ${d.status}${d.outcome !== undefined ? ` (${d.outcome})` : ''}`)
|
|
487
|
+
lines.push(` phase: ${phaseCell(d)}`)
|
|
488
|
+
if (d.reviewRoundCeilings !== undefined) {
|
|
489
|
+
const ceilings = [
|
|
490
|
+
...(d.reviewRoundCeilings.plan !== undefined ? [`plan ${d.reviewRoundCeilings.plan}`] : []),
|
|
491
|
+
...(d.reviewRoundCeilings.code !== undefined ? [`code ${d.reviewRoundCeilings.code}`] : []),
|
|
492
|
+
]
|
|
493
|
+
lines.push(` review round ceiling: ${ceilings.join(', ')}`)
|
|
494
|
+
}
|
|
495
|
+
lines.push(` updated: ${d.updatedAt} (${relativeTime(d.updatedAt, now)})`)
|
|
496
|
+
|
|
497
|
+
// Its own line, never folded into status: a running build with an expired
|
|
498
|
+
// lease is the case this exists to make visible.
|
|
499
|
+
const lease = [` lease: ${healthWord(d.lease.health)}`]
|
|
500
|
+
if (d.lease.holder !== undefined) lease.push(`holder ${d.lease.holder}`)
|
|
501
|
+
if (d.lease.expiresAt !== undefined) lease.push(`expires ${d.lease.expiresAt}`)
|
|
502
|
+
lines.push(lease.join(' '))
|
|
503
|
+
lines.push(
|
|
504
|
+
d.progress.heartbeatAt === undefined
|
|
505
|
+
? ' heartbeat: —'
|
|
506
|
+
: ` heartbeat: ${d.progress.heartbeatAt} (${relativeTime(d.progress.heartbeatAt, now)})`,
|
|
507
|
+
)
|
|
508
|
+
lines.push(
|
|
509
|
+
d.progress.lastEventAt === undefined
|
|
510
|
+
? ' progress: —'
|
|
511
|
+
: ` progress: ${d.progress.lastEventAt} (${relativeTime(d.progress.lastEventAt, now)})${
|
|
512
|
+
isDiverged(d.progress, now) ? ' — diverged' : ''
|
|
513
|
+
}`,
|
|
514
|
+
)
|
|
515
|
+
if (d.status === 'running' && d.lease.health === 'expired') {
|
|
516
|
+
if (d.decision?.kind === 'awaiting-pr') {
|
|
517
|
+
lines.push(
|
|
518
|
+
' note: running with an expired lease — parked waiting on the PR; no runner re-attachment is pending',
|
|
519
|
+
)
|
|
520
|
+
} else if (d.decision?.kind === 'repository-completion') {
|
|
521
|
+
lines.push(
|
|
522
|
+
` note: running with an expired lease — the PR is ${d.decision.prState}; repository-level completion is pending and no runner re-attachment is pending`,
|
|
523
|
+
)
|
|
524
|
+
} else if (d.decision?.kind === 'parked') {
|
|
525
|
+
lines.push(
|
|
526
|
+
' note: running with an expired lease — the current decision is parked; no runner re-attachment is pending',
|
|
527
|
+
)
|
|
528
|
+
} else if (d.decision?.kind === 'unavailable') {
|
|
529
|
+
lines.push(
|
|
530
|
+
' note: running with an expired lease — runner actionability could not be determined from the unavailable build config',
|
|
531
|
+
)
|
|
532
|
+
} else if (d.executionOpen === true) {
|
|
533
|
+
lines.push(
|
|
534
|
+
' note: running with an expired lease — the recorded execution is still the\n' +
|
|
535
|
+
' source of truth: the guest may keep running and the lease sweep\n' +
|
|
536
|
+
' re-attaches only when the guest is actually gone',
|
|
537
|
+
)
|
|
538
|
+
} else {
|
|
539
|
+
lines.push(
|
|
540
|
+
' note: running with an expired lease — the runner is gone; the lease sweep will re-attach it',
|
|
541
|
+
)
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
if (d.pr !== undefined) {
|
|
546
|
+
lines.push(
|
|
547
|
+
` pr: #${d.pr.number}${d.pr.state !== undefined ? ` (${d.pr.state})` : ''} ${d.pr.url}`,
|
|
548
|
+
)
|
|
549
|
+
}
|
|
550
|
+
if (d.decision?.kind === 'awaiting-pr') {
|
|
551
|
+
lines.push(
|
|
552
|
+
` waiting: on PR${d.decision.reason !== undefined ? ` — ${d.decision.reason}` : ''}`,
|
|
553
|
+
)
|
|
554
|
+
} else if (d.decision?.kind === 'repository-completion') {
|
|
555
|
+
lines.push(
|
|
556
|
+
` waiting: repository-level completion after the PR was ${d.decision.prState}; no runner re-attachment is pending`,
|
|
557
|
+
)
|
|
558
|
+
} else if (d.decision?.kind === 'unavailable') {
|
|
559
|
+
lines.push(` decision: unavailable — ${d.decision.diagnostic}`)
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
if (d.execution !== undefined) {
|
|
563
|
+
lines.push(
|
|
564
|
+
` execution: ${safeStatusText(d.execution.provider)} workspace=${safeStatusText(d.execution.workspaceRef)} instance=${safeStatusText(d.execution.instance)}`,
|
|
565
|
+
)
|
|
566
|
+
if (d.execution.environmentId !== undefined)
|
|
567
|
+
lines.push(` environment: ${safeStatusText(d.execution.environmentId)}`)
|
|
568
|
+
if (d.execution.sessionId !== undefined)
|
|
569
|
+
lines.push(` session: ${safeStatusText(d.execution.sessionId)}`)
|
|
570
|
+
}
|
|
571
|
+
if (d.infrastructureFailure !== undefined) {
|
|
572
|
+
lines.push(
|
|
573
|
+
` infrastructure failure: ${d.infrastructureFailure.operation} attempt ${d.infrastructureFailure.attempt} (${d.infrastructureFailure.cause})`,
|
|
574
|
+
)
|
|
575
|
+
lines.push(` error: ${safeStatusText(d.infrastructureFailure.error)}`)
|
|
576
|
+
if (d.cleanupPending) lines.push(' cleanup: pending provider reconciliation')
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
if (d.setupFailure !== undefined) {
|
|
580
|
+
const status =
|
|
581
|
+
d.setupFailure.exitStatus === null
|
|
582
|
+
? 'exit status unavailable'
|
|
583
|
+
: `exit status ${d.setupFailure.exitStatus}`
|
|
584
|
+
lines.push(` setup failure: attempt ${d.setupFailure.attempt}`)
|
|
585
|
+
lines.push(` command: ${safeStatusText(d.setupFailure.command)}`)
|
|
586
|
+
lines.push(` status: ${status}`)
|
|
587
|
+
lines.push(' output:')
|
|
588
|
+
const output = d.setupFailure.output || '(no output)'
|
|
589
|
+
for (const line of output.split('\n')) lines.push(` ${safeStatusText(line)}`)
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
if (d.openEscalations.length > 0) {
|
|
593
|
+
lines.push(` escalations (${d.openEscalations.length} unresolved):`)
|
|
594
|
+
for (const escalation of d.openEscalations) {
|
|
595
|
+
lines.push(` [${escalation.id}] ${escalation.phase}: ${escalation.question}`)
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
if (d.openSessions.length > 0) {
|
|
600
|
+
lines.push(` open sessions (${d.openSessions.length}):`)
|
|
601
|
+
for (const session of d.openSessions) {
|
|
602
|
+
const stream =
|
|
603
|
+
session.stream !== undefined
|
|
604
|
+
? ` — stream ${session.stream} (${session.streamStatus ?? 'open'})`
|
|
605
|
+
: ''
|
|
606
|
+
lines.push(
|
|
607
|
+
` ${session.session} — ${session.role} on ${session.phase} (runner ${session.runner})${stream}`,
|
|
608
|
+
)
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
if (d.observations.length > 0) {
|
|
613
|
+
lines.push(` observations (${d.observations.length}):`)
|
|
614
|
+
for (const observation of d.observations) {
|
|
615
|
+
lines.push(` [${observation.id}] ${observation.kind}: ${observation.summary}`)
|
|
616
|
+
if (observation.files !== undefined) {
|
|
617
|
+
lines.push(` files: ${observation.files.join(', ')}`)
|
|
618
|
+
}
|
|
619
|
+
if (observation.refs !== undefined) {
|
|
620
|
+
lines.push(` refs: ${observation.refs.join(', ')}`)
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
if (d.verify.attempt > 0) {
|
|
626
|
+
lines.push(` verify: attempt ${d.verify.attempt}`)
|
|
627
|
+
for (const step of d.verify.steps) {
|
|
628
|
+
if (step.outcome === 'skipped') {
|
|
629
|
+
lines.push(` SKIP ${step.step} — ${step.reason ?? '(reason unavailable)'}`)
|
|
630
|
+
} else {
|
|
631
|
+
lines.push(` ${step.outcome === 'pass' ? 'pass' : 'FAIL'} ${step.step}`)
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
if (d.verify.currentStep !== undefined) {
|
|
635
|
+
lines.push(` running ${d.verify.currentStep}`)
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
if (d.lastEvent !== undefined) {
|
|
640
|
+
lines.push(
|
|
641
|
+
` last event: ${d.lastEvent.type} (seq ${d.lastEvent.seq}) ${d.lastEvent.ts} by ${viaMarker(d.lastEvent.actor)}`,
|
|
642
|
+
)
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
if (d.events !== undefined) {
|
|
646
|
+
lines.push(` events (newest ${d.events.length}, chronological):`)
|
|
647
|
+
const rows = d.events.map((event) => [
|
|
648
|
+
` ${event.seq}`,
|
|
649
|
+
event.ts,
|
|
650
|
+
event.type,
|
|
651
|
+
viaMarker(event.actor),
|
|
652
|
+
])
|
|
653
|
+
for (const line of padColumns(rows)) lines.push(line)
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
return lines
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// ── The command shells ───────────────────────────────────────────────────────
|
|
660
|
+
|
|
661
|
+
export interface StatusOpts {
|
|
662
|
+
/** The cwd — resolved to the main repo root before matching (see currentRepo). */
|
|
663
|
+
targetRepo: string
|
|
664
|
+
/** Process environment: AB_STORE / AB_TOKEN (D8). */
|
|
665
|
+
env: Record<string, string | undefined>
|
|
666
|
+
exec: Exec
|
|
667
|
+
stdout: (line: string) => void
|
|
668
|
+
json?: boolean
|
|
669
|
+
/** `--store <ref>`; same reference behavior dispatch has (§7.2). */
|
|
670
|
+
storeRef?: string
|
|
671
|
+
/** Injectable store seam — mirrors dispatch.ts's `wire`, keeps tests off the
|
|
672
|
+
* real filesystem. */
|
|
673
|
+
openStore?: StoreOpener
|
|
674
|
+
/** Injectable clock; defaults to the wall clock. */
|
|
675
|
+
now?: () => Date
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/** Statuses a bare `ab builds` shows: the ones an operator can still act on. */
|
|
679
|
+
const ACTIVE_STATUSES: BuildStatus[] = ['running', 'paused', 'blocked']
|
|
680
|
+
|
|
681
|
+
export function statusFilter(all?: boolean, queued?: boolean): BuildStatus[] {
|
|
682
|
+
if (all === true) return ['queued', 'running', 'paused', 'blocked', 'done', 'aborted']
|
|
683
|
+
return queued === true ? [...ACTIVE_STATUSES, 'queued'] : ACTIVE_STATUSES
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
export interface AbBuildsOpts extends StatusOpts {
|
|
687
|
+
queued?: boolean
|
|
688
|
+
all?: boolean
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/** `ab builds` — this repo's builds, active by default. Read-only. */
|
|
692
|
+
export async function abBuilds(opts: AbBuildsOpts): Promise<void> {
|
|
693
|
+
const now = (opts.now ?? (() => new Date()))()
|
|
694
|
+
await withAmbientReadStore(opts, async ({ store, repo }) => {
|
|
695
|
+
// Repository identity is the checkout's normalized origin. Old records
|
|
696
|
+
// are keyed by checkout path and are not migrated; they stay visible
|
|
697
|
+
// from any checkout whose origin matches the recorded `repoOrigin`
|
|
698
|
+
// (read compatibility, not migration). Foreign-origin records are hidden.
|
|
699
|
+
const identity = normalizeGitRemoteUrl(repo)
|
|
700
|
+
const mine = (record: BuildRecord): boolean =>
|
|
701
|
+
record.repo === identity ||
|
|
702
|
+
(record.repoOrigin !== undefined && normalizeGitRemoteUrl(record.repoOrigin) === identity)
|
|
703
|
+
const wanted = new Set(statusFilter(opts.all, opts.queued))
|
|
704
|
+
// Cross-repo aggregation is out of scope (§12: one dispatcher per repo,
|
|
705
|
+
// one repo's builds per answer).
|
|
706
|
+
const records = (await store.listBuilds()).filter(mine)
|
|
707
|
+
const summaries: BuildSummary[] = []
|
|
708
|
+
for (const record of records) {
|
|
709
|
+
const summary = summarize(record, await store.getEvents(record.slug), now)
|
|
710
|
+
if (wanted.has(summary.status)) summaries.push(summary)
|
|
711
|
+
}
|
|
712
|
+
summaries.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
|
|
713
|
+
|
|
714
|
+
if (opts.json === true) {
|
|
715
|
+
opts.stdout(JSON.stringify(summaries, null, 2))
|
|
716
|
+
return
|
|
717
|
+
}
|
|
718
|
+
// The empty line names the filter that matched nothing and suggests only
|
|
719
|
+
// the flags that would actually widen it — telling a caller who passed
|
|
720
|
+
// --queued to "try --queued" reads as a broken command.
|
|
721
|
+
const scope =
|
|
722
|
+
opts.all === true
|
|
723
|
+
? 'builds'
|
|
724
|
+
: opts.queued === true
|
|
725
|
+
? 'active or queued builds'
|
|
726
|
+
: 'active builds'
|
|
727
|
+
const hint =
|
|
728
|
+
opts.all === true ? '' : opts.queued === true ? ' — try --all' : ' — try --queued or --all'
|
|
729
|
+
for (const line of renderSummaries(summaries, now, `no ${scope} for ${repo}${hint}`)) {
|
|
730
|
+
opts.stdout(line)
|
|
731
|
+
}
|
|
732
|
+
})
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
export interface AbBuildStatusOpts extends StatusOpts {
|
|
736
|
+
slug: string
|
|
737
|
+
events?: number
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function openWorkspacePath(events: AbEvent[]): string | undefined {
|
|
741
|
+
let path: string | undefined
|
|
742
|
+
for (const event of events) {
|
|
743
|
+
if (event.type === 'workspace.provisioned') {
|
|
744
|
+
path = event.payload.path ?? event.payload.ref
|
|
745
|
+
} else if (event.type === 'workspace.released') {
|
|
746
|
+
path = undefined
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
return path
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/** The kernel tail both config sources share, so the deposited artifact and
|
|
753
|
+
* the workspace file cannot drift in what they compute — the sources differ
|
|
754
|
+
* only in where the config comes from, never in what is decided with it. */
|
|
755
|
+
function decideFromConfig(
|
|
756
|
+
events: AbEvent[],
|
|
757
|
+
state: BuildState,
|
|
758
|
+
config: Config,
|
|
759
|
+
): BuildDecisionProjection {
|
|
760
|
+
const decision = decideNext(events, config)
|
|
761
|
+
if (decision.kind !== 'wait') return { kind: 'runner-work' }
|
|
762
|
+
if (decision.reason !== 'awaiting-pr') return { kind: 'parked', reason: decision.reason }
|
|
763
|
+
const reason = currentAutoMergeDeferral(events, state)
|
|
764
|
+
return { kind: 'awaiting-pr', ...(reason !== undefined ? { reason } : {}) }
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/** Load the build-owned configuration for the current work-owner decision.
|
|
768
|
+
*
|
|
769
|
+
* Source order: the store's deposited `build-runner-effective-config` artifact
|
|
770
|
+
* first — the exact config the dispatcher froze at the last runner launch and
|
|
771
|
+
* the one the build child actually runs on — then the local workspace's
|
|
772
|
+
* `autobuild.toml` when no deposit exists (older records predate per-launch
|
|
773
|
+
* deposits). For a remote build the recorded workspace path is a guest path
|
|
774
|
+
* that does not exist on the operator's machine; the deposit is what makes the
|
|
775
|
+
* decision available there. The decision is reported unavailable only when
|
|
776
|
+
* neither source can supply a configuration, and the diagnostic then names the
|
|
777
|
+
* missing source rather than a filesystem path: paths embed guest locations
|
|
778
|
+
* the operator cannot access. An unavailable decision is a reportable
|
|
779
|
+
* degradation, never a reason to guess from the current main branch or to make
|
|
780
|
+
* the read-only command fail. */
|
|
781
|
+
async function projectBuildDecision(
|
|
782
|
+
slug: string,
|
|
783
|
+
events: AbEvent[],
|
|
784
|
+
store: Pick<BuildStore, 'getArtifact'>,
|
|
785
|
+
): Promise<BuildDecisionProjection | undefined> {
|
|
786
|
+
const state = reduceBuild(events)
|
|
787
|
+
const active =
|
|
788
|
+
state.status === 'running' || state.status === 'paused' || state.status === 'blocked'
|
|
789
|
+
if (!active) return undefined
|
|
790
|
+
if (state.prState === 'merged' || state.prState === 'closed') {
|
|
791
|
+
return { kind: 'repository-completion', prState: state.prState }
|
|
792
|
+
}
|
|
793
|
+
if (state.prState !== 'open') return undefined
|
|
794
|
+
|
|
795
|
+
// Source 1 — the store deposit. A deposit that fails to parse is itself
|
|
796
|
+
// reportable state: it is surfaced as unavailable naming the artifact rather
|
|
797
|
+
// than silently falling back to a possibly stale workspace file.
|
|
798
|
+
const artifact = await store.getArtifact(slug, BUILD_EFFECTIVE_CONFIG_ARTIFACT)
|
|
799
|
+
if (artifact !== null) {
|
|
800
|
+
try {
|
|
801
|
+
return decideFromConfig(events, state, parseEffectiveBuildConfig(artifact))
|
|
802
|
+
} catch (error) {
|
|
803
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
804
|
+
return {
|
|
805
|
+
kind: 'unavailable',
|
|
806
|
+
diagnostic: `the deposited ${BUILD_EFFECTIVE_CONFIG_ARTIFACT} artifact could not be read: ${message}`,
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// Source 2 — the local workspace, exactly as before, only when no artifact
|
|
812
|
+
// is deposited.
|
|
813
|
+
const workspace = openWorkspacePath(events)
|
|
814
|
+
if (workspace === undefined) {
|
|
815
|
+
return {
|
|
816
|
+
kind: 'unavailable',
|
|
817
|
+
diagnostic: `no ${BUILD_EFFECTIVE_CONFIG_ARTIFACT} artifact is deposited and no unreleased build workspace is recorded, so the build config cannot be read`,
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
try {
|
|
822
|
+
return decideFromConfig(events, state, await loadConfig(join(workspace, 'autobuild.toml')))
|
|
823
|
+
} catch {
|
|
824
|
+
// The raw error deliberately stays here: ENOENT/ENOTDIR messages embed the
|
|
825
|
+
// recorded workspace path, which for a remote build is a guest location.
|
|
826
|
+
return {
|
|
827
|
+
kind: 'unavailable',
|
|
828
|
+
diagnostic: `no ${BUILD_EFFECTIVE_CONFIG_ARTIFACT} artifact is deposited and the build config in the recorded workspace could not be read`,
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/** `ab build status <slug>` — one build in detail. Read-only.
|
|
834
|
+
*
|
|
835
|
+
* Repository authority mirrors the other ambient reads: an operator invocation
|
|
836
|
+
* (no ambient identity) must match the store-recorded repository by path, or
|
|
837
|
+
* by normalized origin for a differently located checkout of the same
|
|
838
|
+
* repository. A validated ambient *build* session additionally authorizes
|
|
839
|
+
* reading its OWN build — `AB_BUILD` equal to the requested slug, from the
|
|
840
|
+
* session's own store — the same trust basis as the phase-scoped
|
|
841
|
+
* `ab artifact get` (AUT-312): the runner-injected tuple validated by
|
|
842
|
+
* `resolveCliEnv` is the authority, so a guest checkout of the same
|
|
843
|
+
* repository at a different path can read its build's status projection even
|
|
844
|
+
* when the record predates origin recording. The bypass is bounded by slug
|
|
845
|
+
* equality and store-ref equality, never a general relaxation; a sessionless
|
|
846
|
+
* relocated-checkout read of a record without `repoOrigin` remains foreign by
|
|
847
|
+
* design (the documented AUT-314 limitation).
|
|
848
|
+
*/
|
|
849
|
+
export async function abBuildStatus(opts: AbBuildStatusOpts): Promise<void> {
|
|
850
|
+
const now = (opts.now ?? (() => new Date()))()
|
|
851
|
+
await withAmbientReadStore(opts, async (context) => {
|
|
852
|
+
const record = await context.store.getBuild(opts.slug)
|
|
853
|
+
if (record === null) {
|
|
854
|
+
throw new Error(
|
|
855
|
+
`no build "${opts.slug}" in this store — run 'ab builds --all' to list ` +
|
|
856
|
+
"this repo's builds, or pass --store <ref> if it lives in another store",
|
|
857
|
+
)
|
|
858
|
+
}
|
|
859
|
+
// Own-session authority: slug equality bounds the read to the session's
|
|
860
|
+
// own build, and an explicit --store must name the session's own store for
|
|
861
|
+
// the bypass to apply. Every other combination falls through to the
|
|
862
|
+
// ordinary repository gate, unchanged — which now keys on the checkout's
|
|
863
|
+
// normalized origin identity (`context.checkout` is the physical path;
|
|
864
|
+
// `buildInRepository` derives the identity from it).
|
|
865
|
+
const ownSession =
|
|
866
|
+
context.ambient !== undefined &&
|
|
867
|
+
'build' in context.ambient &&
|
|
868
|
+
context.ambient.build === opts.slug &&
|
|
869
|
+
(opts.storeRef === undefined || opts.storeRef === context.ambient.store)
|
|
870
|
+
if (!(ownSession || (await buildInRepository(record, context.checkout, opts.exec)))) {
|
|
871
|
+
throw new Error(
|
|
872
|
+
`build "${opts.slug}" belongs to repository "${record.repo}", not "${context.repo}"`,
|
|
873
|
+
)
|
|
874
|
+
}
|
|
875
|
+
const events = await context.store.getEvents(opts.slug)
|
|
876
|
+
const decision = await projectBuildDecision(opts.slug, events, context.store)
|
|
877
|
+
// Stream enrichment is presentation (SPEC §9): an unavailable read
|
|
878
|
+
// degrades to payload-carried ids without a status, never a failure.
|
|
879
|
+
let streams: StreamRecord[] | undefined
|
|
880
|
+
try {
|
|
881
|
+
streams = await context.store.listStreams({
|
|
882
|
+
kind: 'build',
|
|
883
|
+
build: opts.slug,
|
|
884
|
+
} satisfies StreamScope)
|
|
885
|
+
} catch {
|
|
886
|
+
streams = undefined
|
|
887
|
+
}
|
|
888
|
+
const d = detail(record, events, now, opts.events, decision, streams)
|
|
889
|
+
if (opts.json === true) {
|
|
890
|
+
opts.stdout(JSON.stringify(d, null, 2))
|
|
891
|
+
return
|
|
892
|
+
}
|
|
893
|
+
for (const line of renderDetail(d, now)) opts.stdout(line)
|
|
894
|
+
})
|
|
895
|
+
}
|