@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
|
@@ -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',
|
|
@@ -311,12 +325,23 @@ export const HELP_CATALOG: readonly HelpEntry[] = [
|
|
|
311
325
|
' flushed as soon as it is written.',
|
|
312
326
|
' --store <ref> Override the BuildStore path or URL.',
|
|
313
327
|
'',
|
|
314
|
-
'Default filter —
|
|
315
|
-
'
|
|
316
|
-
'
|
|
317
|
-
'
|
|
318
|
-
'dispatcher.
|
|
319
|
-
'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.',
|
|
320
345
|
'',
|
|
321
346
|
'Record shape (--json): build (slug, or null for a repository event), repo (repository',
|
|
322
347
|
'id, present only for repository events), event (the complete durable envelope: build,',
|
|
@@ -364,19 +389,23 @@ export const HELP_CATALOG: readonly HelpEntry[] = [
|
|
|
364
389
|
'Conditions (--for, repeatable): blocked, paused, running, done, aborted, terminal (done',
|
|
365
390
|
'or aborted), pr=open, pr=merged, pr=closed, pr=conflicted, phase=<phase> (a core phase —',
|
|
366
391
|
'plan, plan-review, implement, code-review, finalize, reconcile — or verify:<step>), and',
|
|
367
|
-
'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',
|
|
368
396
|
'any store access. Repeatable --event globs follow the ab watch rules. Multiple --for',
|
|
369
397
|
'values and --event globs are alternatives: the first one satisfied ends the wait — all',
|
|
370
398
|
'--for values in supply order first (each checked against the state after an event and',
|
|
371
|
-
'the
|
|
399
|
+
'the actionable build projection), then all --event globs in supply order.',
|
|
372
400
|
'',
|
|
373
401
|
'A status, PR-lifecycle, or phase condition is satisfied when a targeted build’s reduction',
|
|
374
402
|
'has that status, PR state, or current phase. If any targeted build already satisfies',
|
|
375
403
|
'such a condition when the command starts, the command returns immediately — in the',
|
|
376
404
|
'manner of kubectl wait — with that build’s latest event; --since does not suppress this.',
|
|
377
405
|
'Otherwise the condition is satisfied by the first later event whose reduction produces',
|
|
378
|
-
'it. attention
|
|
379
|
-
'
|
|
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.',
|
|
380
409
|
'',
|
|
381
410
|
'Record shape (--json): the ab watch record — build, event (the complete durable',
|
|
382
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',
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
2
|
+
import {
|
|
3
|
+
constants,
|
|
4
|
+
fstatSync,
|
|
5
|
+
chmodSync,
|
|
6
|
+
lstatSync,
|
|
7
|
+
mkdirSync,
|
|
8
|
+
openSync,
|
|
9
|
+
closeSync,
|
|
10
|
+
readFileSync,
|
|
11
|
+
writeFileSync,
|
|
12
|
+
renameSync,
|
|
13
|
+
unlinkSync,
|
|
14
|
+
} from 'node:fs'
|
|
15
|
+
import { homedir } from 'node:os'
|
|
16
|
+
import { isAbsolute, join } from 'node:path'
|
|
17
|
+
import { normalizeServiceURL } from '../connection'
|
|
18
|
+
import { normalizeGitRemoteUrl } from '../kernel/origin'
|
|
19
|
+
|
|
20
|
+
export interface ProjectCredential {
|
|
21
|
+
service: string
|
|
22
|
+
repo: string
|
|
23
|
+
id: string
|
|
24
|
+
token: string
|
|
25
|
+
}
|
|
26
|
+
export class ProjectCredentials {
|
|
27
|
+
readonly directory: string
|
|
28
|
+
constructor(env: Record<string, string | undefined> = process.env) {
|
|
29
|
+
const root = env.XDG_CONFIG_HOME
|
|
30
|
+
if (root && !isAbsolute(root))
|
|
31
|
+
throw new Error('XDG_CONFIG_HOME must be absolute for project credentials')
|
|
32
|
+
this.directory = join(
|
|
33
|
+
root ||
|
|
34
|
+
(env.HOME && isAbsolute(env.HOME) ? join(env.HOME, '.config') : join(homedir(), '.config')),
|
|
35
|
+
'autobuild',
|
|
36
|
+
'credentials',
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
private path(service: string, repo: string): string {
|
|
40
|
+
return join(
|
|
41
|
+
this.directory,
|
|
42
|
+
`${createHash('sha256')
|
|
43
|
+
.update(JSON.stringify([normalizeServiceURL(service), normalizeGitRemoteUrl(repo)]))
|
|
44
|
+
.digest('hex')}.json`,
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
private guard(path: string): boolean {
|
|
48
|
+
try {
|
|
49
|
+
const info = lstatSync(path)
|
|
50
|
+
if (!info.isFile() || info.isSymbolicLink()) throw new Error('unsafe credential file')
|
|
51
|
+
return true
|
|
52
|
+
} catch (error) {
|
|
53
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false
|
|
54
|
+
throw error
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
load(service: string, repo: string): ProjectCredential | undefined {
|
|
58
|
+
try {
|
|
59
|
+
const path = this.path(service, repo)
|
|
60
|
+
if (!this.guard(path)) return undefined
|
|
61
|
+
const directory = lstatSync(this.directory)
|
|
62
|
+
if (directory.isSymbolicLink() || (directory.mode & 0o777) !== 0o700) throw new Error()
|
|
63
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW)
|
|
64
|
+
let entry: ProjectCredential
|
|
65
|
+
try {
|
|
66
|
+
if ((fstatSync(fd).mode & 0o777) !== 0o600) throw new Error()
|
|
67
|
+
entry = JSON.parse(readFileSync(fd, 'utf8'))
|
|
68
|
+
} finally {
|
|
69
|
+
closeSync(fd)
|
|
70
|
+
}
|
|
71
|
+
if (
|
|
72
|
+
entry.service !== normalizeServiceURL(service) ||
|
|
73
|
+
entry.repo !== normalizeGitRemoteUrl(repo) ||
|
|
74
|
+
typeof entry.id !== 'string' ||
|
|
75
|
+
typeof entry.token !== 'string' ||
|
|
76
|
+
!/^[a-f0-9]{32}$/.test(entry.id) ||
|
|
77
|
+
!new RegExp(`^abp_1_${entry.id}_[a-f0-9]{64}$`).test(entry.token)
|
|
78
|
+
)
|
|
79
|
+
throw new Error()
|
|
80
|
+
return entry
|
|
81
|
+
} catch {
|
|
82
|
+
throw new Error(
|
|
83
|
+
'Cannot read project credential; repair user credential storage and run ab connect',
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
save(entry: ProjectCredential): void {
|
|
88
|
+
const path = this.path(entry.service, entry.repo)
|
|
89
|
+
let temporary: string | undefined
|
|
90
|
+
try {
|
|
91
|
+
mkdirSync(this.directory, { recursive: true, mode: 0o700 })
|
|
92
|
+
if (lstatSync(this.directory).isSymbolicLink()) throw new Error()
|
|
93
|
+
chmodSync(this.directory, 0o700)
|
|
94
|
+
this.guard(path)
|
|
95
|
+
temporary = `${path}.${randomUUID()}`
|
|
96
|
+
const fd = openSync(temporary, 'wx', 0o600)
|
|
97
|
+
try {
|
|
98
|
+
writeFileSync(fd, `${JSON.stringify(entry)}\n`)
|
|
99
|
+
} finally {
|
|
100
|
+
closeSync(fd)
|
|
101
|
+
}
|
|
102
|
+
renameSync(temporary, path)
|
|
103
|
+
} catch {
|
|
104
|
+
throw new Error('Cannot save project credential; check user credential directory permissions')
|
|
105
|
+
} finally {
|
|
106
|
+
if (temporary) {
|
|
107
|
+
try {
|
|
108
|
+
unlinkSync(temporary)
|
|
109
|
+
} catch {
|
|
110
|
+
/* renamed or absent */
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
remove(service: string, repo: string): void {
|
|
116
|
+
const path = this.path(service, repo)
|
|
117
|
+
if (this.guard(path)) unlinkSync(path)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
export function projectToken(
|
|
121
|
+
service: string,
|
|
122
|
+
repo: string,
|
|
123
|
+
env: Record<string, string | undefined>,
|
|
124
|
+
): string | undefined {
|
|
125
|
+
if (env.AB_TOKEN !== undefined && env.AB_TOKEN !== '') return env.AB_TOKEN
|
|
126
|
+
try {
|
|
127
|
+
normalizeServiceURL(service)
|
|
128
|
+
} catch {
|
|
129
|
+
return undefined
|
|
130
|
+
}
|
|
131
|
+
return new ProjectCredentials(env).load(service, repo)?.token
|
|
132
|
+
}
|