@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,932 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ab watch` — the sessionless, read-only streaming half of the status family
|
|
3
|
+
* (§8.2, §7.2). It follows the event logs of one, several, or all active
|
|
4
|
+
* builds in the repository — optionally the repository journal — and emits one
|
|
5
|
+
* record per matching event as it lands, each carrying the full durable
|
|
6
|
+
* envelope, the build's reduced state after that event, and an opaque cursor
|
|
7
|
+
* so a later `--since` invocation resumes exactly where this one stopped.
|
|
8
|
+
*
|
|
9
|
+
* Like `ab builds` / `ab build status`, the projection is pure and the IO is a
|
|
10
|
+
* thin shell. The command composes the existing read surface — `listBuilds`,
|
|
11
|
+
* `getEvents(since)`, `getRepoEvents(since)`, `getBuild` — and never widens
|
|
12
|
+
* `BuildStore`: no event is appended, no lease claimed, no record created. It
|
|
13
|
+
* deliberately does not reuse `pollingSubscribe` (store/subscribe.ts): that
|
|
14
|
+
* helper is single-stream, fixed-membership, and build-only, while `watch`
|
|
15
|
+
* needs dynamic membership (builds created mid-watch join), the repository
|
|
16
|
+
* journal, per-stream error reporting, and a resumable cursor. Delivery
|
|
17
|
+
* semantics (in-order, exactly once per stream) are reimplemented locally for
|
|
18
|
+
* the same reason.
|
|
19
|
+
*
|
|
20
|
+
* Cadence follows the store kind (AUT-334). Against a local store the loop is
|
|
21
|
+
* the sequential interval tick. Against an `http(s)` store every tracked
|
|
22
|
+
* stream long-polls instead: one held `getEvents`/`getRepoEvents` request per
|
|
23
|
+
* stream is always in flight (tasks run concurrently), an appended event is
|
|
24
|
+
* seen within about a second of its append, and gap-fill keeps request starts
|
|
25
|
+
* at least `--interval` apart, so a quiet stream costs one request per wait
|
|
26
|
+
* window — not one per interval. Discovery keeps its interval cadence. Every
|
|
27
|
+
* stop is prompt: an abort, a tripped --count, an all-terminal named set, or
|
|
28
|
+
* an elapsed --timeout cancels the in-flight held reads instead of waiting
|
|
29
|
+
* out a full hold, and a hold never outlives the --timeout deadline.
|
|
30
|
+
*
|
|
31
|
+
* The cursor is `"v1." + base64url(JSON)` of the per-stream sequence position
|
|
32
|
+
* plus the resolved store reference and repository identity. It is opaque to
|
|
33
|
+
* callers — the format is not part of the contract — and a cursor from a
|
|
34
|
+
* different store or repository is rejected before any read. Non-matching
|
|
35
|
+
* events still advance their stream's position, so a resume never rescans or
|
|
36
|
+
* redelivers them.
|
|
37
|
+
*
|
|
38
|
+
* Output carries no ANSI, ever, in either form (§16).
|
|
39
|
+
*/
|
|
40
|
+
import type { AbEvent } from '../events/catalog'
|
|
41
|
+
import { EVENT_TYPES, type EventType } from '../events/payloads'
|
|
42
|
+
import {
|
|
43
|
+
REPOSITORY_EVENT_TYPES,
|
|
44
|
+
type RepositoryEvent,
|
|
45
|
+
type RepositoryEventType,
|
|
46
|
+
} from '../events/repository'
|
|
47
|
+
import type { BuildState, PrLifecycle } from '../kernel/reducer'
|
|
48
|
+
import { reduceBuild } from '../kernel/reducer'
|
|
49
|
+
import type { BuildOutcome, BuildStatus, Phase } from '../ontology'
|
|
50
|
+
import type { Exec } from '../ports/workspace/git-worktree'
|
|
51
|
+
import { PhaseSessionError } from '../store/phase-session'
|
|
52
|
+
import { REMOTE_EVENT_WAIT_SECONDS } from '../store/remote/client'
|
|
53
|
+
import type { BuildRecord } from '../store/types'
|
|
54
|
+
import { resolveAmbientReadSession } from './env'
|
|
55
|
+
import { buildInRepository, isRemoteStoreRef, normalizeGitRemoteUrl } from './repo-state'
|
|
56
|
+
import { withAmbientReadStore, type StoreOpener } from './store-opening'
|
|
57
|
+
|
|
58
|
+
export const WATCH_USAGE =
|
|
59
|
+
'usage: ab watch [<slug>...] [--repository] [--event <glob>]... [--since <cursor>] ' +
|
|
60
|
+
'[--timeout <duration>] [--interval <duration>] [--count <n>] [--json] [--store <ref>]'
|
|
61
|
+
|
|
62
|
+
/** Default poll cadence and watch horizon. */
|
|
63
|
+
export const DEFAULT_REMOTE_INTERVAL_MS = 5_000
|
|
64
|
+
export const DEFAULT_LOCAL_INTERVAL_MS = 1_000
|
|
65
|
+
const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000
|
|
66
|
+
|
|
67
|
+
/** The repository-journal stream's key in the cursor's per-stream map. */
|
|
68
|
+
const REPO_STREAM_KEY = '#repo'
|
|
69
|
+
|
|
70
|
+
/** Statuses a build can still leave; the others are terminal (§15.5). */
|
|
71
|
+
const NONTERMINAL_STATUSES: readonly BuildStatus[] = ['queued', 'running', 'paused', 'blocked']
|
|
72
|
+
const TERMINAL_STATUSES: readonly BuildStatus[] = ['done', 'aborted']
|
|
73
|
+
|
|
74
|
+
// ── The default filter: the attention set ────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The build events an operator or agent must wake up for. With no `--event`,
|
|
78
|
+
* a watch emits exactly these — deciding what to do about a blocker or a
|
|
79
|
+
* merge stays with the caller's skill.
|
|
80
|
+
*/
|
|
81
|
+
export const BUILD_ATTENTION_EVENTS = [
|
|
82
|
+
'escalation.raised',
|
|
83
|
+
'phase.failed',
|
|
84
|
+
'infrastructure.failed',
|
|
85
|
+
'runner.setup-failed',
|
|
86
|
+
'dispatch.failed',
|
|
87
|
+
'publication.lost',
|
|
88
|
+
'finalize.completed',
|
|
89
|
+
'pr.conflicted',
|
|
90
|
+
'pr.merged',
|
|
91
|
+
'pr.closed',
|
|
92
|
+
'build.completed',
|
|
93
|
+
'build.aborted',
|
|
94
|
+
] as const satisfies readonly EventType[]
|
|
95
|
+
|
|
96
|
+
/** The repository-journal attention set, followed only under `--repository`. */
|
|
97
|
+
export const REPOSITORY_ATTENTION_EVENTS = [
|
|
98
|
+
'harvest.escalated',
|
|
99
|
+
'harvest.failed',
|
|
100
|
+
'dispatcher.tick-failed',
|
|
101
|
+
'dispatcher.config-rejected',
|
|
102
|
+
'dispatcher.harvest-runner-failed',
|
|
103
|
+
] as const satisfies readonly RepositoryEventType[]
|
|
104
|
+
|
|
105
|
+
// ── Glob compilation and matching ────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Compile `--event` globs to anchored matchers: `*` matches any run of
|
|
109
|
+
* characters, `?` exactly one, everything else verbatim (so the dots inside
|
|
110
|
+
* event type names are escaped). Each glob must match at least one type in
|
|
111
|
+
* the build catalog — or, when `--repository` is given, in the repository
|
|
112
|
+
* catalog — otherwise a usage error naming the glob is thrown before any
|
|
113
|
+
* store read. Supplying any glob replaces the attention set entirely.
|
|
114
|
+
*/
|
|
115
|
+
export function compileEventGlobs(
|
|
116
|
+
globs: readonly string[],
|
|
117
|
+
opts: { repository: boolean; usage: string },
|
|
118
|
+
): RegExp[] {
|
|
119
|
+
return globs.map((glob) => {
|
|
120
|
+
const source =
|
|
121
|
+
'^' +
|
|
122
|
+
[...glob]
|
|
123
|
+
.map((char) => {
|
|
124
|
+
if (char === '*') return '.*'
|
|
125
|
+
if (char === '?') return '.'
|
|
126
|
+
return char.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
127
|
+
})
|
|
128
|
+
.join('') +
|
|
129
|
+
'$'
|
|
130
|
+
const regex = new RegExp(source)
|
|
131
|
+
const inBuildCatalog = EVENT_TYPES.some((type) => regex.test(type))
|
|
132
|
+
const inRepositoryCatalog =
|
|
133
|
+
opts.repository && REPOSITORY_EVENT_TYPES.some((type) => regex.test(type))
|
|
134
|
+
if (!inBuildCatalog && !inRepositoryCatalog) {
|
|
135
|
+
throw new Error(
|
|
136
|
+
`--event "${glob}" matches no known event type${
|
|
137
|
+
opts.repository ? ' in the build or repository catalogs' : ' in the build event catalog'
|
|
138
|
+
} — ${opts.usage}`,
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
return regex
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function matchesBuildFilter(type: EventType, filters: readonly RegExp[]): boolean {
|
|
146
|
+
return filters.length > 0
|
|
147
|
+
? filters.some((regex) => regex.test(type))
|
|
148
|
+
: (BUILD_ATTENTION_EVENTS as readonly string[]).includes(type)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function matchesRepositoryFilter(type: RepositoryEventType, filters: readonly RegExp[]): boolean {
|
|
152
|
+
return filters.length > 0
|
|
153
|
+
? filters.some((regex) => regex.test(type))
|
|
154
|
+
: (REPOSITORY_ATTENTION_EVENTS as readonly string[]).includes(type)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── The cursor ───────────────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
interface CursorPayload {
|
|
160
|
+
v: 1
|
|
161
|
+
/** The resolved store reference this cursor's positions belong to. */
|
|
162
|
+
store: string
|
|
163
|
+
/** The repository identity (normalized origin or checkout path). */
|
|
164
|
+
repo: string
|
|
165
|
+
/** Per-stream watch position: build slug → seq, `#repo` → seq. */
|
|
166
|
+
streams: Record<string, number>
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const CURSOR_PREFIX = 'v1.'
|
|
170
|
+
|
|
171
|
+
/** Opaque to callers; the format is internal, not contract. */
|
|
172
|
+
export function encodeCursor(payload: CursorPayload): string {
|
|
173
|
+
return CURSOR_PREFIX + Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url')
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Decode and authorize a `--since` cursor against this watch's resolved store
|
|
178
|
+
* and repository — before any store read. Malformed input, and cursors from a
|
|
179
|
+
* different store or repository, each get a distinct rejection naming what
|
|
180
|
+
* mismatched.
|
|
181
|
+
*/
|
|
182
|
+
export function decodeCursor(
|
|
183
|
+
cursor: string,
|
|
184
|
+
expected: { store: string; repo: string },
|
|
185
|
+
opts: { verb: string; usage: string } = { verb: 'watch', usage: WATCH_USAGE },
|
|
186
|
+
): CursorPayload {
|
|
187
|
+
const malformed = (): Error =>
|
|
188
|
+
new Error(
|
|
189
|
+
'invalid --since cursor: malformed — a cursor is an opaque string emitted by a ' +
|
|
190
|
+
`previous 'ab ${opts.verb}' run — ${opts.usage}`,
|
|
191
|
+
)
|
|
192
|
+
if (!cursor.startsWith(CURSOR_PREFIX)) throw malformed()
|
|
193
|
+
let payload: unknown
|
|
194
|
+
try {
|
|
195
|
+
payload = JSON.parse(
|
|
196
|
+
Buffer.from(cursor.slice(CURSOR_PREFIX.length), 'base64url').toString('utf8'),
|
|
197
|
+
)
|
|
198
|
+
} catch {
|
|
199
|
+
throw malformed()
|
|
200
|
+
}
|
|
201
|
+
if (typeof payload !== 'object' || payload === null) throw malformed()
|
|
202
|
+
const { v, store, repo, streams } = payload as Record<string, unknown>
|
|
203
|
+
if (v !== 1 || typeof store !== 'string' || typeof repo !== 'string') throw malformed()
|
|
204
|
+
if (typeof streams !== 'object' || streams === null || Array.isArray(streams)) throw malformed()
|
|
205
|
+
for (const seq of Object.values(streams)) {
|
|
206
|
+
if (typeof seq !== 'number' || !Number.isInteger(seq) || seq < 0) throw malformed()
|
|
207
|
+
}
|
|
208
|
+
if (store !== expected.store) {
|
|
209
|
+
throw new Error(
|
|
210
|
+
`invalid --since cursor: it was created against store "${store}", but this watch ` +
|
|
211
|
+
`reads store "${expected.store}" — resume from the same --store the cursor came from`,
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
if (repo !== expected.repo) {
|
|
215
|
+
throw new Error(
|
|
216
|
+
`invalid --since cursor: it was created for repository "${repo}", but this watch reads ` +
|
|
217
|
+
`repository "${expected.repo}" — run from the repository the cursor came from`,
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
return { v: 1, store, repo, streams: streams as Record<string, number> }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ── Durations and cadence ────────────────────────────────────────────────────
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* `30`, `45s`, `5m`, `1h` — a positive number of seconds with an optional
|
|
227
|
+
* unit suffix; a bare number means seconds (the `ab dispatch --interval`
|
|
228
|
+
* convention). `0` is accepted only where the caller says so (`--timeout 0`
|
|
229
|
+
* means unbounded; `--interval 0` is a usage error).
|
|
230
|
+
*/
|
|
231
|
+
export function parseDurationMs(
|
|
232
|
+
text: string,
|
|
233
|
+
opts: { flag: string; usage: string; allowZero?: boolean },
|
|
234
|
+
): number {
|
|
235
|
+
const match = /^(\d+)([smh]?)$/.exec(text)
|
|
236
|
+
const invalid = (): Error =>
|
|
237
|
+
new Error(
|
|
238
|
+
`${opts.flag} requires a duration — a positive number of seconds, optionally suffixed ` +
|
|
239
|
+
`s, m, or h (for example 30, 45s, 5m, 1h)${
|
|
240
|
+
opts.allowZero === true ? '; 0 means unbounded' : ''
|
|
241
|
+
} — ${opts.usage}`,
|
|
242
|
+
)
|
|
243
|
+
if (match === null) throw invalid()
|
|
244
|
+
const amount = Number(match[1])
|
|
245
|
+
if (amount === 0 && opts.allowZero !== true) throw invalid()
|
|
246
|
+
const unitMs = match[2] === 'm' ? 60_000 : match[2] === 'h' ? 3_600_000 : 1_000 // '' and 's' both mean seconds
|
|
247
|
+
return amount * unitMs
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Default poll interval by store kind: patient remotely, prompt locally. */
|
|
251
|
+
export function watchSelection(storeRef: string): { intervalMs: number } {
|
|
252
|
+
return {
|
|
253
|
+
intervalMs: isRemoteStoreRef(storeRef) ? DEFAULT_REMOTE_INTERVAL_MS : DEFAULT_LOCAL_INTERVAL_MS,
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ── The record projection ────────────────────────────────────────────────────
|
|
258
|
+
|
|
259
|
+
/** The reduction after one event — the exact fields the spec names. */
|
|
260
|
+
export interface WatchRecordState {
|
|
261
|
+
status: BuildStatus
|
|
262
|
+
phase: Phase | null
|
|
263
|
+
round: number
|
|
264
|
+
openEscalations: { id: string; question: string }[]
|
|
265
|
+
pr: { number: number; url: string; headSha: string } | null
|
|
266
|
+
prState: PrLifecycle | null
|
|
267
|
+
outcome: BuildOutcome | null
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export interface WatchRecord {
|
|
271
|
+
/** The build slug, or null for a repository-journal event. */
|
|
272
|
+
build: string | null
|
|
273
|
+
/** The repository identity — present only for repository events. */
|
|
274
|
+
repo?: string
|
|
275
|
+
/** The complete durable envelope, passed through unchanged. */
|
|
276
|
+
event: AbEvent | RepositoryEvent
|
|
277
|
+
/** For build events, the reduction after this event; null for repository events. */
|
|
278
|
+
state: WatchRecordState | null
|
|
279
|
+
/** The watch position after this event — resume here with --since. */
|
|
280
|
+
cursor: string
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function projectState(state: BuildState): WatchRecordState {
|
|
284
|
+
return {
|
|
285
|
+
status: state.status,
|
|
286
|
+
phase: state.phase ?? null,
|
|
287
|
+
round: state.round,
|
|
288
|
+
openEscalations: state.openEscalations.map((escalation) => ({
|
|
289
|
+
id: escalation.id,
|
|
290
|
+
question: escalation.question,
|
|
291
|
+
})),
|
|
292
|
+
pr: state.pr ?? null,
|
|
293
|
+
prState: state.prState ?? null,
|
|
294
|
+
outcome: state.outcome ?? null,
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** `--json` form: exactly one object per matching event. */
|
|
299
|
+
export function projectRecord(
|
|
300
|
+
event: AbEvent | RepositoryEvent,
|
|
301
|
+
state: BuildState | null,
|
|
302
|
+
cursor: string,
|
|
303
|
+
): WatchRecord {
|
|
304
|
+
if ('build' in event) {
|
|
305
|
+
return {
|
|
306
|
+
build: event.build,
|
|
307
|
+
event,
|
|
308
|
+
state: state === null ? null : projectState(state),
|
|
309
|
+
cursor,
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return { build: null, repo: event.repo, event, state: null, cursor }
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Preserve printable text while making control bytes (and ANSI) inert. */
|
|
316
|
+
function singleLine(value: string): string {
|
|
317
|
+
let displayed = ''
|
|
318
|
+
for (const char of value) {
|
|
319
|
+
const code = char.codePointAt(0)!
|
|
320
|
+
displayed += code >= 0x20 && code !== 0x7f ? char : `\\u{${code.toString(16)}}`
|
|
321
|
+
}
|
|
322
|
+
return displayed
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const SUMMARY_MAX = 200
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* A payload-derived one-liner per attention type; unknown types (custom
|
|
329
|
+
* `--event` globs) get a compact single-line payload.
|
|
330
|
+
*/
|
|
331
|
+
export function summarizeEvent(event: AbEvent | RepositoryEvent, state: BuildState | null): string {
|
|
332
|
+
switch (event.type) {
|
|
333
|
+
case 'escalation.raised':
|
|
334
|
+
return `${event.payload.id}: ${singleLine(event.payload.question)}`
|
|
335
|
+
case 'phase.failed':
|
|
336
|
+
return `${event.payload.phase}${
|
|
337
|
+
event.payload.round !== undefined ? ` r${event.payload.round}` : ''
|
|
338
|
+
} attempt ${event.payload.attempt} failed${
|
|
339
|
+
event.payload.willRetry ? ' (will retry)' : ''
|
|
340
|
+
}: ${singleLine(event.payload.error)}`
|
|
341
|
+
case 'infrastructure.failed':
|
|
342
|
+
return `${event.payload.operation} failed on ${event.payload.provider} (attempt ${
|
|
343
|
+
event.payload.attempt
|
|
344
|
+
}, ${event.payload.retryable ? 'retryable' : 'not retryable'}): ${singleLine(
|
|
345
|
+
event.payload.error,
|
|
346
|
+
)}`
|
|
347
|
+
case 'runner.setup-failed':
|
|
348
|
+
return `setup attempt ${event.payload.attempt} failed: ${singleLine(event.payload.command)}`
|
|
349
|
+
case 'dispatch.failed':
|
|
350
|
+
return `${event.payload.stage} attempt ${event.payload.attempt} failed: ${singleLine(
|
|
351
|
+
event.payload.error,
|
|
352
|
+
)}`
|
|
353
|
+
case 'publication.lost':
|
|
354
|
+
return `${event.payload.operation} publication lost: ${singleLine(event.payload.reason)}`
|
|
355
|
+
case 'finalize.completed':
|
|
356
|
+
return `PR #${event.payload.pr.number} opened ${event.payload.pr.url}`
|
|
357
|
+
case 'pr.conflicted':
|
|
358
|
+
return state?.pr !== undefined
|
|
359
|
+
? `PR #${state.pr.number} conflicts with its base`
|
|
360
|
+
: 'PR conflicts with its base'
|
|
361
|
+
case 'pr.merged':
|
|
362
|
+
return state?.pr !== undefined ? `PR #${state.pr.number} merged` : 'PR merged'
|
|
363
|
+
case 'pr.closed':
|
|
364
|
+
return state?.pr !== undefined ? `PR #${state.pr.number} closed` : 'PR closed'
|
|
365
|
+
case 'build.completed':
|
|
366
|
+
return `build completed (${event.payload.outcome})`
|
|
367
|
+
case 'build.aborted':
|
|
368
|
+
return 'build aborted'
|
|
369
|
+
case 'harvest.escalated':
|
|
370
|
+
return `harvest run ${event.payload.run} escalated: ${singleLine(event.payload.reason)}`
|
|
371
|
+
case 'harvest.failed':
|
|
372
|
+
return `harvest run ${event.payload.run} failed at ${event.payload.step} (attempt ${event.payload.attempt}): ${singleLine(event.payload.error)}`
|
|
373
|
+
case 'dispatcher.tick-failed':
|
|
374
|
+
return `dispatcher tick failed: ${singleLine(event.payload.error)}`
|
|
375
|
+
case 'dispatcher.config-rejected':
|
|
376
|
+
return `dispatcher config rejected: ${singleLine(event.payload.error)}`
|
|
377
|
+
case 'dispatcher.harvest-runner-failed':
|
|
378
|
+
return `harvest runner failed: ${singleLine(event.payload.error)}`
|
|
379
|
+
default: {
|
|
380
|
+
const compact = singleLine(JSON.stringify(event.payload))
|
|
381
|
+
return compact.length > SUMMARY_MAX ? `${compact.slice(0, SUMMARY_MAX - 1)}…` : compact
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** The human form: one line, timestamp first, no ANSI anywhere. */
|
|
387
|
+
export function renderWatchLine(
|
|
388
|
+
event: AbEvent | RepositoryEvent,
|
|
389
|
+
state: BuildState | null,
|
|
390
|
+
): string {
|
|
391
|
+
const label = 'build' in event ? event.build : '(repository)'
|
|
392
|
+
return `${event.ts} ${label} ${event.type} ${summarizeEvent(event, state)}`
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ── The ambient-scope preflight ─────────────────────────────────────────────
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Ambient scope, before any store access: inside a phase with a complete
|
|
399
|
+
* build identity, only the ambient build may be read. The errors are the same
|
|
400
|
+
* PhaseSessionError shapes the scoped store handle produces for `ab builds` /
|
|
401
|
+
* `ab build status`, so the message matches exactly. Shared by `ab watch` and
|
|
402
|
+
* `ab wait`; `repository` is true only for watch's `--repository` form.
|
|
403
|
+
*/
|
|
404
|
+
export function assertReadScope(
|
|
405
|
+
slugs: readonly string[],
|
|
406
|
+
opts: { repository: boolean; env: Record<string, string | undefined> },
|
|
407
|
+
): void {
|
|
408
|
+
const ambient = resolveAmbientReadSession(opts.env)
|
|
409
|
+
if (ambient !== undefined && 'build' in ambient) {
|
|
410
|
+
const scope = { kind: 'build' as const, id: ambient.build, session: ambient.session }
|
|
411
|
+
if (slugs.length === 0 || opts.repository) {
|
|
412
|
+
throw new PhaseSessionError(scope, 'listBuilds', { kind: 'admin' })
|
|
413
|
+
}
|
|
414
|
+
for (const slug of slugs) {
|
|
415
|
+
if (slug !== ambient.build) {
|
|
416
|
+
throw new PhaseSessionError(scope, 'getEvents', { kind: 'build', id: slug })
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// ── The command shell ────────────────────────────────────────────────────────
|
|
423
|
+
|
|
424
|
+
export interface AbWatchOpts {
|
|
425
|
+
targetRepo: string
|
|
426
|
+
/** Raw process environment: --store > AB_STORE > repository default (D8). */
|
|
427
|
+
env: Record<string, string | undefined>
|
|
428
|
+
exec: Exec
|
|
429
|
+
stdout: (line: string) => void
|
|
430
|
+
stderr: (line: string) => void
|
|
431
|
+
/** Positional build slugs; none means every nonterminal build in the repo. */
|
|
432
|
+
slugs?: readonly string[]
|
|
433
|
+
/** Also follow the repository journal (dispatcher and harvest events). */
|
|
434
|
+
repository?: boolean
|
|
435
|
+
/** Repeatable `--event` globs; replaces the attention set. */
|
|
436
|
+
events?: readonly string[]
|
|
437
|
+
/** A cursor from an earlier record: resume exactly where that watch stopped. */
|
|
438
|
+
since?: string
|
|
439
|
+
/** `--timeout` duration text; default 30 minutes, `0` unbounded. */
|
|
440
|
+
timeout?: string
|
|
441
|
+
/** `--interval` duration text; default 5s remote, 1s local. */
|
|
442
|
+
interval?: string
|
|
443
|
+
/** `--count`, validated as a positive integer by the route. */
|
|
444
|
+
count?: number
|
|
445
|
+
json?: boolean
|
|
446
|
+
storeRef?: string
|
|
447
|
+
openStore?: StoreOpener
|
|
448
|
+
/** Injectable clock; defaults to the wall clock. */
|
|
449
|
+
now?: () => Date
|
|
450
|
+
/** Injectable tick-gap sleep so tests drive ticks deterministically. */
|
|
451
|
+
delay?: (ms: number, signal?: AbortSignal) => Promise<void>
|
|
452
|
+
/** Stop signal; the generic sessionless SIGINT handler aborts it. */
|
|
453
|
+
signal?: AbortSignal
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
interface BuildStream {
|
|
457
|
+
kind: 'build'
|
|
458
|
+
slug: string
|
|
459
|
+
named: boolean
|
|
460
|
+
/** The full prefix, appended in seq order — what `reduceBuild` runs over. */
|
|
461
|
+
events: AbEvent[]
|
|
462
|
+
lastSeq: number
|
|
463
|
+
status: BuildStatus
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
interface RepositoryStream {
|
|
467
|
+
kind: 'repo'
|
|
468
|
+
lastSeq: number
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
type Stream = BuildStream | RepositoryStream
|
|
472
|
+
|
|
473
|
+
function defaultDelay(ms: number, signal?: AbortSignal): Promise<void> {
|
|
474
|
+
return new Promise((resolve) => {
|
|
475
|
+
if (signal?.aborted === true) {
|
|
476
|
+
resolve()
|
|
477
|
+
return
|
|
478
|
+
}
|
|
479
|
+
const finish = (): void => {
|
|
480
|
+
clearTimeout(timer)
|
|
481
|
+
signal?.removeEventListener('abort', onAbort)
|
|
482
|
+
resolve()
|
|
483
|
+
}
|
|
484
|
+
const timer = setTimeout(finish, ms)
|
|
485
|
+
const onAbort = (): void => finish()
|
|
486
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
487
|
+
})
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* `ab watch` — stream matching build and repository events until a timeout, a
|
|
492
|
+
* record count, or (only when slugs were named) terminal status ends the
|
|
493
|
+
* watch. Read-only: it appends no event, takes no lease, and creates no
|
|
494
|
+
* record.
|
|
495
|
+
*/
|
|
496
|
+
export async function abWatch(opts: AbWatchOpts): Promise<void> {
|
|
497
|
+
const now = opts.now ?? (() => new Date())
|
|
498
|
+
const sleep = opts.delay ?? defaultDelay
|
|
499
|
+
const slugs = [...(opts.slugs ?? [])]
|
|
500
|
+
const repository = opts.repository === true
|
|
501
|
+
|
|
502
|
+
const timeoutMs =
|
|
503
|
+
opts.timeout !== undefined
|
|
504
|
+
? parseDurationMs(opts.timeout, { flag: '--timeout', usage: WATCH_USAGE, allowZero: true })
|
|
505
|
+
: DEFAULT_TIMEOUT_MS
|
|
506
|
+
const intervalMs =
|
|
507
|
+
opts.interval !== undefined
|
|
508
|
+
? parseDurationMs(opts.interval, { flag: '--interval', usage: WATCH_USAGE })
|
|
509
|
+
: undefined
|
|
510
|
+
|
|
511
|
+
// Ambient scope, before any store access.
|
|
512
|
+
assertReadScope(slugs, { repository, env: opts.env })
|
|
513
|
+
|
|
514
|
+
// Glob validation needs only the catalogs — before any store access.
|
|
515
|
+
const filters = compileEventGlobs(opts.events ?? [], { repository, usage: WATCH_USAGE })
|
|
516
|
+
|
|
517
|
+
await withAmbientReadStore(
|
|
518
|
+
{
|
|
519
|
+
targetRepo: opts.targetRepo,
|
|
520
|
+
env: opts.env,
|
|
521
|
+
exec: opts.exec,
|
|
522
|
+
...(opts.storeRef !== undefined ? { storeRef: opts.storeRef } : {}),
|
|
523
|
+
...(opts.openStore !== undefined ? { openStore: opts.openStore } : {}),
|
|
524
|
+
},
|
|
525
|
+
async (context) => {
|
|
526
|
+
const { store, repo, storeRef, checkout } = context
|
|
527
|
+
const { exec } = opts
|
|
528
|
+
const interval = intervalMs ?? watchSelection(storeRef).intervalMs
|
|
529
|
+
|
|
530
|
+
// Decode --since before any read: a foreign cursor is rejected outright.
|
|
531
|
+
const sinceStreams =
|
|
532
|
+
opts.since !== undefined ? decodeCursor(opts.since, { store: storeRef, repo }).streams : {}
|
|
533
|
+
|
|
534
|
+
const identity = normalizeGitRemoteUrl(repo)
|
|
535
|
+
const mine = (record: BuildRecord): boolean =>
|
|
536
|
+
record.repo === identity ||
|
|
537
|
+
(record.repoOrigin !== undefined && normalizeGitRemoteUrl(record.repoOrigin) === identity)
|
|
538
|
+
|
|
539
|
+
const streams = new Map<string, Stream>()
|
|
540
|
+
// Every stream's position — matching or not, delivered or baselined —
|
|
541
|
+
// lives here, so a resume never rescans or redelivers a filtered event.
|
|
542
|
+
const positions: Record<string, number> = { ...sinceStreams }
|
|
543
|
+
let emitted = 0
|
|
544
|
+
let countCursor: string | undefined
|
|
545
|
+
let stop = false
|
|
546
|
+
// Held reads in flight right now, each with its own controller. When
|
|
547
|
+
// the watch stops for any reason, every in-flight read is cancelled —
|
|
548
|
+
// otherwise a stop during a quiet window would wait out a full wait
|
|
549
|
+
// window (up to the remote hold) before the watch could exit.
|
|
550
|
+
const inFlightReads = new Set<AbortController>()
|
|
551
|
+
// Wakes stop-aware gap sleeps the moment the watch stops.
|
|
552
|
+
const stopController = new AbortController()
|
|
553
|
+
const requestStop = (): void => {
|
|
554
|
+
stop = true
|
|
555
|
+
stopController.abort()
|
|
556
|
+
for (const controller of inFlightReads) controller.abort()
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const encodeCurrent = (): string =>
|
|
560
|
+
encodeCursor({ v: 1, store: storeRef, repo, streams: { ...positions } })
|
|
561
|
+
|
|
562
|
+
/** One record, flushed the moment it is projected. `cursor` is the watch
|
|
563
|
+
* position AFTER this event. */
|
|
564
|
+
const emit = (event: AbEvent | RepositoryEvent, state: BuildState | null): void => {
|
|
565
|
+
const cursor = encodeCurrent()
|
|
566
|
+
if (opts.json === true) {
|
|
567
|
+
opts.stdout(JSON.stringify(projectRecord(event, state, cursor)))
|
|
568
|
+
} else {
|
|
569
|
+
opts.stdout(renderWatchLine(event, state))
|
|
570
|
+
}
|
|
571
|
+
emitted += 1
|
|
572
|
+
if (opts.count !== undefined && emitted >= opts.count) {
|
|
573
|
+
// Events read but not delivered because the count tripped stay
|
|
574
|
+
// unprocessed: the next run resumes from THIS record's cursor.
|
|
575
|
+
countCursor = cursor
|
|
576
|
+
requestStop()
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** Record one event. `prefix` is the event prefix ENDING at `event` —
|
|
581
|
+
* the record's state is the reduction after THIS event, never a
|
|
582
|
+
* reduction over events that landed after it. The poll path passes the
|
|
583
|
+
* stream's just-extended array; the resume path replays the backlog
|
|
584
|
+
* into a scratch prefix so a resumed record never leaks state from
|
|
585
|
+
* events that landed after it. */
|
|
586
|
+
const processBuildEvent = (stream: BuildStream, event: AbEvent, prefix: AbEvent[]): void => {
|
|
587
|
+
stream.lastSeq = event.seq
|
|
588
|
+
positions[stream.slug] = event.seq
|
|
589
|
+
if (!matchesBuildFilter(event.type, filters)) return
|
|
590
|
+
emit(event, reduceBuild(prefix))
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const processRepositoryEvent = (stream: RepositoryStream, event: RepositoryEvent): void => {
|
|
594
|
+
stream.lastSeq = event.seq
|
|
595
|
+
positions[REPO_STREAM_KEY] = event.seq
|
|
596
|
+
if (!matchesRepositoryFilter(event.type, filters)) return
|
|
597
|
+
emit(event, null)
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Register a build stream over the full event prefix. `resumeSeq` (a
|
|
602
|
+
* cursor position) additionally emits every matching event after it —
|
|
603
|
+
* the backlog that landed while no watch was running. A baseline (the
|
|
604
|
+
* default) replays nothing: the position starts at the current maximum.
|
|
605
|
+
*/
|
|
606
|
+
const trackBuild = async (slug: string, resumeSeq?: number): Promise<boolean> => {
|
|
607
|
+
if (streams.has(slug)) return true
|
|
608
|
+
const record = await store.getBuild(slug)
|
|
609
|
+
if (record === null) return false
|
|
610
|
+
const events = await store.getEvents(slug)
|
|
611
|
+
const stream: BuildStream = {
|
|
612
|
+
kind: 'build',
|
|
613
|
+
slug,
|
|
614
|
+
named: slugs.includes(slug),
|
|
615
|
+
events,
|
|
616
|
+
lastSeq: events.at(-1)?.seq ?? 0,
|
|
617
|
+
status: reduceBuild(events).status,
|
|
618
|
+
}
|
|
619
|
+
streams.set(slug, stream)
|
|
620
|
+
positions[slug] = stream.lastSeq
|
|
621
|
+
if (resumeSeq === undefined) return true
|
|
622
|
+
stream.lastSeq = resumeSeq
|
|
623
|
+
// Replay the backlog into a scratch prefix: each record's state is
|
|
624
|
+
// the reduction of the prefix ENDING at that event, never the
|
|
625
|
+
// reduction of the full loaded prefix, which would leak state from
|
|
626
|
+
// events that landed after the one being recorded.
|
|
627
|
+
const replay: AbEvent[] = events.filter((event) => event.seq <= resumeSeq)
|
|
628
|
+
for (const event of events) {
|
|
629
|
+
if (event.seq <= resumeSeq) continue
|
|
630
|
+
replay.push(event)
|
|
631
|
+
processBuildEvent(stream, event, replay)
|
|
632
|
+
if (stop) break
|
|
633
|
+
}
|
|
634
|
+
return true
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* No-slug membership: every nonterminal build of this repository joins
|
|
639
|
+
* the watch, baselined at its current maximum so no history replays. A
|
|
640
|
+
* build discovered while nonterminal stays tracked even after it turns
|
|
641
|
+
* terminal. Cursor-resumed and named streams are never re-baselined.
|
|
642
|
+
*
|
|
643
|
+
* Throws on a failed read — never swallows it: the poll loop's tick
|
|
644
|
+
* catches and reports the failure once on stderr, and the initial scan
|
|
645
|
+
* turns it into the friendly startup error. Streams discovered before
|
|
646
|
+
* the throw stay tracked, so the retry neither duplicates nor skips.
|
|
647
|
+
*/
|
|
648
|
+
const discoverBuilds = async (): Promise<void> => {
|
|
649
|
+
for (const record of (await store.listBuilds()).filter(mine)) {
|
|
650
|
+
if (streams.has(record.slug)) continue
|
|
651
|
+
const events = await store.getEvents(record.slug)
|
|
652
|
+
const status = reduceBuild(events).status
|
|
653
|
+
if (!NONTERMINAL_STATUSES.includes(status)) continue
|
|
654
|
+
const stream: BuildStream = {
|
|
655
|
+
kind: 'build',
|
|
656
|
+
slug: record.slug,
|
|
657
|
+
named: false,
|
|
658
|
+
events,
|
|
659
|
+
lastSeq: events.at(-1)?.seq ?? 0,
|
|
660
|
+
status,
|
|
661
|
+
}
|
|
662
|
+
streams.set(record.slug, stream)
|
|
663
|
+
positions[record.slug] = stream.lastSeq
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
const trackRepository = async (): Promise<boolean> => {
|
|
668
|
+
const resumeSeq = sinceStreams[REPO_STREAM_KEY]
|
|
669
|
+
const stream: RepositoryStream = { kind: 'repo', lastSeq: resumeSeq ?? 0 }
|
|
670
|
+
streams.set(REPO_STREAM_KEY, stream)
|
|
671
|
+
positions[REPO_STREAM_KEY] = stream.lastSeq
|
|
672
|
+
try {
|
|
673
|
+
// A fresh store has no repository row; do not create one.
|
|
674
|
+
if (resumeSeq === undefined && (await store.getRepo(repo)) !== null) {
|
|
675
|
+
const events = await store.getRepoEvents(repo)
|
|
676
|
+
stream.lastSeq = events.at(-1)?.seq ?? 0
|
|
677
|
+
positions[REPO_STREAM_KEY] = stream.lastSeq
|
|
678
|
+
}
|
|
679
|
+
return true
|
|
680
|
+
} catch {
|
|
681
|
+
return false
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const pollBuild = async (
|
|
686
|
+
stream: BuildStream,
|
|
687
|
+
readOpts?: { waitSeconds?: number; signal?: AbortSignal },
|
|
688
|
+
): Promise<boolean> => {
|
|
689
|
+
const fresh = await store.getEvents(stream.slug, stream.lastSeq, readOpts)
|
|
690
|
+
for (const event of fresh) {
|
|
691
|
+
stream.events.push(event)
|
|
692
|
+
processBuildEvent(stream, event, stream.events)
|
|
693
|
+
if (stop) break
|
|
694
|
+
}
|
|
695
|
+
if (fresh.length > 0 && !stop) {
|
|
696
|
+
stream.status = reduceBuild(stream.events).status
|
|
697
|
+
}
|
|
698
|
+
return true
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const pollRepository = async (
|
|
702
|
+
stream: RepositoryStream,
|
|
703
|
+
readOpts?: { waitSeconds?: number; signal?: AbortSignal },
|
|
704
|
+
): Promise<boolean> => {
|
|
705
|
+
// A repository row may be created mid-watch by the first dispatch.
|
|
706
|
+
if ((await store.getRepo(repo)) === null) return true
|
|
707
|
+
const fresh = await store.getRepoEvents(repo, stream.lastSeq, readOpts)
|
|
708
|
+
for (const event of fresh) {
|
|
709
|
+
processRepositoryEvent(stream, event)
|
|
710
|
+
if (stop) break
|
|
711
|
+
}
|
|
712
|
+
return true
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
const namedAllTerminal = (): boolean => {
|
|
716
|
+
if (slugs.length === 0) return false
|
|
717
|
+
for (const slug of slugs) {
|
|
718
|
+
const stream = streams.get(slug)
|
|
719
|
+
if (stream === undefined || stream.kind !== 'build') return false
|
|
720
|
+
if (!TERMINAL_STATUSES.includes(stream.status)) return false
|
|
721
|
+
}
|
|
722
|
+
return true
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/** A failed read is reported once per failure streak, advances nothing,
|
|
726
|
+
* and is retried at the next interval (local) or next request (remote);
|
|
727
|
+
* a success on the same source re-arms the report. Each read source —
|
|
728
|
+
* per stream task, plus discovery — carries its own streak, so a
|
|
729
|
+
* persistent failure on one stream interleaved with successes elsewhere
|
|
730
|
+
* is still reported only once. */
|
|
731
|
+
const makeFailureStreak = (): {
|
|
732
|
+
onFailure: (error: unknown) => void
|
|
733
|
+
onSuccess: () => void
|
|
734
|
+
} => {
|
|
735
|
+
let reported = false
|
|
736
|
+
return {
|
|
737
|
+
onFailure: (error: unknown): void => {
|
|
738
|
+
if (reported) return
|
|
739
|
+
reported = true
|
|
740
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
741
|
+
opts.stderr(`ab watch: a store read failed (${message}); retrying at the next interval`)
|
|
742
|
+
},
|
|
743
|
+
onSuccess: (): void => {
|
|
744
|
+
reported = false
|
|
745
|
+
},
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// One streak for the whole local loop — the same lifetime the shared
|
|
750
|
+
// reported flag had before per-source streaks: a persistent read
|
|
751
|
+
// failure is reported once, and a fully-successful cycle re-arms it.
|
|
752
|
+
const tickStreak = makeFailureStreak()
|
|
753
|
+
|
|
754
|
+
const tick = async (): Promise<void> => {
|
|
755
|
+
let allReadsOk = true
|
|
756
|
+
if (slugs.length === 0) {
|
|
757
|
+
try {
|
|
758
|
+
await discoverBuilds()
|
|
759
|
+
} catch (error) {
|
|
760
|
+
allReadsOk = false
|
|
761
|
+
tickStreak.onFailure(error)
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
for (const stream of [...streams.values()]) {
|
|
765
|
+
if (stop) return
|
|
766
|
+
try {
|
|
767
|
+
if (stream.kind === 'build') await pollBuild(stream)
|
|
768
|
+
else await pollRepository(stream)
|
|
769
|
+
} catch (error) {
|
|
770
|
+
allReadsOk = false
|
|
771
|
+
tickStreak.onFailure(error)
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
if (allReadsOk) tickStreak.onSuccess()
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// ── Initial scan ──
|
|
778
|
+
// Named slugs are validated (and must belong to this repository) before
|
|
779
|
+
// any record is emitted: an unknown slug is an error, full stop.
|
|
780
|
+
for (const slug of slugs) {
|
|
781
|
+
const record = await store.getBuild(slug)
|
|
782
|
+
if (record === null) {
|
|
783
|
+
throw new Error(
|
|
784
|
+
`no build "${slug}" in this store — run 'ab builds --all' to list ` +
|
|
785
|
+
"this repo's builds, or pass --store <ref> if it lives in another store",
|
|
786
|
+
)
|
|
787
|
+
}
|
|
788
|
+
if (!(await buildInRepository(record, checkout, exec))) {
|
|
789
|
+
throw new Error(`build "${slug}" belongs to repository "${record.repo}", not "${repo}"`)
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
for (const slug of slugs) await trackBuild(slug, sinceStreams[slug])
|
|
793
|
+
// Cursor streams that are no longer named resume anyway: finishing
|
|
794
|
+
// their backlog is what makes a resumed watch lossless.
|
|
795
|
+
for (const key of Object.keys(sinceStreams)) {
|
|
796
|
+
if (key === REPO_STREAM_KEY) continue
|
|
797
|
+
await trackBuild(key, sinceStreams[key])
|
|
798
|
+
if (stop) break
|
|
799
|
+
}
|
|
800
|
+
if (repository) {
|
|
801
|
+
if (!(await trackRepository())) {
|
|
802
|
+
makeFailureStreak().onFailure(
|
|
803
|
+
new Error(`could not read the repository journal for "${repo}"`),
|
|
804
|
+
)
|
|
805
|
+
}
|
|
806
|
+
} else if (slugs.length === 0) {
|
|
807
|
+
// Membership discovery also baselines the default watch; a failed
|
|
808
|
+
// discovery here is fatal — nothing has started yet.
|
|
809
|
+
try {
|
|
810
|
+
await discoverBuilds()
|
|
811
|
+
} catch (error) {
|
|
812
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
813
|
+
throw new Error(`could not list builds in this store (${message})`)
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// ── The poll loop ──
|
|
818
|
+
const deadline = timeoutMs === 0 ? Number.POSITIVE_INFINITY : now().getTime() + timeoutMs
|
|
819
|
+
const aborted = (): boolean => opts.signal?.aborted === true
|
|
820
|
+
|
|
821
|
+
if (!isRemoteStoreRef(storeRef)) {
|
|
822
|
+
// Local cadence: one sequential tick per interval.
|
|
823
|
+
while (!stop) {
|
|
824
|
+
if (aborted() || now().getTime() >= deadline || namedAllTerminal()) break
|
|
825
|
+
await sleep(interval, opts.signal)
|
|
826
|
+
if (aborted()) break
|
|
827
|
+
await tick()
|
|
828
|
+
}
|
|
829
|
+
} else {
|
|
830
|
+
// Remote cadence (AUT-334): one long-poll task per tracked stream,
|
|
831
|
+
// running concurrently — a quiet stream keeps one held request in
|
|
832
|
+
// flight, so an appended event arrives within about a second instead
|
|
833
|
+
// of at the next interval tick. Gap-fill spaces each stream's request
|
|
834
|
+
// starts at least `interval` apart (elapsed request time counts
|
|
835
|
+
// toward the gap), so a server that answers immediately still sees
|
|
836
|
+
// today's request rate. Discovery keeps its interval cadence.
|
|
837
|
+
// Pre-launch guard mirrors the local loop's top-of-cycle checks: an
|
|
838
|
+
// already-terminal named set, an elapsed deadline, or an abort ends
|
|
839
|
+
// the watch before any request.
|
|
840
|
+
// Every stop is prompt: an external abort, a tripped --count, an
|
|
841
|
+
// all-terminal named set, or an elapsed --timeout cancels the
|
|
842
|
+
// in-flight held reads (via `requestStop`) and wakes the gap sleeps
|
|
843
|
+
// (via `stopController`) instead of waiting out a full hold. The
|
|
844
|
+
// held read's own bound is additionally capped at the watch's
|
|
845
|
+
// remaining time budget, so a hold can never outlive the deadline.
|
|
846
|
+
const shouldStop = stop || aborted() || now().getTime() >= deadline || namedAllTerminal()
|
|
847
|
+
const tasks: Promise<void>[] = []
|
|
848
|
+
const launched = new Set<string>()
|
|
849
|
+
|
|
850
|
+
/** The wait bound for one held read: the remote default, capped at
|
|
851
|
+
* the watch's remaining time budget. */
|
|
852
|
+
const readWaitSeconds = (atMs: number): number => {
|
|
853
|
+
if (timeoutMs === 0) return REMOTE_EVENT_WAIT_SECONDS
|
|
854
|
+
const remaining = Math.floor((deadline - atMs) / 1000)
|
|
855
|
+
return Math.max(0, Math.min(REMOTE_EVENT_WAIT_SECONDS, remaining))
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
const runStreamTask = async (
|
|
859
|
+
poll: (readOpts: { waitSeconds?: number; signal?: AbortSignal }) => Promise<unknown>,
|
|
860
|
+
): Promise<void> => {
|
|
861
|
+
const streak = makeFailureStreak()
|
|
862
|
+
while (!stop && !aborted() && now().getTime() < deadline) {
|
|
863
|
+
const started = now().getTime()
|
|
864
|
+
const controller = new AbortController()
|
|
865
|
+
inFlightReads.add(controller)
|
|
866
|
+
try {
|
|
867
|
+
await poll({ waitSeconds: readWaitSeconds(started), signal: controller.signal })
|
|
868
|
+
streak.onSuccess()
|
|
869
|
+
if (namedAllTerminal()) {
|
|
870
|
+
requestStop()
|
|
871
|
+
return
|
|
872
|
+
}
|
|
873
|
+
} catch (error) {
|
|
874
|
+
// A cancelled held read is the watch stopping, not a store
|
|
875
|
+
// failure — never report it.
|
|
876
|
+
if (!stop && !aborted() && !controller.signal.aborted) streak.onFailure(error)
|
|
877
|
+
} finally {
|
|
878
|
+
inFlightReads.delete(controller)
|
|
879
|
+
}
|
|
880
|
+
if (stop || aborted()) return
|
|
881
|
+
const elapsed = now().getTime() - started
|
|
882
|
+
if (elapsed < interval) await sleep(interval - elapsed, stopController.signal)
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
const launch = (stream: Stream): void => {
|
|
887
|
+
const key = stream.kind === 'build' ? stream.slug : REPO_STREAM_KEY
|
|
888
|
+
if (launched.has(key)) return
|
|
889
|
+
launched.add(key)
|
|
890
|
+
tasks.push(
|
|
891
|
+
stream.kind === 'build'
|
|
892
|
+
? runStreamTask((readOpts) => pollBuild(stream, readOpts))
|
|
893
|
+
: runStreamTask((readOpts) => pollRepository(stream, readOpts)),
|
|
894
|
+
)
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// An external abort (SIGINT) is a stop like any other: it cancels
|
|
898
|
+
// the in-flight held reads and wakes the gap sleeps.
|
|
899
|
+
opts.signal?.addEventListener('abort', requestStop, { once: true })
|
|
900
|
+
|
|
901
|
+
for (const stream of streams.values()) if (!shouldStop) launch(stream)
|
|
902
|
+
|
|
903
|
+
if (slugs.length === 0 && !shouldStop) {
|
|
904
|
+
tasks.push(
|
|
905
|
+
(async (): Promise<void> => {
|
|
906
|
+
const streak = makeFailureStreak()
|
|
907
|
+
while (!stop && !aborted() && now().getTime() < deadline) {
|
|
908
|
+
try {
|
|
909
|
+
await discoverBuilds()
|
|
910
|
+
streak.onSuccess()
|
|
911
|
+
} catch (error) {
|
|
912
|
+
streak.onFailure(error)
|
|
913
|
+
}
|
|
914
|
+
for (const stream of streams.values()) launch(stream)
|
|
915
|
+
if (stop || aborted()) return
|
|
916
|
+
await sleep(interval, stopController.signal)
|
|
917
|
+
}
|
|
918
|
+
})(),
|
|
919
|
+
)
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
await Promise.all(tasks)
|
|
923
|
+
opts.signal?.removeEventListener('abort', requestStop)
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
const finalCursor = countCursor ?? encodeCurrent()
|
|
927
|
+
if (opts.json === true) {
|
|
928
|
+
opts.stdout(JSON.stringify({ cursor: finalCursor }))
|
|
929
|
+
}
|
|
930
|
+
},
|
|
931
|
+
)
|
|
932
|
+
}
|