@defrex/autobuild 0.10.1 → 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 +237 -23
- package/bin/ab-dispatch-kernel.ts +1 -1
- package/docs/architecture.md +66 -4
- package/docs/configuration.md +137 -19
- 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 +107 -7
- package/docs/remote-store-protocol.md +189 -144
- package/docs/remote-ticket-protocol.md +111 -18
- 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/binary.ts +3 -3
- 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 +8 -5
- package/packages/core/src/cli/dashboard/render.ts +37 -7
- package/packages/core/src/cli/dispatch-frontend.ts +16 -1
- package/packages/core/src/cli/dispatch-process.ts +25 -7
- package/packages/core/src/cli/dispatch.ts +247 -47
- package/packages/core/src/cli/dispatcher-liveness.ts +91 -0
- package/packages/core/src/cli/help.ts +45 -13
- 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/repository-status.ts +45 -5
- 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 +350 -68
- package/packages/core/src/config/live.ts +4 -0
- package/packages/core/src/config/schema.ts +65 -11
- package/packages/core/src/connection.ts +86 -0
- package/packages/core/src/events/catalog.ts +10 -2
- package/packages/core/src/events/globs.ts +8 -3
- package/packages/core/src/events/payloads.ts +44 -0
- package/packages/core/src/events/repository.ts +64 -2
- package/packages/core/src/integration/harness.ts +2 -1
- package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +148 -83
- package/packages/core/src/kernel/dispatch-status.ts +9 -1
- package/packages/core/src/kernel/dispatcher-liveness.ts +70 -0
- 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 +12 -0
- package/packages/core/src/kernel/reducer.ts +8 -0
- package/packages/core/src/kernel/ticket-source-health.ts +30 -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 +44 -19
- 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/contract.ts +25 -0
- package/packages/core/src/ports/tickets/create.ts +11 -3
- package/packages/core/src/ports/tickets/fake.ts +10 -8
- package/packages/core/src/ports/tickets/file.ts +1 -0
- package/packages/core/src/ports/tickets/hosted-index.ts +0 -3
- package/packages/core/src/ports/tickets/linear.ts +241 -49
- package/packages/core/src/ports/tickets/remote-protocol.ts +3 -0
- package/packages/core/src/ports/tickets/remote.ts +1 -0
- package/packages/core/src/ports/types.ts +17 -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 +336 -83
- 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/processes/repo-view.ts +2 -1
- package/packages/core/src/store/adapter.ts +1 -0
- package/packages/core/src/store/contract.ts +109 -0
- package/packages/core/src/store/digest.ts +14 -2
- package/packages/core/src/store/local/store.ts +18 -5
- 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 +26 -0
- package/packages/core/src/store/remote/token.ts +74 -13
- package/packages/core/src/store/repo-state-events.ts +49 -59
- package/packages/core/src/store/types.ts +5 -1
- package/packages/core/src/testing/index.ts +7 -0
- package/skills/guide/SKILL.md +175 -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 +159 -34
- package/templates/hosted-service/package.json +13 -0
- package/templates/hosted-service/vercel.json +7 -0
|
@@ -69,6 +69,20 @@ export const HELP_CATALOG: readonly HelpEntry[] = [
|
|
|
69
69
|
'locally. --validate cannot be combined with --force.',
|
|
70
70
|
]),
|
|
71
71
|
},
|
|
72
|
+
{
|
|
73
|
+
name: 'connect',
|
|
74
|
+
audience: 'human',
|
|
75
|
+
summary: 'Connect this project to a hosted service with browser approval.',
|
|
76
|
+
detail:
|
|
77
|
+
'Usage: ab connect [url]\nWith a URL, approve the printed URL and code in your browser. Stores the credential in user configuration and adds [store] url to autobuild.toml. Without a URL, reports the saved credential status. Runs outside sessions.',
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
name: 'disconnect',
|
|
81
|
+
audience: 'human',
|
|
82
|
+
summary: 'Revoke and remove this project’s saved credential.',
|
|
83
|
+
detail:
|
|
84
|
+
'Usage: ab disconnect\nRevokes the saved credential remotely before removing it locally. Keeps project registration, data and [store] url. Runs outside sessions.',
|
|
85
|
+
},
|
|
72
86
|
{
|
|
73
87
|
name: 'upgrade',
|
|
74
88
|
audience: 'human',
|
|
@@ -282,7 +296,7 @@ export const HELP_CATALOG: readonly HelpEntry[] = [
|
|
|
282
296
|
' ab watch [<slug>...] [--repository] [--event <glob>]... [--since <cursor>] [--timeout <duration>] [--interval <duration>] [--count <n>] [--json] [--store <ref>]',
|
|
283
297
|
'',
|
|
284
298
|
'Follow the event logs of one, several, or all active builds in this repository —',
|
|
285
|
-
'
|
|
299
|
+
'the repository journal by default with no slugs — and emit one record per matching event as it',
|
|
286
300
|
'lands. With no slug, every nonterminal build (queued, running, paused, blocked) is',
|
|
287
301
|
'watched, and a build created while the watch runs joins it without a restart.',
|
|
288
302
|
'Naming one or more slugs watches exactly those builds; an unknown slug is an error',
|
|
@@ -291,16 +305,18 @@ export const HELP_CATALOG: readonly HelpEntry[] = [
|
|
|
291
305
|
'',
|
|
292
306
|
'Options:',
|
|
293
307
|
' --repository Also follow the repository journal (dispatcher and harvest',
|
|
294
|
-
' events).
|
|
308
|
+
' events) for named watches. No-slug watches include it by default.',
|
|
295
309
|
' --event <glob> Repeatable glob over event type names: * matches any run of',
|
|
296
310
|
' characters, ? one character (escalation.*, pr.merged, *). Every',
|
|
297
311
|
' glob must match at least one type in the build catalog — or,',
|
|
298
|
-
' with --repository, the repository catalog — or the command fails',
|
|
312
|
+
' with no slugs or --repository, the repository catalog — or the command fails',
|
|
299
313
|
' before any store read. Any --event replaces the default filter.',
|
|
300
314
|
' --since <cursor> Resume from a cursor emitted by an earlier record; every matching',
|
|
301
315
|
' event after that cursor is delivered exactly once, including',
|
|
302
316
|
' events that landed while no watch was running. A cursor from a',
|
|
303
|
-
' different repository or store is rejected.',
|
|
317
|
+
' different repository or store is rejected. A legacy cursor without',
|
|
318
|
+
' a repository position baselines that stream now; reconcile earlier',
|
|
319
|
+
' source conditions with ab repository status.',
|
|
304
320
|
' --timeout <dur> End the watch after this duration (30, 45s, 5m, 1h). Default',
|
|
305
321
|
' 30 minutes; 0 means unbounded.',
|
|
306
322
|
' --interval <dur> Poll cadence. Default: 5s for an http(s) store, 1s for a local store.',
|
|
@@ -309,11 +325,23 @@ export const HELP_CATALOG: readonly HelpEntry[] = [
|
|
|
309
325
|
' flushed as soon as it is written.',
|
|
310
326
|
' --store <ref> Override the BuildStore path or URL.',
|
|
311
327
|
'',
|
|
312
|
-
'Default filter —
|
|
313
|
-
'
|
|
314
|
-
'
|
|
315
|
-
'
|
|
316
|
-
'dispatcher.
|
|
328
|
+
'Default filter — actionable attention only. Builds: escalation.raised and',
|
|
329
|
+
'build.merge-decision (finalized open PR, auto-merge off or currently deferred).',
|
|
330
|
+
'Armed consent awaiting application is silent; one merge decision per build/PR.',
|
|
331
|
+
'Repository: harvest.escalated, harvest.recovery-exhausted, dispatcher.config-rejected,',
|
|
332
|
+
'dispatcher.config-reloaded with restartRequired, abnormal dispatcher.run-stopped,',
|
|
333
|
+
'and dispatcher.lost. dispatcher.ticket-source-persistent reports degradation past',
|
|
334
|
+
'resetAt after a tick started at or after reset completes or fails while still degraded,',
|
|
335
|
+
'or 15 minutes without a reset; dispatcher.tick-failure-persistent reports',
|
|
336
|
+
'five minutes of continuous tick failure outside source grace or cooldown. Each episode',
|
|
337
|
+
'reports once; recovery ends it. Cooldown failures do not start the tick-failure clock.',
|
|
338
|
+
'--event replaces defaults with exactly matching catalog events (or dispatcher.lost).',
|
|
339
|
+
'Routine failures, finalize and terminal outcomes require explicit --event globs.',
|
|
340
|
+
'Derived records carry observation instead of event, with observedAt and PR/reason',
|
|
341
|
+
'or source/cause/message/resetAt or run/firstFailure/error plus episodeSeq.',
|
|
342
|
+
'Cursors suppress delivered observations, including timer-only records. Old v1',
|
|
343
|
+
'cursors remain valid. Fresh watches baseline history and retain repository timers;',
|
|
344
|
+
'reconcile existing build attention through status after arming.',
|
|
317
345
|
'',
|
|
318
346
|
'Record shape (--json): build (slug, or null for a repository event), repo (repository',
|
|
319
347
|
'id, present only for repository events), event (the complete durable envelope: build,',
|
|
@@ -361,19 +389,23 @@ export const HELP_CATALOG: readonly HelpEntry[] = [
|
|
|
361
389
|
'Conditions (--for, repeatable): blocked, paused, running, done, aborted, terminal (done',
|
|
362
390
|
'or aborted), pr=open, pr=merged, pr=closed, pr=conflicted, phase=<phase> (a core phase —',
|
|
363
391
|
'plan, plan-review, implement, code-review, finalize, reconcile — or verify:<step>), and',
|
|
364
|
-
'attention (
|
|
392
|
+
'attention (an open escalation or a build waiting for a merge decision). Merge attention',
|
|
393
|
+
'requires a finalized open PR with auto-merge off or a current actionable gate deferral;',
|
|
394
|
+
'armed pending consent, routine outcomes and self-healing failures stay silent.',
|
|
395
|
+
'An unknown condition value is rejected before',
|
|
365
396
|
'any store access. Repeatable --event globs follow the ab watch rules. Multiple --for',
|
|
366
397
|
'values and --event globs are alternatives: the first one satisfied ends the wait — all',
|
|
367
398
|
'--for values in supply order first (each checked against the state after an event and',
|
|
368
|
-
'the
|
|
399
|
+
'the actionable build projection), then all --event globs in supply order.',
|
|
369
400
|
'',
|
|
370
401
|
'A status, PR-lifecycle, or phase condition is satisfied when a targeted build’s reduction',
|
|
371
402
|
'has that status, PR state, or current phase. If any targeted build already satisfies',
|
|
372
403
|
'such a condition when the command starts, the command returns immediately — in the',
|
|
373
404
|
'manner of kubectl wait — with that build’s latest event; --since does not suppress this.',
|
|
374
405
|
'Otherwise the condition is satisfied by the first later event whose reduction produces',
|
|
375
|
-
'it. attention
|
|
376
|
-
'
|
|
406
|
+
'it. attention also recognizes existing conditions immediately, even with --since.',
|
|
407
|
+
'--event conditions match only events appended after the command started, or a',
|
|
408
|
+
'cursor-resumed backlog after --since; use --event to opt into other event types.',
|
|
377
409
|
'',
|
|
378
410
|
'Record shape (--json): the ab watch record — build, event (the complete durable',
|
|
379
411
|
'envelope), state (the reduction after this event: status, phase, round, openEscalations,',
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { projectToken } from './project-credentials'
|
|
2
|
+
import { resolveRepoOrigin } from './repo-state'
|
|
1
3
|
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
|
2
4
|
import { tmpdir } from 'node:os'
|
|
3
5
|
import { join, resolve } from 'node:path'
|
|
@@ -390,7 +392,31 @@ export async function validateInitReadiness(opts: {
|
|
|
390
392
|
const configPath = join(repo, 'autobuild.toml')
|
|
391
393
|
const configBytes = await readFile(configPath, 'utf8')
|
|
392
394
|
const config = await loadConfig(configPath)
|
|
393
|
-
|
|
395
|
+
hostPreflight(config, opts.env)
|
|
396
|
+
const selectedRemote = opts.env.AB_STORE?.trim() || config.store?.url
|
|
397
|
+
const identity =
|
|
398
|
+
selectedRemote && isRemoteStoreRef(selectedRemote) && !opts.env.AB_TOKEN
|
|
399
|
+
? ((await resolveRepoOrigin(repo, exec)) ?? repo)
|
|
400
|
+
: repo
|
|
401
|
+
const state = resolveRepoStatePaths({
|
|
402
|
+
repo: identity,
|
|
403
|
+
checkout: repo,
|
|
404
|
+
envStore: opts.env.AB_STORE,
|
|
405
|
+
configStore: config.store?.url,
|
|
406
|
+
})
|
|
407
|
+
opts = {
|
|
408
|
+
...opts,
|
|
409
|
+
env: {
|
|
410
|
+
...opts.env,
|
|
411
|
+
AB_STORE: state.storeRef,
|
|
412
|
+
...(isRemoteStoreRef(state.storeRef)
|
|
413
|
+
? { AB_TOKEN: projectToken(state.storeRef, identity, opts.env) }
|
|
414
|
+
: {}),
|
|
415
|
+
},
|
|
416
|
+
}
|
|
417
|
+
if (config.store?.url === state.storeRef && !opts.env.AB_TOKEN) {
|
|
418
|
+
throw new Error('No project credential for this service; run ab connect')
|
|
419
|
+
}
|
|
394
420
|
// Pre-registry parse and redaction come from the builtin capability table;
|
|
395
421
|
// after plugin load the registration's own declarations take over (AUT-516).
|
|
396
422
|
const builtinCaps = builtinWorkspaceProviderCapabilities(config.workspace.provider)
|
|
@@ -405,7 +431,6 @@ export async function validateInitReadiness(opts: {
|
|
|
405
431
|
let caps: WorkspaceProviderCapabilities | undefined
|
|
406
432
|
let assigneeLine: string | undefined
|
|
407
433
|
try {
|
|
408
|
-
hostPreflight(config, opts.env)
|
|
409
434
|
// Ticket acquisition is a host responsibility for both workspace providers.
|
|
410
435
|
// Exercise its read surface before allocating disposable infrastructure.
|
|
411
436
|
const hostPlugins = await loadPlugins(config.plugins, repo, { packageRoot: repo })
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { abConnect, abDisconnect } from './connect'
|
|
1
2
|
/**
|
|
2
3
|
* `ab` — the only channel between agents and the store, and the enforcement
|
|
3
4
|
* point of the entire ontology (SPEC §8). This module is pure routing: every
|
|
@@ -16,7 +17,7 @@ import type { Clock } from '../store/types'
|
|
|
16
17
|
import type { BuildStore } from '../store/types'
|
|
17
18
|
import { textContent } from '../store/types'
|
|
18
19
|
import type { Forge } from '../ports/types'
|
|
19
|
-
import type
|
|
20
|
+
import { spawnExec, type Exec } from '../ports/workspace/git-worktree'
|
|
20
21
|
import type { IdSource } from '../ids'
|
|
21
22
|
import { reduceBuild } from '../kernel/reducer'
|
|
22
23
|
import { artifactDownload, artifactDownloadStream, artifactGet, artifactPut } from './artifact'
|
|
@@ -44,7 +45,6 @@ import { recognizeHelpRequest, renderCommandHelp, renderTopLevelHelp } from './h
|
|
|
44
45
|
import { observe } from './observe'
|
|
45
46
|
import { abPlugin, type PluginContractSubprocess } from './plugin'
|
|
46
47
|
import { preparePrAttachments } from './pr-attachments'
|
|
47
|
-
import { renderPrSummary } from './pr-summary'
|
|
48
48
|
import { abBuildAssetGet, abBuilds, abBuildStatus } from './status'
|
|
49
49
|
import { abRepositoryStatus } from './repository-status'
|
|
50
50
|
import { abWatch, WATCH_USAGE } from './watch'
|
|
@@ -53,6 +53,7 @@ import { abMcp, AB_MCP_USAGE } from './mcp'
|
|
|
53
53
|
import type { StoreOpener } from './store-opening'
|
|
54
54
|
import { done, escalate, verdict } from './terminals'
|
|
55
55
|
import { abTicket, openTicketSource } from './ticket'
|
|
56
|
+
import { checkUpgradeCompatibility, type CompatibilityFetch } from './upgrade-compatibility'
|
|
56
57
|
import { abUpgrade, type ResolveConflict } from './upgrade'
|
|
57
58
|
import { withUpgradeProgress } from './upgrade-progress'
|
|
58
59
|
import {
|
|
@@ -95,6 +96,8 @@ import {
|
|
|
95
96
|
*/
|
|
96
97
|
export const SESSIONLESS_COMMANDS = new Set([
|
|
97
98
|
'init',
|
|
99
|
+
'connect',
|
|
100
|
+
'disconnect',
|
|
98
101
|
'upgrade',
|
|
99
102
|
'update',
|
|
100
103
|
'ticket',
|
|
@@ -186,6 +189,7 @@ export interface SessionlessCliDeps {
|
|
|
186
189
|
* to the running module, independent of the current repository. */
|
|
187
190
|
distributionRoot?: string
|
|
188
191
|
/** Self-update seams keep release/package-manager orchestration testable. */
|
|
192
|
+
compatibilityFetch?: CompatibilityFetch
|
|
189
193
|
selfUpdateCommand?: SelfUpdateCommand
|
|
190
194
|
selfUpdate?: (options: SelfUpdateOptions) => Promise<SelfUpdateResult>
|
|
191
195
|
/** Production supplies the tool-free upgrade agent; tests inject a fake.
|
|
@@ -444,6 +448,22 @@ async function dispatch(argv: string[], deps: SessionlessCliDeps): Promise<numbe
|
|
|
444
448
|
|
|
445
449
|
// init and upgrade run OUTSIDE build sessions (§16.3): they operate on a
|
|
446
450
|
// repo, not a build, so they route before any store/env requirement.
|
|
451
|
+
case 'connect':
|
|
452
|
+
case 'disconnect': {
|
|
453
|
+
const usage = command === 'connect' ? 'usage: ab connect [url]' : 'usage: ab disconnect'
|
|
454
|
+
const parsed = parseArgs(rest, {}, usage)
|
|
455
|
+
if (parsed.positionals.length > (command === 'connect' ? 1 : 0)) throw new Error(usage)
|
|
456
|
+
const options = {
|
|
457
|
+
cwd: deps.workspacePath,
|
|
458
|
+
env: deps.processEnv ?? process.env,
|
|
459
|
+
exec: deps.exec ?? spawnExec,
|
|
460
|
+
print: stdout,
|
|
461
|
+
...(deps.signal ? { signal: deps.signal } : {}),
|
|
462
|
+
}
|
|
463
|
+
if (command === 'connect') await abConnect(parsed.positionals[0], options)
|
|
464
|
+
else await abDisconnect(options)
|
|
465
|
+
return 0
|
|
466
|
+
}
|
|
447
467
|
case 'init': {
|
|
448
468
|
const usage = 'usage: ab init [target] [--force | --validate] (§16.3)'
|
|
449
469
|
const removed = [
|
|
@@ -515,6 +535,14 @@ async function dispatch(argv: string[], deps: SessionlessCliDeps): Promise<numbe
|
|
|
515
535
|
}
|
|
516
536
|
const targetRepo = parsed.positionals[0] ?? deps.workspacePath
|
|
517
537
|
const noCommit = parsed.flags.has('no-commit')
|
|
538
|
+
const compatibilityWarnings = new Set<string>()
|
|
539
|
+
const upgradeStderr = (line: string): void => {
|
|
540
|
+
if (line.startsWith('warning:')) {
|
|
541
|
+
if (compatibilityWarnings.has(line)) return
|
|
542
|
+
compatibilityWarnings.add(line)
|
|
543
|
+
}
|
|
544
|
+
stderr(line)
|
|
545
|
+
}
|
|
518
546
|
const handoff = deps.processEnv?.[SELF_UPDATE_HANDOFF_ENV] === '1'
|
|
519
547
|
let commitContext: UpgradeCommitContext | undefined
|
|
520
548
|
if (!noCommit) {
|
|
@@ -543,9 +571,10 @@ async function dispatch(argv: string[], deps: SessionlessCliDeps): Promise<numbe
|
|
|
543
571
|
if (!parsed.flags.has('no-self-update') && !handoff) {
|
|
544
572
|
const update = await (deps.selfUpdate ?? selfUpdate)({
|
|
545
573
|
targetRepo,
|
|
574
|
+
compatibilityFetch: deps.compatibilityFetch,
|
|
546
575
|
stdout,
|
|
547
|
-
stderr,
|
|
548
|
-
env: deps.processEnv ??
|
|
576
|
+
stderr: upgradeStderr,
|
|
577
|
+
env: deps.processEnv ?? process.env,
|
|
549
578
|
noCommit: noCommit || commitContext === undefined,
|
|
550
579
|
...(commitContext === undefined
|
|
551
580
|
? {}
|
|
@@ -559,6 +588,13 @@ async function dispatch(argv: string[], deps: SessionlessCliDeps): Promise<numbe
|
|
|
559
588
|
if (update.kind === 'handoff') return update.exitCode
|
|
560
589
|
if (update.kind === 'failed') return 1
|
|
561
590
|
}
|
|
591
|
+
await checkUpgradeCompatibility({
|
|
592
|
+
targetRepo,
|
|
593
|
+
distRoot: deps.distributionRoot,
|
|
594
|
+
env: deps.processEnv,
|
|
595
|
+
stderr: upgradeStderr,
|
|
596
|
+
fetch: deps.compatibilityFetch,
|
|
597
|
+
})
|
|
562
598
|
const resolverFactory = deps.upgradeResolverFactory
|
|
563
599
|
let resolver: ResolveConflict | undefined
|
|
564
600
|
const resolveConflict: ResolveConflict | undefined =
|
|
@@ -1314,7 +1350,7 @@ async function dispatch(argv: string[], deps: SessionlessCliDeps): Promise<numbe
|
|
|
1314
1350
|
const meta = await artifactPut(session, kind, file, { attach })
|
|
1315
1351
|
|
|
1316
1352
|
// A designation made after the PR exists (verify after reconcile or a
|
|
1317
|
-
// finalize post-step)
|
|
1353
|
+
// finalize post-step) prepares hosted attachments. Every
|
|
1318
1354
|
// external side effect is best-effort after the atomic designation.
|
|
1319
1355
|
if (attach) {
|
|
1320
1356
|
try {
|
|
@@ -1328,16 +1364,6 @@ async function dispatch(argv: string[], deps: SessionlessCliDeps): Promise<numbe
|
|
|
1328
1364
|
// asset identity before throwing. Keep going so its hosting
|
|
1329
1365
|
// failure cannot suppress the complete text projection.
|
|
1330
1366
|
}
|
|
1331
|
-
try {
|
|
1332
|
-
await session.forge.commentOnPr(
|
|
1333
|
-
session.workspacePath,
|
|
1334
|
-
pr.number,
|
|
1335
|
-
renderPrSummary(session.env, await session.store.getEvents(session.env.build)),
|
|
1336
|
-
)
|
|
1337
|
-
} catch {
|
|
1338
|
-
// The exact artifact and designation are already durable. A
|
|
1339
|
-
// comment refresh cannot turn the agent's deposit into failure.
|
|
1340
|
-
}
|
|
1341
1367
|
}
|
|
1342
1368
|
} catch {
|
|
1343
1369
|
// Reading the optional PR projection is best-effort too.
|
|
@@ -107,12 +107,13 @@ export async function buildMcpTicketBackend(
|
|
|
107
107
|
config.tickets.readyState,
|
|
108
108
|
config.tickets.claimedState,
|
|
109
109
|
config.tickets.createState,
|
|
110
|
+
config.tickets.doneState,
|
|
110
111
|
].filter((state): state is string => state !== undefined),
|
|
111
112
|
),
|
|
112
113
|
]
|
|
113
114
|
return {
|
|
114
115
|
sourceFor: async (context) => {
|
|
115
|
-
if (config.tickets.teamKey !== context.teamKey) {
|
|
116
|
+
if ((config.tickets.teamKey ?? '') !== context.teamKey) {
|
|
116
117
|
throw new Error(
|
|
117
118
|
`the repository's effective ticket config names team "${context.teamKey}", but this checkout's autobuild.toml names team "${String(config.tickets.teamKey)}"; refusing to serve tickets across that drift — run ab mcp from the checkout dispatch uses`,
|
|
118
119
|
)
|
|
@@ -200,6 +201,11 @@ export async function abMcp(opts: AbMcpOpts): Promise<number> {
|
|
|
200
201
|
try {
|
|
201
202
|
const backend = await buildMcpTicketBackend({
|
|
202
203
|
...opts,
|
|
204
|
+
env: {
|
|
205
|
+
...opts.env,
|
|
206
|
+
AB_STORE: context.storeRef,
|
|
207
|
+
...(context.token !== undefined ? { AB_TOKEN: context.token } : {}),
|
|
208
|
+
},
|
|
203
209
|
checkout: context.checkout,
|
|
204
210
|
localStateRoot: context.localStateRoot,
|
|
205
211
|
})
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { summaryBoundary, pendingSummaryRequests } from '../kernel/pr-summary'
|
|
2
|
+
export { summaryBoundary, pendingSummaryRequests } from '../kernel/pr-summary'
|
|
3
|
+
import type { AbEvent } from '../events/catalog'
|
|
4
|
+
import { KERNEL } from '../events/envelope'
|
|
5
|
+
import { isRemoteWorkspace } from '../events/workspace-remote'
|
|
6
|
+
import { reduceBuild } from '../kernel/reducer'
|
|
7
|
+
import { currentPrAttachments } from '../kernel/pr-attachments'
|
|
8
|
+
import { openExecution } from '../processes/execution-settlement'
|
|
9
|
+
import type { Forge } from '../ports/types'
|
|
10
|
+
import { githubRateLimitUntil } from '../ports/forge/github-transport'
|
|
11
|
+
import { summaryMarker } from '../ports/forge/summary-marker'
|
|
12
|
+
import type { BuildStore, Clock } from '../store/types'
|
|
13
|
+
import { html, shellQuote, renderPrSummary } from './pr-summary'
|
|
14
|
+
|
|
15
|
+
export const SUMMARY_LIMIT = 65_536
|
|
16
|
+
|
|
17
|
+
interface SummaryDeps {
|
|
18
|
+
store: BuildStore
|
|
19
|
+
forge: Forge
|
|
20
|
+
slug: string
|
|
21
|
+
workspacePath: string
|
|
22
|
+
storeRef: string
|
|
23
|
+
clock: Clock
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** No credentials are accepted by the renderer. The full oversized projection
|
|
27
|
+
* is deliberately not designated, avoiding recursive growth. */
|
|
28
|
+
export async function boundedSummary(
|
|
29
|
+
deps: SummaryDeps,
|
|
30
|
+
events: readonly AbEvent[],
|
|
31
|
+
): Promise<string> {
|
|
32
|
+
const env = {
|
|
33
|
+
build: deps.slug,
|
|
34
|
+
store: deps.storeRef,
|
|
35
|
+
phase: 'finalize' as const,
|
|
36
|
+
round: 1,
|
|
37
|
+
session: '',
|
|
38
|
+
}
|
|
39
|
+
const full = renderPrSummary(env, events)
|
|
40
|
+
if (full.length <= SUMMARY_LIMIT) return full
|
|
41
|
+
const meta = await deps.store.putArtifact(deps.slug, {
|
|
42
|
+
kind: 'pr-summary',
|
|
43
|
+
content: full,
|
|
44
|
+
metadata: {},
|
|
45
|
+
})
|
|
46
|
+
const command = `ab artifact download ${shellQuote(deps.slug)} ${shellQuote(`pr-summary@${meta.revision}`)} --store ${shellQuote(deps.storeRef)} --output pr-summary.md`
|
|
47
|
+
const compact = [
|
|
48
|
+
summaryMarker(deps.slug),
|
|
49
|
+
`## Autobuild: ${html(deps.slug)}`,
|
|
50
|
+
'',
|
|
51
|
+
`The full summary exceeds the comment limit. It includes ${currentPrAttachments(events).length} PR attachments.`,
|
|
52
|
+
`Store: <code>${html(deps.storeRef)}</code>`,
|
|
53
|
+
'',
|
|
54
|
+
'Download the complete summary, including all pinned artifact commands and hosted links:',
|
|
55
|
+
`<pre><code>${html(command)}</code></pre>`,
|
|
56
|
+
].join('\n')
|
|
57
|
+
if (compact.length > SUMMARY_LIMIT) throw new Error('Summary locator exceeds comment limit')
|
|
58
|
+
return compact
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Caller owns the exclusive build lease and has fenced guest execution.
|
|
62
|
+
* Claim-before-effect plus CAS also fences overlapping refresh triggers. */
|
|
63
|
+
export async function publishSummary(
|
|
64
|
+
deps: SummaryDeps,
|
|
65
|
+
boundary: { occurrence: number; boundary: number },
|
|
66
|
+
): Promise<'attempted' | 'skipped' | 'deferred'> {
|
|
67
|
+
try {
|
|
68
|
+
const events = await deps.store.getEvents(deps.slug)
|
|
69
|
+
const pr = reduceBuild(events).pr
|
|
70
|
+
if (pr === undefined) return 'skipped'
|
|
71
|
+
if (
|
|
72
|
+
events.some(
|
|
73
|
+
(event) =>
|
|
74
|
+
event.type === 'pr-summary.attempted' && event.payload.occurrence === boundary.occurrence,
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
return 'attempted'
|
|
78
|
+
if (
|
|
79
|
+
events.some(
|
|
80
|
+
(event) =>
|
|
81
|
+
event.type === 'pr-summary.result' &&
|
|
82
|
+
event.payload.eligibleAfter !== undefined &&
|
|
83
|
+
Date.parse(event.payload.eligibleAfter) > deps.clock().getTime(),
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
return 'deferred'
|
|
87
|
+
const claim = await deps.store.appendIfCurrent(deps.slug, events.at(-1)?.seq ?? 0, {
|
|
88
|
+
actor: KERNEL,
|
|
89
|
+
type: 'pr-summary.attempted',
|
|
90
|
+
payload: { ...boundary, pr: pr.number },
|
|
91
|
+
})
|
|
92
|
+
if (claim === null) return 'skipped'
|
|
93
|
+
let outcome: 'success' | 'failure' | 'rate-limited' = 'success'
|
|
94
|
+
let eligibleAfter: string | undefined
|
|
95
|
+
try {
|
|
96
|
+
const body = await boundedSummary(deps, await deps.store.getEvents(deps.slug))
|
|
97
|
+
await deps.forge.commentOnPr(deps.workspacePath, pr.number, body)
|
|
98
|
+
} catch (error) {
|
|
99
|
+
eligibleAfter = githubRateLimitUntil(error, deps.clock().getTime())
|
|
100
|
+
outcome = eligibleAfter === undefined ? 'failure' : 'rate-limited'
|
|
101
|
+
}
|
|
102
|
+
await deps.store.append(deps.slug, {
|
|
103
|
+
actor: KERNEL,
|
|
104
|
+
type: 'pr-summary.result',
|
|
105
|
+
payload: {
|
|
106
|
+
attempt: claim.seq,
|
|
107
|
+
outcome,
|
|
108
|
+
...(eligibleAfter === undefined ? {} : { eligibleAfter }),
|
|
109
|
+
},
|
|
110
|
+
})
|
|
111
|
+
return 'attempted'
|
|
112
|
+
} catch {
|
|
113
|
+
return 'skipped'
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Local boundary or durable guest handoff. Optional refresh failures never
|
|
118
|
+
* prevent pipeline progress, including a failed request deposit. */
|
|
119
|
+
export async function refreshSummaryBoundary(deps: SummaryDeps): Promise<boolean> {
|
|
120
|
+
try {
|
|
121
|
+
const events = await deps.store.getEvents(deps.slug)
|
|
122
|
+
const boundary = summaryBoundary(events)
|
|
123
|
+
if (boundary === undefined) return false
|
|
124
|
+
const workspace = events.findLast((event) => event.type === 'workspace.provisioned')
|
|
125
|
+
if (workspace?.type === 'workspace.provisioned' && isRemoteWorkspace(workspace.payload)) {
|
|
126
|
+
const request = events.find(
|
|
127
|
+
(event) =>
|
|
128
|
+
event.type === 'pr-summary.requested' && event.payload.occurrence === boundary.occurrence,
|
|
129
|
+
)
|
|
130
|
+
if (request !== undefined) {
|
|
131
|
+
// A lost result/disposition append must not repeatedly park a guest.
|
|
132
|
+
if (
|
|
133
|
+
events.some(
|
|
134
|
+
(event) =>
|
|
135
|
+
event.type === 'pr-summary.attempted' &&
|
|
136
|
+
event.payload.occurrence === boundary.occurrence,
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
return false
|
|
140
|
+
if (
|
|
141
|
+
events.some(
|
|
142
|
+
(event) =>
|
|
143
|
+
event.type === 'pr-summary.result' &&
|
|
144
|
+
event.payload.eligibleAfter !== undefined &&
|
|
145
|
+
Date.parse(event.payload.eligibleAfter) > deps.clock().getTime(),
|
|
146
|
+
)
|
|
147
|
+
)
|
|
148
|
+
return false
|
|
149
|
+
return pendingSummaryRequests(events).some((event) => event.seq === request.seq)
|
|
150
|
+
}
|
|
151
|
+
if (
|
|
152
|
+
reduceBuild(events).pr === undefined &&
|
|
153
|
+
!events.some(
|
|
154
|
+
(event) =>
|
|
155
|
+
event.type === 'publication.requested' && event.payload.operation === 'finalize',
|
|
156
|
+
)
|
|
157
|
+
)
|
|
158
|
+
return false
|
|
159
|
+
const written = await deps.store.appendIfCurrent(deps.slug, events.at(-1)?.seq ?? 0, {
|
|
160
|
+
actor: KERNEL,
|
|
161
|
+
type: 'pr-summary.requested',
|
|
162
|
+
payload: boundary,
|
|
163
|
+
})
|
|
164
|
+
return written !== null
|
|
165
|
+
}
|
|
166
|
+
await publishSummary(deps, boundary)
|
|
167
|
+
} catch {
|
|
168
|
+
/* Optional publication cannot fail phase execution. */
|
|
169
|
+
}
|
|
170
|
+
return false
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Host-only, after execution settlement. Reuse a launch reservation only
|
|
174
|
+
* before execution.start; otherwise acquire and release our own lease. */
|
|
175
|
+
export async function settleSummaryRequests(
|
|
176
|
+
deps: SummaryDeps & { holder: string; reservation?: string },
|
|
177
|
+
): Promise<void> {
|
|
178
|
+
let acquired = false
|
|
179
|
+
let heartbeat: ReturnType<typeof setInterval> | undefined
|
|
180
|
+
try {
|
|
181
|
+
let events = await deps.store.getEvents(deps.slug)
|
|
182
|
+
if (openExecution(events) !== null) return
|
|
183
|
+
// Repair a crash after terminal/session completion but before guest handoff.
|
|
184
|
+
const workspace = events.findLast((event) => event.type === 'workspace.provisioned')
|
|
185
|
+
if (workspace?.type !== 'workspace.provisioned' || !isRemoteWorkspace(workspace.payload)) return
|
|
186
|
+
const holder = deps.reservation ?? deps.holder
|
|
187
|
+
acquired = await deps.store.claimLease(deps.slug, holder, 60_000)
|
|
188
|
+
if (!acquired) return
|
|
189
|
+
heartbeat = setInterval(() => {
|
|
190
|
+
void deps.store.heartbeat(deps.slug, holder).catch(() => {})
|
|
191
|
+
}, 15_000)
|
|
192
|
+
events = await deps.store.getEvents(deps.slug)
|
|
193
|
+
if (openExecution(events) !== null) return
|
|
194
|
+
await refreshSummaryBoundary(deps)
|
|
195
|
+
events = await deps.store.getEvents(deps.slug)
|
|
196
|
+
const requests = pendingSummaryRequests(events)
|
|
197
|
+
const latest = requests.at(-1)
|
|
198
|
+
for (const request of requests) {
|
|
199
|
+
if (request.type !== 'pr-summary.requested') continue
|
|
200
|
+
const disposition =
|
|
201
|
+
request === latest ? await publishSummary(deps, request.payload) : 'skipped'
|
|
202
|
+
await deps.store.append(deps.slug, {
|
|
203
|
+
actor: KERNEL,
|
|
204
|
+
type: 'pr-summary.settled',
|
|
205
|
+
payload: { request: request.seq, disposition },
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
} catch {
|
|
209
|
+
/* Best effort, separate from branch publication and phase outcome. */
|
|
210
|
+
} finally {
|
|
211
|
+
if (heartbeat !== undefined) clearInterval(heartbeat)
|
|
212
|
+
if (acquired && deps.reservation === undefined) {
|
|
213
|
+
try {
|
|
214
|
+
await deps.store.releaseLease(deps.slug, deps.holder)
|
|
215
|
+
} catch {
|
|
216
|
+
/* lease expires */
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { summaryMarker } from '../ports/forge/summary-marker'
|
|
1
2
|
import type { AbEvent } from '../events/catalog'
|
|
2
3
|
import {
|
|
3
4
|
currentPrAttachments,
|
|
@@ -106,6 +107,7 @@ export function renderPrSummary(env: CliEnv, events: readonly AbEvent[]): string
|
|
|
106
107
|
}
|
|
107
108
|
|
|
108
109
|
return [
|
|
110
|
+
summaryMarker(env.build),
|
|
109
111
|
`## Autobuild: ${html(env.build)}`,
|
|
110
112
|
'',
|
|
111
113
|
'### Verdict history',
|