@defrex/autobuild 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/SPEC.md +222 -22
- package/docs/architecture.md +66 -4
- package/docs/configuration.md +105 -16
- package/docs/hosted-dispatcher.md +17 -2
- package/docs/hosted-service.md +214 -0
- package/docs/mcp.md +8 -2
- package/docs/operator-api.md +90 -7
- package/docs/remote-store-protocol.md +189 -144
- package/docs/remote-ticket-protocol.md +107 -19
- package/docs/setup.md +173 -16
- package/docs/ticket-dependencies.md +1 -1
- package/package.json +7 -6
- package/packages/core/src/cli/attention.ts +209 -0
- package/packages/core/src/cli/configured-store.ts +17 -0
- package/packages/core/src/cli/connect.ts +261 -0
- package/packages/core/src/cli/dashboard/model.ts +4 -5
- package/packages/core/src/cli/dispatch.ts +183 -41
- package/packages/core/src/cli/help.ts +39 -10
- package/packages/core/src/cli/init-validation.ts +27 -2
- package/packages/core/src/cli/main.ts +41 -15
- package/packages/core/src/cli/mcp.ts +7 -1
- package/packages/core/src/cli/pr-summary-publication.ts +220 -0
- package/packages/core/src/cli/pr-summary.ts +2 -0
- package/packages/core/src/cli/project-credentials.ts +132 -0
- package/packages/core/src/cli/repo-state.ts +15 -2
- package/packages/core/src/cli/self-update.ts +10 -0
- package/packages/core/src/cli/store-opening.ts +22 -14
- package/packages/core/src/cli/terminals.ts +0 -10
- package/packages/core/src/cli/ticket.ts +8 -1
- package/packages/core/src/cli/upgrade-compatibility.ts +80 -0
- package/packages/core/src/cli/wait.ts +84 -17
- package/packages/core/src/cli/watch.ts +197 -60
- package/packages/core/src/config/live.ts +3 -0
- package/packages/core/src/config/schema.ts +56 -9
- package/packages/core/src/connection.ts +86 -0
- package/packages/core/src/events/catalog.ts +10 -2
- package/packages/core/src/events/payloads.ts +44 -0
- package/packages/core/src/events/repository.ts +13 -0
- package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +130 -83
- package/packages/core/src/kernel/engine.ts +24 -0
- package/packages/core/src/kernel/generators/build-log.ts +10 -0
- package/packages/core/src/kernel/log-index.ts +17 -0
- package/packages/core/src/kernel/pr-summary.ts +89 -0
- package/packages/core/src/kernel/reducer-registry.ts +8 -0
- package/packages/core/src/kernel/reducer.ts +8 -0
- package/packages/core/src/ontology.ts +1 -0
- package/packages/core/src/operator/index.ts +7 -0
- package/packages/core/src/operator/query.ts +38 -16
- package/packages/core/src/operator/ticket-source-backend.ts +1 -0
- package/packages/core/src/operator/tickets.ts +6 -8
- package/packages/core/src/plugin-sdk/index.ts +1 -0
- package/packages/core/src/plugins/manifest.ts +5 -3
- package/packages/core/src/plugins/runtimes.ts +3 -1
- package/packages/core/src/ports/forge/fake.ts +13 -1
- package/packages/core/src/ports/forge/github-transport.ts +36 -1
- package/packages/core/src/ports/forge/github.ts +29 -5
- package/packages/core/src/ports/forge/local-git.ts +12 -4
- package/packages/core/src/ports/forge/summary-marker.ts +8 -0
- package/packages/core/src/ports/runner/session-env.ts +1 -0
- package/packages/core/src/ports/tickets/create.ts +11 -3
- package/packages/core/src/ports/tickets/hosted-index.ts +0 -3
- package/packages/core/src/ports/tickets/remote-protocol.ts +1 -0
- package/packages/core/src/ports/tickets/remote.ts +1 -0
- package/packages/core/src/ports/workspace/build-execution.ts +5 -1
- package/packages/core/src/ports/workspace/create.ts +11 -2
- package/packages/core/src/ports/workspace/distribution-archive.ts +3 -5
- package/packages/core/src/ports/workspace/harvest-execution.ts +3 -1
- package/packages/core/src/ports/workspace/local-build-execution.ts +8 -1
- package/packages/core/src/ports/workspace/vercel-sandbox.ts +47 -19
- package/packages/core/src/processes/build-execution-state.ts +1 -1
- package/packages/core/src/processes/build-runner.ts +87 -26
- package/packages/core/src/processes/dispatch-failure-budget.ts +83 -0
- package/packages/core/src/processes/dispatcher.ts +59 -10
- package/packages/core/src/processes/harvest-child.ts +1 -0
- package/packages/core/src/processes/harvest-execution-state.ts +19 -2
- package/packages/core/src/processes/harvest-runner.ts +142 -100
- package/packages/core/src/processes/phase-failure-budget.ts +1 -0
- package/packages/core/src/processes/publication-loss.ts +18 -1
- package/packages/core/src/store/remote/client.ts +1 -1
- package/packages/core/src/store/remote/compatibility.ts +90 -0
- package/packages/core/src/store/remote/credentials.ts +28 -0
- package/packages/core/src/store/remote/index.ts +14 -1
- package/packages/core/src/store/remote/protocol.ts +25 -0
- package/packages/core/src/store/remote/token.ts +74 -13
- package/packages/core/src/testing/index.ts +7 -0
- package/skills/guide/SKILL.md +144 -26
- package/skills/guide/references/operator-api.md +135 -13
- package/skills/guide/references/plugin-authoring.md +10 -2
- package/skills/guide/references/remote-store-protocol.md +189 -144
- package/skills/guide/references/remote-ticket-protocol.md +111 -18
- package/skills/guide/references/setup.md +173 -16
- package/skills/guide/references/ticket-dependencies.md +1 -1
- package/skills/operate/SKILL.md +126 -36
- package/templates/hosted-service/package.json +13 -0
- package/templates/hosted-service/vercel.json +7 -0
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* normalized against that checkout so the dispatcher and agents cannot
|
|
21
21
|
* interpret a relative path from different working directories.
|
|
22
22
|
*/
|
|
23
|
+
import { configuredStore } from './configured-store'
|
|
23
24
|
import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
|
|
24
25
|
import type { Exec } from '../ports/workspace/git-worktree'
|
|
25
26
|
import type { BuildRecord } from '../store/types'
|
|
@@ -92,6 +93,8 @@ export async function resolveMainRepo(targetRepo: string, exec: Exec): Promise<s
|
|
|
92
93
|
}
|
|
93
94
|
|
|
94
95
|
export interface RepoStatePaths {
|
|
96
|
+
/** Validated repository setting used for this selection, when present. */
|
|
97
|
+
configStore?: string
|
|
95
98
|
/** Repository identity in BuildStore records/journals: the checkout's
|
|
96
99
|
* normalized origin URL, falling back to the resolved checkout path only
|
|
97
100
|
* when the checkout has no origin remote. Opaque to the store schema. */
|
|
@@ -116,7 +119,7 @@ function nonBlank(value: string | undefined): string | undefined {
|
|
|
116
119
|
|
|
117
120
|
/**
|
|
118
121
|
* Select state with one precedence rule for every sessionless command:
|
|
119
|
-
* non-blank explicit `--store` > non-blank `AB_STORE` >
|
|
122
|
+
* non-blank explicit `--store` > non-blank `AB_STORE` > `[store] url` > local default.
|
|
120
123
|
*
|
|
121
124
|
* A local selection relocates the whole local tree, including worktrees. A
|
|
122
125
|
* remote store has no filesystem root, so its worktrees remain local beneath
|
|
@@ -130,14 +133,20 @@ export function resolveRepoStatePaths(opts: {
|
|
|
130
133
|
checkout?: string
|
|
131
134
|
storeRef?: string
|
|
132
135
|
envStore?: string
|
|
136
|
+
configStore?: string
|
|
133
137
|
}): RepoStatePaths {
|
|
134
138
|
const checkout = resolve(opts.checkout ?? opts.repo)
|
|
135
139
|
const defaultLocalRoot = join(checkout, LOCAL_STATE_DIR)
|
|
136
|
-
const selected =
|
|
140
|
+
const selected =
|
|
141
|
+
nonBlank(opts.storeRef) ??
|
|
142
|
+
nonBlank(opts.envStore) ??
|
|
143
|
+
nonBlank(opts.configStore) ??
|
|
144
|
+
defaultLocalRoot
|
|
137
145
|
const remote = isRemoteStoreRef(selected)
|
|
138
146
|
const storeRef = remote ? selected : resolve(checkout, selected)
|
|
139
147
|
const localStateRoot = remote ? defaultLocalRoot : storeRef
|
|
140
148
|
return {
|
|
149
|
+
...(opts.configStore !== undefined ? { configStore: opts.configStore } : {}),
|
|
141
150
|
repo: opts.repo,
|
|
142
151
|
checkout,
|
|
143
152
|
defaultLocalRoot,
|
|
@@ -223,10 +232,14 @@ export async function resolveRepoState(opts: {
|
|
|
223
232
|
exec: Exec
|
|
224
233
|
storeRef?: string
|
|
225
234
|
envStore?: string
|
|
235
|
+
configStore?: string
|
|
226
236
|
}): Promise<RepoStatePaths> {
|
|
227
237
|
const checkout = await resolveMainRepo(opts.targetRepo, opts.exec)
|
|
228
238
|
const origin = await resolveRepoOrigin(checkout, opts.exec)
|
|
229
239
|
return resolveRepoStatePaths({
|
|
240
|
+
configStore:
|
|
241
|
+
opts.configStore ??
|
|
242
|
+
(nonBlank(opts.storeRef) || nonBlank(opts.envStore) ? undefined : configuredStore(checkout)),
|
|
230
243
|
repo: origin ?? checkout,
|
|
231
244
|
checkout,
|
|
232
245
|
...(opts.storeRef !== undefined ? { storeRef: opts.storeRef } : {}),
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { checkUpgradeCompatibility, type CompatibilityFetch } from './upgrade-compatibility'
|
|
1
2
|
import { readFile, writeFile } from 'node:fs/promises'
|
|
2
3
|
import semver from 'semver'
|
|
3
4
|
import type { ExecResult } from '../ports/workspace/git-worktree'
|
|
@@ -71,6 +72,7 @@ export function registryVersion(response: { status: number; body: string }): str
|
|
|
71
72
|
|
|
72
73
|
export interface SelfUpdateOptions {
|
|
73
74
|
targetRepo: string
|
|
75
|
+
compatibilityFetch?: CompatibilityFetch
|
|
74
76
|
version?: string
|
|
75
77
|
distRoot?: string
|
|
76
78
|
env?: Record<string, string | undefined>
|
|
@@ -393,6 +395,14 @@ export async function selfUpdate(options: SelfUpdateOptions): Promise<SelfUpdate
|
|
|
393
395
|
return { kind: 'continue' }
|
|
394
396
|
}
|
|
395
397
|
|
|
398
|
+
await checkUpgradeCompatibility({
|
|
399
|
+
targetRepo: options.targetRepo,
|
|
400
|
+
version: resolved,
|
|
401
|
+
env: options.env,
|
|
402
|
+
stderr: options.stderr,
|
|
403
|
+
fetch: options.compatibilityFetch,
|
|
404
|
+
})
|
|
405
|
+
|
|
396
406
|
const dependency = updateDependency(install, resolved)
|
|
397
407
|
let updateCommand: string[]
|
|
398
408
|
if (install.scope === 'global') {
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* joins those seams to the production remote adapter and owns finite-command
|
|
7
7
|
* store lifecycle.
|
|
8
8
|
*/
|
|
9
|
+
import { projectToken } from './project-credentials'
|
|
10
|
+
import { isRemoteStoreRef } from './repo-state'
|
|
9
11
|
import type { Exec } from '../ports/workspace/git-worktree'
|
|
10
12
|
import { RemoteBuildStore } from '../store/remote/client'
|
|
11
13
|
import { scopeLocalStoreToPhaseSession } from '../store/phase-session'
|
|
@@ -16,7 +18,7 @@ import {
|
|
|
16
18
|
type CliEnv,
|
|
17
19
|
type HarvestCliEnv,
|
|
18
20
|
} from './env'
|
|
19
|
-
import {
|
|
21
|
+
import { resolveRepoState, type RepoStatePaths } from './repo-state'
|
|
20
22
|
import { resolveStore } from './store-ref'
|
|
21
23
|
|
|
22
24
|
/** Shared injection seam used by every command that opens a BuildStore. */
|
|
@@ -39,15 +41,14 @@ export const openProductionStore: StoreOpener = (ref, token) => {
|
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
/** Apply validated ambient authority to a selected Store handle. Remote
|
|
42
|
-
* handles retain
|
|
43
|
-
* the
|
|
44
|
+
* handles retain server-side project authorization; every handle receives
|
|
45
|
+
* the same in-process build/session scope. Keeping this primitive shared prevents
|
|
44
46
|
* phase commands and ambient-aware query commands from drifting. */
|
|
45
47
|
function scopeSelectedStoreToPhaseSession(
|
|
46
48
|
store: BuildStore,
|
|
47
|
-
|
|
49
|
+
_ref: string,
|
|
48
50
|
env: AmbientReadSession,
|
|
49
51
|
): BuildStore {
|
|
50
|
-
if (isRemoteStoreRef(ref)) return store
|
|
51
52
|
return scopeLocalStoreToPhaseSession(
|
|
52
53
|
store,
|
|
53
54
|
'build' in env
|
|
@@ -65,15 +66,15 @@ export interface OpenedStoreContext extends RepoStatePaths {
|
|
|
65
66
|
store: BuildStore
|
|
66
67
|
token?: string
|
|
67
68
|
/** The validated ambient phase/Harvest identity this invocation carries, if
|
|
68
|
-
* any — the same value that scoped `store
|
|
69
|
-
*
|
|
70
|
-
* where no session wrapper exists. */
|
|
69
|
+
* any — the same value that scoped `store`. Exposed for commands whose
|
|
70
|
+
* repository selection also depends on ambient own-build authority. */
|
|
71
71
|
ambient?: AmbientReadSession
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
export interface OpenStoreForRepoStateOpts {
|
|
75
75
|
env: Record<string, string | undefined>
|
|
76
76
|
openStore?: StoreOpener
|
|
77
|
+
useProjectCredential?: boolean
|
|
77
78
|
}
|
|
78
79
|
|
|
79
80
|
/** Open from an already canonicalized state selection. Dispatch uses this form
|
|
@@ -82,7 +83,13 @@ export function openStoreForRepoState(
|
|
|
82
83
|
state: RepoStatePaths,
|
|
83
84
|
opts: OpenStoreForRepoStateOpts,
|
|
84
85
|
): OpenedStoreContext {
|
|
85
|
-
const token =
|
|
86
|
+
const token =
|
|
87
|
+
opts.useProjectCredential !== false && isRemoteStoreRef(state.storeRef)
|
|
88
|
+
? projectToken(state.storeRef, state.repo, opts.env)
|
|
89
|
+
: optionalToken(opts.env.AB_TOKEN)
|
|
90
|
+
if (!token && isRemoteStoreRef(state.storeRef) && state.configStore === state.storeRef) {
|
|
91
|
+
throw new Error('No project credential for this service; run ab connect')
|
|
92
|
+
}
|
|
86
93
|
const store = (opts.openStore ?? openProductionStore)(state.storeRef, token)
|
|
87
94
|
return {
|
|
88
95
|
...state,
|
|
@@ -115,10 +122,8 @@ export async function openSessionlessStore(
|
|
|
115
122
|
/** Finite command ownership boundary. Opening failures occur before the
|
|
116
123
|
* try/finally, so only a successfully returned handle is ever closed.
|
|
117
124
|
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
* consume `ambient` from the context directly rather than rely on the handle
|
|
121
|
-
* enforcing a scope. */
|
|
125
|
+
* Ambient query openers apply in-process build/session scope to both local
|
|
126
|
+
* and remote handles; the server additionally enforces project authority. */
|
|
122
127
|
export async function withSessionlessStore<T>(
|
|
123
128
|
opts: SessionlessStoreOpts,
|
|
124
129
|
use: (context: OpenedStoreContext) => Promise<T> | T,
|
|
@@ -145,7 +150,10 @@ export async function openAmbientReadStore(
|
|
|
145
150
|
...(opts.storeRef !== undefined ? { storeRef: opts.storeRef } : {}),
|
|
146
151
|
...(opts.env.AB_STORE !== undefined ? { envStore: opts.env.AB_STORE } : {}),
|
|
147
152
|
})
|
|
148
|
-
const context = openStoreForRepoState(state,
|
|
153
|
+
const context = openStoreForRepoState(state, {
|
|
154
|
+
...opts,
|
|
155
|
+
useProjectCredential: ambient === undefined,
|
|
156
|
+
})
|
|
149
157
|
if (ambient === undefined) return context
|
|
150
158
|
return {
|
|
151
159
|
...context,
|
|
@@ -50,7 +50,6 @@ import type { ArtifactMeta, BuildStore } from '../store/types'
|
|
|
50
50
|
import { textContent } from '../store/types'
|
|
51
51
|
import { expandIssues } from '../zod-issues'
|
|
52
52
|
import { preparePrAttachments } from './pr-attachments'
|
|
53
|
-
import { renderPrSummary } from './pr-summary'
|
|
54
53
|
import type { CliEnv } from './env'
|
|
55
54
|
|
|
56
55
|
/** Structural subset of CliDeps (src/cli/main.ts) — what terminals need. */
|
|
@@ -441,15 +440,6 @@ export async function completeFinalizePr(
|
|
|
441
440
|
}
|
|
442
441
|
}
|
|
443
442
|
|
|
444
|
-
try {
|
|
445
|
-
await deps.forge.commentOnPr(
|
|
446
|
-
deps.workspacePath,
|
|
447
|
-
pr.number,
|
|
448
|
-
renderPrSummary(env, await store.getEvents(env.build)),
|
|
449
|
-
)
|
|
450
|
-
} catch {
|
|
451
|
-
// The durable audit trail remains authoritative.
|
|
452
|
-
}
|
|
453
443
|
return event
|
|
454
444
|
}
|
|
455
445
|
|
|
@@ -199,13 +199,20 @@ export async function openTicketSource(
|
|
|
199
199
|
const plugins = await loadPlugins(config.plugins, targetRepo)
|
|
200
200
|
const repoState = resolveRepoStatePaths({
|
|
201
201
|
repo: targetRepo,
|
|
202
|
+
configStore: config.store?.url,
|
|
202
203
|
...(opts.storeRef !== undefined ? { storeRef: opts.storeRef } : {}),
|
|
203
204
|
...(opts.env.AB_STORE !== undefined ? { envStore: opts.env.AB_STORE } : {}),
|
|
204
205
|
})
|
|
205
206
|
const factory = opts.sourceFactory ?? createTicketSource
|
|
206
207
|
return {
|
|
207
208
|
config,
|
|
208
|
-
source: await factory(
|
|
209
|
+
source: await factory(
|
|
210
|
+
config.tickets,
|
|
211
|
+
config.tickets.source === 'hosted' ? { ...opts.env, AB_STORE: repoState.storeRef } : opts.env,
|
|
212
|
+
targetRepo,
|
|
213
|
+
repoState.localStateRoot,
|
|
214
|
+
plugins,
|
|
215
|
+
),
|
|
209
216
|
}
|
|
210
217
|
}
|
|
211
218
|
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { spawnExec } from '../ports/workspace/git-worktree'
|
|
2
|
+
import { readDistributionIdentity } from './installation'
|
|
3
|
+
import { isRemoteStoreRef, resolveRepoState } from './repo-state'
|
|
4
|
+
import {
|
|
5
|
+
classifyClient,
|
|
6
|
+
compatibilityAdvice,
|
|
7
|
+
stableVersion,
|
|
8
|
+
supportedClientRangeSchema,
|
|
9
|
+
} from '../store/remote/compatibility'
|
|
10
|
+
|
|
11
|
+
export type CompatibilityFetch = (url: string, init: RequestInit) => Promise<Response>
|
|
12
|
+
|
|
13
|
+
/** Read-only, advisory, and deliberately credential-free. Never expose a store URL. */
|
|
14
|
+
export async function checkUpgradeCompatibility(options: {
|
|
15
|
+
targetRepo: string
|
|
16
|
+
version?: string
|
|
17
|
+
distRoot?: string
|
|
18
|
+
env?: Record<string, string | undefined>
|
|
19
|
+
stderr: (line: string) => void
|
|
20
|
+
fetch?: CompatibilityFetch
|
|
21
|
+
timeoutMs?: number
|
|
22
|
+
}): Promise<void> {
|
|
23
|
+
const paths = await resolveRepoState({
|
|
24
|
+
targetRepo: options.targetRepo,
|
|
25
|
+
exec: spawnExec,
|
|
26
|
+
envStore: (options.env ?? process.env).AB_STORE,
|
|
27
|
+
})
|
|
28
|
+
if (!isRemoteStoreRef(paths.storeRef)) return
|
|
29
|
+
const unknown = () =>
|
|
30
|
+
options.stderr(
|
|
31
|
+
'warning: hosted service compatibility could not be verified; the upgraded project may fail to connect',
|
|
32
|
+
)
|
|
33
|
+
try {
|
|
34
|
+
const targetVersion =
|
|
35
|
+
options.version ?? (await readDistributionIdentity(options.distRoot)).version
|
|
36
|
+
const url = new URL(paths.storeRef)
|
|
37
|
+
url.username = ''
|
|
38
|
+
url.password = ''
|
|
39
|
+
url.search = ''
|
|
40
|
+
url.hash = ''
|
|
41
|
+
url.pathname = `${url.pathname.replace(/\/$/, '')}/health`
|
|
42
|
+
const controller = new AbortController()
|
|
43
|
+
let timeout: ReturnType<typeof setTimeout> | undefined
|
|
44
|
+
const expired = new Promise<never>((_, reject) => {
|
|
45
|
+
timeout = setTimeout(() => {
|
|
46
|
+
controller.abort()
|
|
47
|
+
reject(new Error('timeout'))
|
|
48
|
+
}, options.timeoutMs ?? 3000)
|
|
49
|
+
})
|
|
50
|
+
const response = await Promise.race([
|
|
51
|
+
(async () => {
|
|
52
|
+
const response = await (options.fetch ?? fetch)(url.toString(), {
|
|
53
|
+
signal: controller.signal,
|
|
54
|
+
redirect: 'error',
|
|
55
|
+
})
|
|
56
|
+
if (response.status !== 200) throw new Error('health failed')
|
|
57
|
+
return (await response.json()) as Record<string, unknown>
|
|
58
|
+
})(),
|
|
59
|
+
expired,
|
|
60
|
+
]).finally(() => clearTimeout(timeout))
|
|
61
|
+
if (!response || !stableVersion(response.autobuildVersion)) return unknown()
|
|
62
|
+
const serviceVersion = response.autobuildVersion as string
|
|
63
|
+
if (!Object.hasOwn(response, 'supportedClientRange')) {
|
|
64
|
+
if (targetVersion !== serviceVersion)
|
|
65
|
+
options.stderr(
|
|
66
|
+
`warning: service Autobuild ${serviceVersion} requires an exact client version; target ${targetVersion} differs. Upgrade the service to the compatibility-window release first to publish a supported client range; the upgraded project may fail to connect`,
|
|
67
|
+
)
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
const range = supportedClientRangeSchema.safeParse(response.supportedClientRange)
|
|
71
|
+
if (!range.success || classifyClient(targetVersion, range.data) === 'invalid') return unknown()
|
|
72
|
+
const advice = compatibilityAdvice(targetVersion, serviceVersion, range.data)
|
|
73
|
+
if (advice)
|
|
74
|
+
options.stderr(
|
|
75
|
+
`warning: target Autobuild ${targetVersion}: ${advice}; the upgraded project may fail to connect`,
|
|
76
|
+
)
|
|
77
|
+
} catch {
|
|
78
|
+
unknown()
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
* Read-only: it appends no event, claims or renews no lease, and creates no
|
|
32
32
|
* build or repository record. Output carries no ANSI, ever, in either form.
|
|
33
33
|
*/
|
|
34
|
+
import { buildNeedsAttention, mergeDecision, mergeIdentity } from './attention'
|
|
34
35
|
import type { AbEvent } from '../events/catalog'
|
|
35
36
|
import type { EventType } from '../events/payloads'
|
|
36
37
|
import type { BuildState, PrLifecycle } from '../kernel/reducer'
|
|
@@ -54,6 +55,8 @@ import {
|
|
|
54
55
|
encodeCursor,
|
|
55
56
|
parseDurationMs,
|
|
56
57
|
projectRecord,
|
|
58
|
+
projectAttentionRecord,
|
|
59
|
+
renderAttentionLine,
|
|
57
60
|
renderWatchLine,
|
|
58
61
|
watchSelection,
|
|
59
62
|
} from './watch'
|
|
@@ -153,8 +156,8 @@ export function parseConditions(
|
|
|
153
156
|
return { conditions, globs }
|
|
154
157
|
}
|
|
155
158
|
|
|
156
|
-
/** Does one condition body hold over a reduction? Attention
|
|
157
|
-
function stateSatisfies(cond: ConditionBody, state: BuildState): boolean {
|
|
159
|
+
/** Does one condition body hold over a reduction? Attention includes current escalation and merge state. */
|
|
160
|
+
function stateSatisfies(cond: ConditionBody, state: BuildState, events: AbEvent[] = []): boolean {
|
|
158
161
|
switch (cond.kind) {
|
|
159
162
|
case 'status':
|
|
160
163
|
return state.status === cond.status
|
|
@@ -165,7 +168,7 @@ function stateSatisfies(cond: ConditionBody, state: BuildState): boolean {
|
|
|
165
168
|
case 'phase':
|
|
166
169
|
return (state.phase ?? null) === cond.phase
|
|
167
170
|
case 'attention':
|
|
168
|
-
return
|
|
171
|
+
return buildNeedsAttention(events, state)
|
|
169
172
|
}
|
|
170
173
|
}
|
|
171
174
|
|
|
@@ -174,9 +177,13 @@ function stateSatisfies(cond: ConditionBody, state: BuildState): boolean {
|
|
|
174
177
|
* the start-of-command and start-of-registration checks — never during a
|
|
175
178
|
* backlog replay, where only event conditions may fire.
|
|
176
179
|
*/
|
|
177
|
-
export function matchState(
|
|
180
|
+
export function matchState(
|
|
181
|
+
conditions: readonly WaitCondition[],
|
|
182
|
+
state: BuildState,
|
|
183
|
+
events: AbEvent[] = [],
|
|
184
|
+
): string | null {
|
|
178
185
|
for (const { text, cond } of conditions) {
|
|
179
|
-
if (stateSatisfies(cond, state)) return text
|
|
186
|
+
if (stateSatisfies(cond, state, events)) return text
|
|
180
187
|
}
|
|
181
188
|
return null
|
|
182
189
|
}
|
|
@@ -188,9 +195,15 @@ export function matchEvent(
|
|
|
188
195
|
event: Pick<AbEvent, 'type'>,
|
|
189
196
|
conditions: readonly WaitCondition[],
|
|
190
197
|
globs: readonly EventGlob[],
|
|
198
|
+
events: AbEvent[] = [],
|
|
191
199
|
): string | null {
|
|
192
200
|
for (const { text, cond } of conditions) {
|
|
193
|
-
if (
|
|
201
|
+
if (
|
|
202
|
+
cond.kind === 'attention' &&
|
|
203
|
+
(isAttentionEvent(event.type) ||
|
|
204
|
+
(events.length > 0 && mergeDecision(events, new Date(0)) !== null))
|
|
205
|
+
)
|
|
206
|
+
return text
|
|
194
207
|
}
|
|
195
208
|
for (const { regex, text } of globs) {
|
|
196
209
|
if (regex.test(event.type)) return text
|
|
@@ -209,10 +222,16 @@ function matchFirst(
|
|
|
209
222
|
state: BuildState,
|
|
210
223
|
conditions: readonly WaitCondition[],
|
|
211
224
|
globs: readonly EventGlob[],
|
|
225
|
+
events: AbEvent[] = [],
|
|
212
226
|
): string | null {
|
|
213
227
|
for (const { text, cond } of conditions) {
|
|
214
|
-
if (stateSatisfies(cond, state)) return text
|
|
215
|
-
if (
|
|
228
|
+
if (stateSatisfies(cond, state, events)) return text
|
|
229
|
+
if (
|
|
230
|
+
cond.kind === 'attention' &&
|
|
231
|
+
(isAttentionEvent(event.type) ||
|
|
232
|
+
(events.length > 0 && mergeDecision(events, new Date(0)) !== null))
|
|
233
|
+
)
|
|
234
|
+
return text
|
|
216
235
|
}
|
|
217
236
|
for (const { regex, text } of globs) {
|
|
218
237
|
if (regex.test(event.type)) return text
|
|
@@ -305,11 +324,12 @@ export async function abWait(opts: AbWaitOpts): Promise<number> {
|
|
|
305
324
|
const interval = intervalMs ?? watchSelection(storeRef).intervalMs
|
|
306
325
|
|
|
307
326
|
// Decode --since before any read: a foreign cursor is rejected outright.
|
|
308
|
-
const
|
|
327
|
+
const resumedCursor =
|
|
309
328
|
opts.since !== undefined
|
|
310
329
|
? decodeCursor(opts.since, { store: storeRef, repo }, { verb: 'wait', usage: WAIT_USAGE })
|
|
311
|
-
|
|
312
|
-
|
|
330
|
+
: undefined
|
|
331
|
+
const sinceStreams = resumedCursor?.streams ?? {}
|
|
332
|
+
const deliveredAttention = new Set(resumedCursor?.deliveredAttention ?? [])
|
|
313
333
|
|
|
314
334
|
const identity = normalizeGitRemoteUrl(repo)
|
|
315
335
|
const mine = (record: BuildRecord): boolean =>
|
|
@@ -322,17 +342,40 @@ export async function abWait(opts: AbWaitOpts): Promise<number> {
|
|
|
322
342
|
const positions: Record<string, number> = { ...sinceStreams }
|
|
323
343
|
|
|
324
344
|
const encodeCurrent = (): string =>
|
|
325
|
-
encodeCursor({
|
|
345
|
+
encodeCursor({
|
|
346
|
+
v: 1,
|
|
347
|
+
store: storeRef,
|
|
348
|
+
repo,
|
|
349
|
+
streams: { ...positions },
|
|
350
|
+
...(resumedCursor?.lostRuns ? { lostRuns: resumedCursor.lostRuns } : {}),
|
|
351
|
+
deliveredAttention: [...deliveredAttention],
|
|
352
|
+
})
|
|
326
353
|
|
|
327
354
|
/** The one success/terminal record. Exit 0 is contractually tied to
|
|
328
355
|
* exactly one condition-satisfying record; the exit-2 record carries
|
|
329
356
|
* `condition` null so the caller can see how the build ended. */
|
|
330
357
|
const finish = (condition: string | null, event: AbEvent, state: BuildState): number => {
|
|
331
|
-
const
|
|
358
|
+
const observation =
|
|
359
|
+
condition === 'attention' && event.type !== 'escalation.raised'
|
|
360
|
+
? mergeDecision(
|
|
361
|
+
streams.get(event.build)?.events.filter((e) => e.seq <= event.seq) ?? [],
|
|
362
|
+
now(),
|
|
363
|
+
state,
|
|
364
|
+
)
|
|
365
|
+
: null
|
|
366
|
+
if (observation) deliveredAttention.add(mergeIdentity(event.build, observation.pr.number))
|
|
367
|
+
const record = {
|
|
368
|
+
...(observation
|
|
369
|
+
? projectAttentionRecord(observation, state, encodeCurrent(), { build: event.build })
|
|
370
|
+
: projectRecord(event, state, encodeCurrent())),
|
|
371
|
+
condition,
|
|
372
|
+
}
|
|
332
373
|
if (json) {
|
|
333
374
|
opts.stdout(JSON.stringify(record))
|
|
334
375
|
} else {
|
|
335
|
-
const line =
|
|
376
|
+
const line = observation
|
|
377
|
+
? renderAttentionLine(observation, event.build)
|
|
378
|
+
: renderWatchLine(event, state)
|
|
336
379
|
opts.stdout(condition === null ? line : `${line} (${condition})`)
|
|
337
380
|
}
|
|
338
381
|
return condition === null ? 2 : 0
|
|
@@ -351,6 +394,15 @@ export async function abWait(opts: AbWaitOpts): Promise<number> {
|
|
|
351
394
|
if (satisfied === null) satisfied = { event, state, condition }
|
|
352
395
|
}
|
|
353
396
|
|
|
397
|
+
const eventConditions = (event: AbEvent, prefix: AbEvent[]): readonly WaitCondition[] => {
|
|
398
|
+
if (event.type === 'escalation.raised') return conditions
|
|
399
|
+
const observation = mergeDecision(prefix, now())
|
|
400
|
+
return observation &&
|
|
401
|
+
!deliveredAttention.has(mergeIdentity(event.build, observation.pr.number))
|
|
402
|
+
? conditions
|
|
403
|
+
: conditions.filter(({ cond }) => cond.kind !== 'attention')
|
|
404
|
+
}
|
|
405
|
+
|
|
354
406
|
const registerStream = (slug: string, events: AbEvent[], named: boolean): WaitStream => {
|
|
355
407
|
const state = reduceBuild(events)
|
|
356
408
|
const stream: WaitStream = {
|
|
@@ -370,7 +422,7 @@ export async function abWait(opts: AbWaitOpts): Promise<number> {
|
|
|
370
422
|
* latest event. Not cursor-relative — `--since` does not suppress it. */
|
|
371
423
|
const checkRegistrationState = (stream: WaitStream): void => {
|
|
372
424
|
const state = reduceBuild(stream.events)
|
|
373
|
-
const condition = matchState(conditions, state)
|
|
425
|
+
const condition = matchState(conditions, state, stream.events)
|
|
374
426
|
if (condition !== null && stream.events.length > 0) {
|
|
375
427
|
match(stream.events.at(-1)!, state, condition)
|
|
376
428
|
}
|
|
@@ -401,12 +453,20 @@ export async function abWait(opts: AbWaitOpts): Promise<number> {
|
|
|
401
453
|
// the reduction of the prefix ENDING at that event, never the
|
|
402
454
|
// reduction of the full loaded prefix.
|
|
403
455
|
const replay: AbEvent[] = events.filter((event) => event.seq <= resumeSeq)
|
|
456
|
+
if (resumedCursor?.deliveredAttention === undefined) {
|
|
457
|
+
const historical: AbEvent[] = []
|
|
458
|
+
for (const event of replay) {
|
|
459
|
+
historical.push(event)
|
|
460
|
+
const observation = mergeDecision(historical, now())
|
|
461
|
+
if (observation) deliveredAttention.add(mergeIdentity(slug, observation.pr.number))
|
|
462
|
+
}
|
|
463
|
+
}
|
|
404
464
|
for (const event of events) {
|
|
405
465
|
if (event.seq <= resumeSeq) continue
|
|
406
466
|
replay.push(event)
|
|
407
467
|
stream.lastSeq = event.seq
|
|
408
468
|
positions[slug] = event.seq
|
|
409
|
-
const condition = matchEvent(event,
|
|
469
|
+
const condition = matchEvent(event, eventConditions(event, replay), globs, replay)
|
|
410
470
|
if (condition !== null) {
|
|
411
471
|
match(event, reduceBuild(replay), condition)
|
|
412
472
|
break
|
|
@@ -470,7 +530,13 @@ export async function abWait(opts: AbWaitOpts): Promise<number> {
|
|
|
470
530
|
positions[stream.slug] = event.seq
|
|
471
531
|
const state = reduceBuild(stream.events)
|
|
472
532
|
stream.status = state.status
|
|
473
|
-
const condition = matchFirst(
|
|
533
|
+
const condition = matchFirst(
|
|
534
|
+
event,
|
|
535
|
+
state,
|
|
536
|
+
eventConditions(event, stream.events),
|
|
537
|
+
globs,
|
|
538
|
+
stream.events,
|
|
539
|
+
)
|
|
474
540
|
if (condition !== null) {
|
|
475
541
|
match(event, state, condition)
|
|
476
542
|
return
|
|
@@ -564,6 +630,7 @@ export async function abWait(opts: AbWaitOpts): Promise<number> {
|
|
|
564
630
|
// Cursor streams that are no longer named resume anyway: finishing
|
|
565
631
|
// their backlog is what makes a resumed wait lossless.
|
|
566
632
|
for (const key of Object.keys(sinceStreams)) {
|
|
633
|
+
if (key === '#repo') continue
|
|
567
634
|
await trackBuild(key, sinceStreams[key])
|
|
568
635
|
if (hasMatch()) return finishSatisfied()
|
|
569
636
|
}
|