@defrex/autobuild 0.11.0 → 0.13.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.
Files changed (136) hide show
  1. package/README.md +3 -1
  2. package/SPEC.md +335 -31
  3. package/docs/architecture.md +125 -7
  4. package/docs/configuration.md +142 -21
  5. package/docs/hosted-dispatcher.md +17 -2
  6. package/docs/hosted-service.md +240 -0
  7. package/docs/mcp.md +8 -2
  8. package/docs/operator-api.md +97 -7
  9. package/docs/remote-store-protocol.md +199 -144
  10. package/docs/remote-ticket-protocol.md +249 -19
  11. package/docs/setup.md +199 -16
  12. package/docs/ticket-dependencies.md +1 -1
  13. package/docs/ticket-migration.md +64 -0
  14. package/package.json +7 -6
  15. package/packages/core/src/cli/attention.ts +209 -0
  16. package/packages/core/src/cli/build-control.ts +12 -1
  17. package/packages/core/src/cli/configured-store.ts +17 -0
  18. package/packages/core/src/cli/connect.ts +364 -0
  19. package/packages/core/src/cli/context.ts +7 -1
  20. package/packages/core/src/cli/dashboard/model.ts +4 -5
  21. package/packages/core/src/cli/dashboard/poll.ts +1 -1
  22. package/packages/core/src/cli/dispatch.ts +336 -41
  23. package/packages/core/src/cli/harvest.ts +13 -3
  24. package/packages/core/src/cli/help.ts +51 -16
  25. package/packages/core/src/cli/init-validation.ts +27 -2
  26. package/packages/core/src/cli/installation.ts +67 -13
  27. package/packages/core/src/cli/main.ts +60 -16
  28. package/packages/core/src/cli/mcp.ts +9 -2
  29. package/packages/core/src/cli/pr-summary-publication.ts +220 -0
  30. package/packages/core/src/cli/pr-summary.ts +2 -0
  31. package/packages/core/src/cli/project-credentials.ts +132 -0
  32. package/packages/core/src/cli/repo-state.ts +15 -2
  33. package/packages/core/src/cli/self-update.ts +15 -1
  34. package/packages/core/src/cli/store-opening.ts +22 -14
  35. package/packages/core/src/cli/terminals.ts +0 -10
  36. package/packages/core/src/cli/ticket-assets.ts +7 -2
  37. package/packages/core/src/cli/ticket-editor.ts +82 -0
  38. package/packages/core/src/cli/ticket.ts +172 -35
  39. package/packages/core/src/cli/upgrade-compatibility.ts +80 -0
  40. package/packages/core/src/cli/wait.ts +84 -17
  41. package/packages/core/src/cli/watch.ts +197 -60
  42. package/packages/core/src/config/live.ts +3 -0
  43. package/packages/core/src/config/schema.ts +90 -11
  44. package/packages/core/src/connection.ts +86 -0
  45. package/packages/core/src/events/catalog.ts +11 -2
  46. package/packages/core/src/events/payloads.ts +50 -0
  47. package/packages/core/src/events/repository.ts +13 -0
  48. package/packages/core/src/integration/harness.ts +37 -2
  49. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +145 -83
  50. package/packages/core/src/kernel/engine.ts +24 -0
  51. package/packages/core/src/kernel/generators/build-log.ts +11 -1
  52. package/packages/core/src/kernel/incremental-fixtures.ts +13 -2
  53. package/packages/core/src/kernel/log-index.ts +17 -0
  54. package/packages/core/src/kernel/pr-summary.ts +89 -0
  55. package/packages/core/src/kernel/reducer-registry.ts +10 -0
  56. package/packages/core/src/kernel/reducer.ts +8 -0
  57. package/packages/core/src/ontology.ts +2 -0
  58. package/packages/core/src/operator/annotations.ts +12 -0
  59. package/packages/core/src/operator/index.ts +12 -0
  60. package/packages/core/src/operator/protocol.ts +19 -2
  61. package/packages/core/src/operator/query.ts +38 -16
  62. package/packages/core/src/operator/registry.ts +61 -8
  63. package/packages/core/src/operator/ticket-source-backend.ts +17 -20
  64. package/packages/core/src/operator/tickets.ts +136 -28
  65. package/packages/core/src/plugin-sdk/index.ts +7 -0
  66. package/packages/core/src/plugins/manifest.ts +5 -3
  67. package/packages/core/src/plugins/runtimes.ts +7 -1
  68. package/packages/core/src/ports/forge/fake.ts +13 -1
  69. package/packages/core/src/ports/forge/github-transport.ts +36 -1
  70. package/packages/core/src/ports/forge/github.ts +29 -5
  71. package/packages/core/src/ports/forge/local-git.ts +12 -4
  72. package/packages/core/src/ports/forge/summary-marker.ts +8 -0
  73. package/packages/core/src/ports/runner/session-env.ts +3 -0
  74. package/packages/core/src/ports/tickets/contract.ts +66 -1
  75. package/packages/core/src/ports/tickets/create.ts +54 -10
  76. package/packages/core/src/ports/tickets/fake.ts +30 -1
  77. package/packages/core/src/ports/tickets/file-assets.ts +159 -0
  78. package/packages/core/src/ports/tickets/file-codec.ts +90 -0
  79. package/packages/core/src/ports/tickets/file.ts +114 -120
  80. package/packages/core/src/ports/tickets/grants.ts +47 -0
  81. package/packages/core/src/ports/tickets/hosted-index.ts +17 -3
  82. package/packages/core/src/ports/tickets/identity.ts +20 -0
  83. package/packages/core/src/ports/tickets/lifecycle.ts +18 -0
  84. package/packages/core/src/ports/tickets/linear.ts +74 -9
  85. package/packages/core/src/ports/tickets/migration.ts +336 -0
  86. package/packages/core/src/ports/tickets/operations.ts +38 -7
  87. package/packages/core/src/ports/tickets/portable-assets.ts +54 -0
  88. package/packages/core/src/ports/tickets/remote-protocol.ts +96 -0
  89. package/packages/core/src/ports/tickets/remote.ts +61 -3
  90. package/packages/core/src/ports/tickets/requested-id.ts +46 -0
  91. package/packages/core/src/ports/tickets/revision.ts +62 -0
  92. package/packages/core/src/ports/tickets/states.ts +61 -0
  93. package/packages/core/src/ports/types.ts +40 -3
  94. package/packages/core/src/ports/workspace/build-execution.ts +6 -1
  95. package/packages/core/src/ports/workspace/create.ts +15 -2
  96. package/packages/core/src/ports/workspace/distribution-archive.ts +3 -5
  97. package/packages/core/src/ports/workspace/harvest-execution.ts +3 -1
  98. package/packages/core/src/ports/workspace/local-build-execution.ts +8 -1
  99. package/packages/core/src/ports/workspace/operator-sandbox.ts +2 -0
  100. package/packages/core/src/ports/workspace/vercel-sandbox.ts +51 -19
  101. package/packages/core/src/processes/build-execution-state.ts +1 -1
  102. package/packages/core/src/processes/build-runner.ts +87 -26
  103. package/packages/core/src/processes/dispatch-failure-budget.ts +83 -0
  104. package/packages/core/src/processes/dispatcher.ts +168 -41
  105. package/packages/core/src/processes/execution-no-progress.ts +176 -0
  106. package/packages/core/src/processes/harvest-child.ts +31 -4
  107. package/packages/core/src/processes/harvest-execution-state.ts +19 -2
  108. package/packages/core/src/processes/harvest-runner.ts +166 -104
  109. package/packages/core/src/processes/harvest.ts +34 -14
  110. package/packages/core/src/processes/phase-failure-budget.ts +2 -0
  111. package/packages/core/src/processes/publication-loss.ts +18 -1
  112. package/packages/core/src/processes/repo-view.ts +35 -0
  113. package/packages/core/src/store/contract.ts +32 -1
  114. package/packages/core/src/store/local/store.ts +1 -1
  115. package/packages/core/src/store/remote/client.ts +1 -1
  116. package/packages/core/src/store/remote/compatibility.ts +90 -0
  117. package/packages/core/src/store/remote/credentials.ts +28 -0
  118. package/packages/core/src/store/remote/index.ts +14 -1
  119. package/packages/core/src/store/remote/protocol.ts +25 -0
  120. package/packages/core/src/store/remote/token.ts +74 -13
  121. package/packages/core/src/store/ticket-asset-snapshots.ts +192 -0
  122. package/packages/core/src/store/ticket-assets.ts +86 -8
  123. package/packages/core/src/store/types.ts +1 -1
  124. package/packages/core/src/testing/index.ts +7 -0
  125. package/skills/guide/SKILL.md +232 -34
  126. package/skills/guide/references/operator-api.md +142 -13
  127. package/skills/guide/references/plugin-authoring.md +10 -2
  128. package/skills/guide/references/remote-store-protocol.md +199 -144
  129. package/skills/guide/references/remote-ticket-protocol.md +253 -18
  130. package/skills/guide/references/setup.md +199 -16
  131. package/skills/guide/references/ticket-dependencies.md +1 -1
  132. package/skills/guide/references/ticket-migration.md +64 -0
  133. package/skills/operate/SKILL.md +126 -36
  134. package/skills/tickets/SKILL.md +71 -0
  135. package/templates/hosted-service/package.json +13 -0
  136. package/templates/hosted-service/vercel.json +7 -0
@@ -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
+ }
@@ -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` > repository-local default.
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 = nonBlank(opts.storeRef) ?? nonBlank(opts.envStore) ?? defaultLocalRoot
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,5 @@
1
+ import { join } from 'node:path'
2
+ import { checkUpgradeCompatibility, type CompatibilityFetch } from './upgrade-compatibility'
1
3
  import { readFile, writeFile } from 'node:fs/promises'
2
4
  import semver from 'semver'
3
5
  import type { ExecResult } from '../ports/workspace/git-worktree'
@@ -71,6 +73,7 @@ export function registryVersion(response: { status: number; body: string }): str
71
73
 
72
74
  export interface SelfUpdateOptions {
73
75
  targetRepo: string
76
+ compatibilityFetch?: CompatibilityFetch
74
77
  version?: string
75
78
  distRoot?: string
76
79
  env?: Record<string, string | undefined>
@@ -361,6 +364,7 @@ export async function selfUpdate(options: SelfUpdateOptions): Promise<SelfUpdate
361
364
  const inspection = await inspectInstallation({
362
365
  distRoot,
363
366
  globalBin: globalBinResult.stdout.trim(),
367
+ cwd: options.targetRepo,
364
368
  })
365
369
  if (inspection.kind !== 'bun-forge' && inspection.kind !== 'npm-registry') {
366
370
  return explicit ? fail(options, inspection.reason) : warn(options, inspection.reason)
@@ -393,6 +397,14 @@ export async function selfUpdate(options: SelfUpdateOptions): Promise<SelfUpdate
393
397
  return { kind: 'continue' }
394
398
  }
395
399
 
400
+ await checkUpgradeCompatibility({
401
+ targetRepo: options.targetRepo,
402
+ version: resolved,
403
+ env: options.env,
404
+ stderr: options.stderr,
405
+ fetch: options.compatibilityFetch,
406
+ })
407
+
396
408
  const dependency = updateDependency(install, resolved)
397
409
  let updateCommand: string[]
398
410
  if (install.scope === 'global') {
@@ -459,7 +471,9 @@ export async function selfUpdate(options: SelfUpdateOptions): Promise<SelfUpdate
459
471
 
460
472
  let updated: DistributionIdentity
461
473
  try {
462
- updated = await readDistributionIdentity(distRoot)
474
+ updated = await readDistributionIdentity(
475
+ join(install.ownerRoot, 'node_modules', ...install.packageName.split('/')),
476
+ )
463
477
  // Injected command fixtures do not rewrite disk; a real successful Bun
464
478
  // operation must. Keep the check injectable by accepting the old identity
465
479
  // only when the command seam itself was supplied.
@@ -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 { isRemoteStoreRef, resolveRepoState, type RepoStatePaths } from './repo-state'
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 token-backed server authorization; filesystem handles receive
43
- * the equivalent in-process scope. Keeping this primitive shared prevents
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
- ref: string,
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` on the local path. Exposed so a
69
- * command core can extend own-resource authority to remote handles too,
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 = optionalToken(opts.env.AB_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
- * Remote store handles do not session-scope (bearer authorization is the
119
- * server's concern), so a command core that needs own-build authority must
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, opts)
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
 
@@ -42,6 +42,7 @@ export function ticketAssetNote(
42
42
  event: TicketAssetNoteEvent,
43
43
  meta: TicketAssetMeta,
44
44
  cli = 'ab',
45
+ hosted = false,
45
46
  ): string {
46
47
  const fileCount = meta.entries.filter((entry) => entry.type === 'file').length
47
48
  const label = `\`${meta.kind}/${meta.name}\``
@@ -52,7 +53,9 @@ export function ticketAssetNote(
52
53
  return [
53
54
  `Ticket asset attached: ${label} (revision ${meta.revision}, ${contents}).`,
54
55
  '',
55
- 'The contents live in the Autobuild store, not in this ticket.',
56
+ hosted
57
+ ? 'The contents live in the ticket service storage.'
58
+ : 'The contents live in the Autobuild store, not in this ticket.',
56
59
  `Download the latest revision: \`${get}\``,
57
60
  `Download this exact revision: \`${get} --rev ${meta.revision}\``,
58
61
  ].join('\n')
@@ -60,7 +63,9 @@ export function ticketAssetNote(
60
63
  return [
61
64
  `Ticket asset replaced: ${label} is now revision ${meta.revision} (${contents}).`,
62
65
  '',
63
- 'The contents live in the Autobuild store, not in this ticket. Earlier revisions stay retrievable.',
66
+ hosted
67
+ ? 'The contents live in the ticket service storage. Earlier revisions stay retrievable.'
68
+ : 'The contents live in the Autobuild store, not in this ticket. Earlier revisions stay retrievable.',
64
69
  `Download the latest revision: \`${get}\``,
65
70
  `Download this exact revision: \`${get} --rev ${meta.revision}\``,
66
71
  ].join('\n')