@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
|
@@ -53,7 +53,7 @@ export async function withDispatchTerminalRestore<T>(
|
|
|
53
53
|
terminal: TerminalOut,
|
|
54
54
|
run: () => Promise<T>,
|
|
55
55
|
boundary: TerminalRestoreProcess = process as unknown as TerminalRestoreProcess,
|
|
56
|
-
requestStop: () => void = () => {},
|
|
56
|
+
requestStop: (signal: NodeJS.Signals) => void = () => {},
|
|
57
57
|
): Promise<T> {
|
|
58
58
|
const hook =
|
|
59
59
|
command === 'dispatch'
|
|
@@ -96,7 +96,7 @@ export async function runBinary(
|
|
|
96
96
|
// cleanly at the next tick boundary (§15.6-C: in-flight leases expire and
|
|
97
97
|
// a future dispatch re-attaches).
|
|
98
98
|
const controller = new AbortController()
|
|
99
|
-
const onSigint = (): void => controller.abort()
|
|
99
|
+
const onSigint = (): void => controller.abort('SIGINT')
|
|
100
100
|
const ownsSigint = usesGenericSessionlessSigintHandler(command)
|
|
101
101
|
const terminal = processTerminal(process.stdout)
|
|
102
102
|
const removeSigint = installSessionlessSigintHandler(command, onSigint)
|
|
@@ -119,7 +119,7 @@ export async function runBinary(
|
|
|
119
119
|
...(resolveDashboardRenderer !== undefined ? { resolveDashboardRenderer } : {}),
|
|
120
120
|
}),
|
|
121
121
|
process as unknown as TerminalRestoreProcess,
|
|
122
|
-
|
|
122
|
+
(signal) => controller.abort(signal),
|
|
123
123
|
)
|
|
124
124
|
} finally {
|
|
125
125
|
removeSigint()
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { parse } from 'smol-toml'
|
|
4
|
+
import { storeSchema } from '../config/schema'
|
|
5
|
+
|
|
6
|
+
/** Store selection happens before full dispatch configuration is loaded. */
|
|
7
|
+
export function configuredStore(checkout: string): string | undefined {
|
|
8
|
+
let text: string
|
|
9
|
+
try {
|
|
10
|
+
text = readFileSync(join(checkout, 'autobuild.toml'), 'utf8')
|
|
11
|
+
} catch (error) {
|
|
12
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined
|
|
13
|
+
throw error
|
|
14
|
+
}
|
|
15
|
+
const table = parse(text)
|
|
16
|
+
return table.store === undefined ? undefined : storeSchema.parse(table.store).url
|
|
17
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomUUID } from 'node:crypto'
|
|
2
|
+
import { access, readFile, rename, unlink, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { constants } from 'node:fs'
|
|
4
|
+
import { dirname, join } from 'node:path'
|
|
5
|
+
import { parse } from 'smol-toml'
|
|
6
|
+
import {
|
|
7
|
+
normalizeConnectionRepository,
|
|
8
|
+
normalizeServiceURL,
|
|
9
|
+
type ConnectionStarted,
|
|
10
|
+
type ConnectionOutcome,
|
|
11
|
+
} from '../connection'
|
|
12
|
+
import type { Exec } from '../ports/workspace/git-worktree'
|
|
13
|
+
import { resolveMainRepo } from './repo-state'
|
|
14
|
+
import { ProjectCredentials } from './project-credentials'
|
|
15
|
+
|
|
16
|
+
interface ConnectOptions {
|
|
17
|
+
cwd: string
|
|
18
|
+
env: Record<string, string | undefined>
|
|
19
|
+
exec: Exec
|
|
20
|
+
print: (message: string) => void
|
|
21
|
+
signal?: AbortSignal
|
|
22
|
+
fetch?: (input: string, init?: RequestInit) => Promise<Response>
|
|
23
|
+
browser?: (url: string) => Promise<void>
|
|
24
|
+
sleep?: (ms: number) => Promise<void>
|
|
25
|
+
now?: () => number
|
|
26
|
+
}
|
|
27
|
+
function requireOperator(env: ConnectOptions['env']) {
|
|
28
|
+
if (
|
|
29
|
+
['AB_BUILD', 'AB_PHASE', 'AB_SESSION', 'AB_HARVEST', 'AB_REPO'].some(
|
|
30
|
+
(key) => env[key] !== undefined,
|
|
31
|
+
)
|
|
32
|
+
)
|
|
33
|
+
throw new Error('connect and disconnect are unavailable inside build or harvest sessions')
|
|
34
|
+
}
|
|
35
|
+
async function project(opts: ConnectOptions) {
|
|
36
|
+
requireOperator(opts.env)
|
|
37
|
+
const checkout = await resolveMainRepo(opts.cwd, opts.exec)
|
|
38
|
+
const remote = await opts.exec(['git', 'remote', 'get-url', 'origin'], { cwd: checkout })
|
|
39
|
+
if (remote.exitCode !== 0 || !remote.stdout.trim())
|
|
40
|
+
throw new Error('A network origin remote is required; add an origin before running ab connect')
|
|
41
|
+
const repo = normalizeConnectionRepository(remote.stdout.trim())
|
|
42
|
+
const path = join(checkout, 'autobuild.toml')
|
|
43
|
+
const text = await readFile(path, 'utf8')
|
|
44
|
+
const table = parse(text)
|
|
45
|
+
const store = table.store as { url?: unknown } | undefined
|
|
46
|
+
if (store && (typeof store !== 'object' || Array.isArray(store)))
|
|
47
|
+
throw new Error('Invalid [store] configuration')
|
|
48
|
+
const service = store?.url === undefined ? undefined : normalizeServiceURL(String(store.url))
|
|
49
|
+
return { checkout, repo, path, text, table, service }
|
|
50
|
+
}
|
|
51
|
+
async function request(
|
|
52
|
+
opts: ConnectOptions,
|
|
53
|
+
service: string,
|
|
54
|
+
path: string,
|
|
55
|
+
body?: unknown,
|
|
56
|
+
token?: string,
|
|
57
|
+
) {
|
|
58
|
+
const response = await (opts.fetch ?? fetch)(`${service}/connect/v1/${path}`, {
|
|
59
|
+
method: body === undefined ? 'GET' : 'POST',
|
|
60
|
+
headers: {
|
|
61
|
+
'content-type': 'application/json',
|
|
62
|
+
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
63
|
+
},
|
|
64
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
65
|
+
signal: AbortSignal.timeout(15000),
|
|
66
|
+
redirect: 'error',
|
|
67
|
+
})
|
|
68
|
+
return response
|
|
69
|
+
}
|
|
70
|
+
async function revoke(opts: ConnectOptions, service: string, token: string) {
|
|
71
|
+
const response = await request(opts, service, 'revoke', {}, token)
|
|
72
|
+
if (!response.ok && response.status !== 401)
|
|
73
|
+
throw new Error('Remote revocation failed; retry ab disconnect when the service is reachable')
|
|
74
|
+
}
|
|
75
|
+
async function atomicConfig(path: string, before: string, after: string) {
|
|
76
|
+
const temporary = `${path}.${randomUUID()}.tmp`
|
|
77
|
+
try {
|
|
78
|
+
await writeFile(temporary, after, { flag: 'wx' })
|
|
79
|
+
if ((await readFile(path, 'utf8')) !== before)
|
|
80
|
+
throw new Error('autobuild.toml changed during connection; retry ab connect')
|
|
81
|
+
await rename(temporary, path)
|
|
82
|
+
} finally {
|
|
83
|
+
await unlink(temporary).catch(() => {})
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
export async function abConnect(raw: string | undefined, opts: ConnectOptions): Promise<void> {
|
|
87
|
+
const p = await project(opts)
|
|
88
|
+
const credentials = new ProjectCredentials(opts.env)
|
|
89
|
+
if (raw === undefined) {
|
|
90
|
+
if (!p.service) {
|
|
91
|
+
opts.print(
|
|
92
|
+
`Project: ${p.repo}\nService: unconfigured\nCredential: missing; run ab connect <url>`,
|
|
93
|
+
)
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
const entry = credentials.load(p.service, p.repo)
|
|
97
|
+
let status = 'missing; run ab connect'
|
|
98
|
+
if (entry) {
|
|
99
|
+
try {
|
|
100
|
+
const response = await request(opts, p.service, 'status', undefined, entry.token)
|
|
101
|
+
if (response.ok) {
|
|
102
|
+
const body = (await response.json()) as {
|
|
103
|
+
repo?: string
|
|
104
|
+
credentialId?: string
|
|
105
|
+
accepted?: boolean
|
|
106
|
+
}
|
|
107
|
+
if (body.repo !== p.repo || body.credentialId !== entry.id || body.accepted !== true)
|
|
108
|
+
throw new Error('Invalid status response')
|
|
109
|
+
}
|
|
110
|
+
status = response.ok
|
|
111
|
+
? 'accepted'
|
|
112
|
+
: response.status === 401 || response.status === 403
|
|
113
|
+
? 'rejected; run ab connect'
|
|
114
|
+
: 'unreachable'
|
|
115
|
+
} catch {
|
|
116
|
+
status = 'unreachable'
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
opts.print(`Service: ${p.service}\nProject: ${p.repo}\nCredential: ${status}`)
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
const service = normalizeServiceURL(raw)
|
|
123
|
+
if (p.service && p.service !== service)
|
|
124
|
+
throw new Error(
|
|
125
|
+
'Configured [store] url differs; update it explicitly before connecting to another service',
|
|
126
|
+
)
|
|
127
|
+
await access(p.path, constants.W_OK)
|
|
128
|
+
await access(dirname(p.path), constants.W_OK)
|
|
129
|
+
const previous = credentials.load(service, p.repo)
|
|
130
|
+
const id = randomBytes(16).toString('hex'),
|
|
131
|
+
secret = randomBytes(32).toString('hex')
|
|
132
|
+
const token = `abp_1_${id}_${secret}`
|
|
133
|
+
const response = await request(opts, service, 'requests', {
|
|
134
|
+
repo: p.repo,
|
|
135
|
+
credentialId: id,
|
|
136
|
+
digest: createHash('sha256').update(secret).digest('hex'),
|
|
137
|
+
})
|
|
138
|
+
if (!response.ok)
|
|
139
|
+
throw new Error('Could not start connection approval; verify the web-enabled service URL')
|
|
140
|
+
const started = (await response.json()) as ConnectionStarted
|
|
141
|
+
if (
|
|
142
|
+
!/^[a-f0-9]{32}$/.test(started.id) ||
|
|
143
|
+
!/^[a-f0-9]{64}$/.test(started.capability) ||
|
|
144
|
+
!/^[A-F0-9]{10}$/.test(started.code) ||
|
|
145
|
+
!Number.isFinite(Date.parse(started.expiresAt))
|
|
146
|
+
)
|
|
147
|
+
throw new Error('Invalid connection response')
|
|
148
|
+
const verification = `${service}/connect?code=${started.code}`
|
|
149
|
+
opts.print(`Approve project ${p.repo}\nOpen ${verification}\nCode: ${started.code}`)
|
|
150
|
+
try {
|
|
151
|
+
if (opts.browser) await opts.browser(verification)
|
|
152
|
+
else {
|
|
153
|
+
const command =
|
|
154
|
+
process.platform === 'darwin'
|
|
155
|
+
? ['open', verification]
|
|
156
|
+
: process.platform === 'win32'
|
|
157
|
+
? ['rundll32', 'url.dll,FileProtocolHandler', verification]
|
|
158
|
+
: ['xdg-open', verification]
|
|
159
|
+
const child = Bun.spawn(command, { stdout: 'ignore', stderr: 'ignore' })
|
|
160
|
+
child.unref()
|
|
161
|
+
}
|
|
162
|
+
} catch {
|
|
163
|
+
opts.print('Browser could not be opened. Use the URL and code on another device.')
|
|
164
|
+
}
|
|
165
|
+
const capability = { id: started.id, capability: started.capability }
|
|
166
|
+
const now = opts.now ?? Date.now
|
|
167
|
+
const sleep = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)))
|
|
168
|
+
const interval = Math.max(5, Math.min(30, started.interval || 5)) * 1000
|
|
169
|
+
let approved = false
|
|
170
|
+
let configChanged = false
|
|
171
|
+
let saved = false
|
|
172
|
+
try {
|
|
173
|
+
let outcome: ConnectionOutcome | undefined
|
|
174
|
+
while (!opts.signal?.aborted && now() <= Date.parse(started.expiresAt) + interval) {
|
|
175
|
+
await sleep(interval)
|
|
176
|
+
const polled = await request(opts, service, 'poll', capability)
|
|
177
|
+
if (polled.status === 429) continue
|
|
178
|
+
if (!polled.ok) throw new Error('Approval polling failed; retry ab connect')
|
|
179
|
+
outcome = (await polled.json()) as ConnectionOutcome
|
|
180
|
+
if (outcome.status !== 'pending') break
|
|
181
|
+
}
|
|
182
|
+
if (!outcome || outcome.status === 'pending' || opts.signal?.aborted) {
|
|
183
|
+
const cancelled = await request(opts, service, 'cancel', capability)
|
|
184
|
+
if (!cancelled.ok)
|
|
185
|
+
throw new Error(
|
|
186
|
+
'Could not confirm cancellation; use the owner credential list to check this connection',
|
|
187
|
+
)
|
|
188
|
+
opts.print(
|
|
189
|
+
opts.signal?.aborted
|
|
190
|
+
? 'Connection cancelled; local configuration unchanged.'
|
|
191
|
+
: 'Connection timed out; local configuration unchanged.',
|
|
192
|
+
)
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
if (outcome.status !== 'approved') {
|
|
196
|
+
opts.print(`Connection ${outcome.status}; local configuration unchanged.`)
|
|
197
|
+
return
|
|
198
|
+
}
|
|
199
|
+
approved = true
|
|
200
|
+
if (outcome.repo !== p.repo || outcome.credentialId !== id)
|
|
201
|
+
throw new Error('Approval identity mismatch')
|
|
202
|
+
if (!p.service) {
|
|
203
|
+
// Dotted root key also supports an existing empty [store] table after it.
|
|
204
|
+
const after = `store.url = ${JSON.stringify(service)}\n${p.text}`
|
|
205
|
+
const parsed = parse(after)
|
|
206
|
+
if ((parsed.store as { url?: string })?.url !== service)
|
|
207
|
+
throw new Error('Cannot safely update [store] url; add it manually and retry')
|
|
208
|
+
await atomicConfig(p.path, p.text, after)
|
|
209
|
+
configChanged = true
|
|
210
|
+
}
|
|
211
|
+
credentials.save({ service, repo: p.repo, id, token })
|
|
212
|
+
saved = true
|
|
213
|
+
if (previous) {
|
|
214
|
+
try {
|
|
215
|
+
await revoke(opts, service, previous.token)
|
|
216
|
+
} catch {
|
|
217
|
+
opts.print(
|
|
218
|
+
`Connected, but the previous credential ${previous.id} could not be revoked; revoke it using the service administrative tools.`,
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
opts.print(`Connected ${p.repo} to ${service}.`)
|
|
223
|
+
if (configChanged) opts.print(`Updated ${p.path}; review and commit this non-secret setting.`)
|
|
224
|
+
} catch (error) {
|
|
225
|
+
if (!saved) {
|
|
226
|
+
try {
|
|
227
|
+
const cancelled = await request(opts, service, 'cancel', capability)
|
|
228
|
+
if (!cancelled.ok) throw new Error()
|
|
229
|
+
} catch {
|
|
230
|
+
opts.print(
|
|
231
|
+
`Could not confirm remote cleanup for credential ${id}; check the service administrative credential list.`,
|
|
232
|
+
)
|
|
233
|
+
}
|
|
234
|
+
if (configChanged) {
|
|
235
|
+
try {
|
|
236
|
+
await atomicConfig(p.path, `store.url = ${JSON.stringify(service)}\n${p.text}`, p.text)
|
|
237
|
+
} catch {
|
|
238
|
+
opts.print('Restore [store] url manually if needed; configuration changed concurrently.')
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
throw new Error(
|
|
243
|
+
approved
|
|
244
|
+
? 'Connection could not be saved; previous credential retained. Repair local storage and run ab connect again.'
|
|
245
|
+
: (error as Error).message,
|
|
246
|
+
)
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
export async function abDisconnect(opts: ConnectOptions): Promise<void> {
|
|
250
|
+
const p = await project(opts)
|
|
251
|
+
if (!p.service) throw new Error('No configured service')
|
|
252
|
+
const credentials = new ProjectCredentials(opts.env)
|
|
253
|
+
const entry = credentials.load(p.service, p.repo)
|
|
254
|
+
if (!entry) {
|
|
255
|
+
opts.print('No stored credential; project configuration and service data retained.')
|
|
256
|
+
return
|
|
257
|
+
}
|
|
258
|
+
await revoke(opts, p.service, entry.token)
|
|
259
|
+
credentials.remove(p.service, p.repo)
|
|
260
|
+
opts.print('Disconnected; project registration, data, and [store] url retained.')
|
|
261
|
+
}
|
|
@@ -116,11 +116,9 @@ export interface PipelineStep {
|
|
|
116
116
|
/** Absent ⇒ the step has never run in the current spec scope ⇒ no time is
|
|
117
117
|
* shown (AC 6). The renderer composes the elapsed segment from this. */
|
|
118
118
|
timing?: StepTiming
|
|
119
|
-
/** Display-only
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
* build is current at merge with effective auto-merge off; the terminal
|
|
123
|
-
* row/detail and the web row/detail render the same words. */
|
|
119
|
+
/** Display-only policy skip reason or parked merge explanation. The shared
|
|
120
|
+
* row/detail renderers use this wrapping prose segment for longer reasons;
|
|
121
|
+
* it is never consulted for routing. */
|
|
124
122
|
reason?: string
|
|
125
123
|
}
|
|
126
124
|
|
|
@@ -268,6 +266,8 @@ export interface DashboardModel {
|
|
|
268
266
|
harvestPaused: boolean
|
|
269
267
|
/** Stable row identity, never a row index. */
|
|
270
268
|
selection?: DashboardSelection
|
|
269
|
+
/** Invocation-local standby identity, independent of notices and build rows. */
|
|
270
|
+
standby?: string
|
|
271
271
|
/** Release courtesy, separate from transient and startup warnings so those
|
|
272
272
|
* notices can never overwrite one another. Store-only frontends project it
|
|
273
273
|
* from their correlated dispatcher run. */
|
|
@@ -750,6 +750,7 @@ export function projectBuildFromFacts(
|
|
|
750
750
|
: stepResults.some((r) => r.outcome === 'skipped')
|
|
751
751
|
? 'skipped'
|
|
752
752
|
: undefined,
|
|
753
|
+
reason: stepResults.find((r) => r.skipCause === 'post-reconcile-policy')?.reason,
|
|
753
754
|
count,
|
|
754
755
|
timing: timingFor(intervals, phase, cycleSince, frozenNow),
|
|
755
756
|
}),
|
|
@@ -1090,6 +1091,7 @@ interface DashboardFrameHeader {
|
|
|
1090
1091
|
queued: number
|
|
1091
1092
|
observationCount: number
|
|
1092
1093
|
selection?: DashboardSelection
|
|
1094
|
+
standby?: string
|
|
1093
1095
|
availableUpgrade?: string
|
|
1094
1096
|
warningLines?: readonly string[]
|
|
1095
1097
|
resumeInput?: ResumeInputView
|
|
@@ -1125,6 +1127,7 @@ export function buildDashboardFromProjected(
|
|
|
1125
1127
|
defaultAutoMerge: settings.defaultAutoMerge,
|
|
1126
1128
|
harvestPaused: harvestProjection.harvestPaused,
|
|
1127
1129
|
...(header.selection !== undefined ? { selection: header.selection } : {}),
|
|
1130
|
+
...(header.standby !== undefined ? { standby: header.standby } : {}),
|
|
1128
1131
|
...(header.availableUpgrade !== undefined ? { availableUpgrade: header.availableUpgrade } : {}),
|
|
1129
1132
|
...(header.warningLines !== undefined && header.warningLines.length > 0
|
|
1130
1133
|
? { warningLines: header.warningLines }
|
|
@@ -722,6 +722,21 @@ interface DetailGeometry extends DetailBodyLayout {
|
|
|
722
722
|
limit: number
|
|
723
723
|
}
|
|
724
724
|
|
|
725
|
+
function standbyLine(model: DashboardModel, color: boolean, width: number): string[] {
|
|
726
|
+
return model.standby === undefined
|
|
727
|
+
? []
|
|
728
|
+
: [
|
|
729
|
+
truncate(
|
|
730
|
+
paint(
|
|
731
|
+
` Dispatcher on standby: repository held by ${displayText(model.standby)}`,
|
|
732
|
+
'yellow',
|
|
733
|
+
color,
|
|
734
|
+
),
|
|
735
|
+
width,
|
|
736
|
+
),
|
|
737
|
+
]
|
|
738
|
+
}
|
|
739
|
+
|
|
725
740
|
function upgradeNoticeLine(
|
|
726
741
|
model: DashboardModel,
|
|
727
742
|
color: boolean,
|
|
@@ -749,7 +764,8 @@ function detailGeometry(
|
|
|
749
764
|
const build = model.builds.find((candidate) => candidate.slug === view.slug)
|
|
750
765
|
if (build === undefined) return undefined
|
|
751
766
|
const layout = detailBody(build, view, { color: false, width, height, now: 0 })
|
|
752
|
-
const topLength =
|
|
767
|
+
const topLength =
|
|
768
|
+
2 + (model.standby === undefined ? 0 : 1) + (model.availableUpgrade === undefined ? 0 : 1)
|
|
753
769
|
const controls =
|
|
754
770
|
model.resumeInput !== undefined || model.abortConfirmation !== undefined
|
|
755
771
|
? dashboardControls(model, false, width, controlsCapacity(height, topLength))
|
|
@@ -808,6 +824,7 @@ function renderDetail(model: DashboardModel, opts: RenderOpts): string[] {
|
|
|
808
824
|
` status ${paint(build.status.toUpperCase(), STATUS_COLOR[build.status], color)}${paused}`,
|
|
809
825
|
width,
|
|
810
826
|
),
|
|
827
|
+
...standbyLine(model, color, width),
|
|
811
828
|
...(notice === undefined ? [] : [notice]),
|
|
812
829
|
]
|
|
813
830
|
const { body } = detailBody(build, view, opts)
|
|
@@ -862,11 +879,12 @@ export function transcriptScrollLimit(
|
|
|
862
879
|
terminalWidth: number,
|
|
863
880
|
height: number,
|
|
864
881
|
hasUpgradeNotice = false,
|
|
882
|
+
hasStandby = false,
|
|
865
883
|
): number {
|
|
866
884
|
const contentLines = transcriptContent(presentation, dashboardContentWidth(terminalWidth)).length
|
|
867
|
-
// Transcript chrome is two header rows,
|
|
885
|
+
// Transcript chrome is two header rows, optional standby/upgrade rows, two
|
|
868
886
|
// separators, and controls. Keep this in lockstep with renderTranscript.
|
|
869
|
-
const capacity = Math.max(0, height - 5 - (hasUpgradeNotice ? 1 : 0))
|
|
887
|
+
const capacity = Math.max(0, height - 5 - (hasUpgradeNotice ? 1 : 0) - (hasStandby ? 1 : 0))
|
|
870
888
|
return Math.max(0, contentLines - capacity)
|
|
871
889
|
}
|
|
872
890
|
|
|
@@ -877,8 +895,15 @@ export function moveTranscriptScroll(
|
|
|
877
895
|
current: number,
|
|
878
896
|
delta: number,
|
|
879
897
|
hasUpgradeNotice = false,
|
|
898
|
+
hasStandby = false,
|
|
880
899
|
): number {
|
|
881
|
-
const limit = transcriptScrollLimit(
|
|
900
|
+
const limit = transcriptScrollLimit(
|
|
901
|
+
presentation,
|
|
902
|
+
terminalWidth,
|
|
903
|
+
height,
|
|
904
|
+
hasUpgradeNotice,
|
|
905
|
+
hasStandby,
|
|
906
|
+
)
|
|
882
907
|
// Clamp current first because a resize, or state from an older controller,
|
|
883
908
|
// may leave it beyond the freshly wrapped viewport's end.
|
|
884
909
|
return Math.max(0, Math.min(limit, Math.min(current, limit) + delta))
|
|
@@ -909,6 +934,7 @@ function renderTranscript(model: DashboardModel, opts: RenderOpts): string[] {
|
|
|
909
934
|
const top = [
|
|
910
935
|
truncate(`${paint('Transcript', 'bold', color)} ${displayText(view.slug)}`, width),
|
|
911
936
|
truncate(` session ${displayText(view.sessionId)}`, width),
|
|
937
|
+
...standbyLine(model, color, width),
|
|
912
938
|
...(notice === undefined ? [] : [notice]),
|
|
913
939
|
]
|
|
914
940
|
const content = transcriptContent(view.transcript, width).map((line) => truncate(line, width))
|
|
@@ -948,11 +974,12 @@ export function sessionScrollLimit(
|
|
|
948
974
|
terminalWidth: number,
|
|
949
975
|
height: number,
|
|
950
976
|
hasUpgradeNotice = false,
|
|
977
|
+
hasStandby = false,
|
|
951
978
|
): number {
|
|
952
979
|
const contentLines = sessionContentLines(view, dashboardContentWidth(terminalWidth)).length
|
|
953
|
-
// Session chrome is two header rows,
|
|
980
|
+
// Session chrome is two header rows, optional standby/upgrade rows, two
|
|
954
981
|
// separators, and controls — the transcript's exact arithmetic.
|
|
955
|
-
const capacity = Math.max(0, height - 5 - (hasUpgradeNotice ? 1 : 0))
|
|
982
|
+
const capacity = Math.max(0, height - 5 - (hasUpgradeNotice ? 1 : 0) - (hasStandby ? 1 : 0))
|
|
956
983
|
return Math.max(0, contentLines - capacity)
|
|
957
984
|
}
|
|
958
985
|
|
|
@@ -963,8 +990,9 @@ export function moveSessionScroll(
|
|
|
963
990
|
current: number,
|
|
964
991
|
delta: number,
|
|
965
992
|
hasUpgradeNotice = false,
|
|
993
|
+
hasStandby = false,
|
|
966
994
|
): number {
|
|
967
|
-
const limit = sessionScrollLimit(view, terminalWidth, height, hasUpgradeNotice)
|
|
995
|
+
const limit = sessionScrollLimit(view, terminalWidth, height, hasUpgradeNotice, hasStandby)
|
|
968
996
|
// Clamp current first: a resize, a feed apply, or state from an older
|
|
969
997
|
// controller may leave it beyond the freshly wrapped viewport's end.
|
|
970
998
|
return Math.max(0, Math.min(limit, Math.min(current, limit) + delta))
|
|
@@ -983,6 +1011,7 @@ function renderSessionView(model: DashboardModel, opts: RenderOpts): string[] {
|
|
|
983
1011
|
}`,
|
|
984
1012
|
width,
|
|
985
1013
|
),
|
|
1014
|
+
...standbyLine(model, color, width),
|
|
986
1015
|
...(notice === undefined ? [] : [notice]),
|
|
987
1016
|
]
|
|
988
1017
|
const content = sessionContentLines(view, width).map((line) => truncate(line, width))
|
|
@@ -1417,6 +1446,7 @@ function renderDashboardContent(model: DashboardModel, opts: RenderOpts): string
|
|
|
1417
1446
|
const top = [
|
|
1418
1447
|
summary,
|
|
1419
1448
|
toggles,
|
|
1449
|
+
...standbyLine(model, color, width),
|
|
1420
1450
|
...(upgradeNotice === undefined ? [] : [upgradeNotice]),
|
|
1421
1451
|
...warnings,
|
|
1422
1452
|
]
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { StandbyIdentityResolver } from './dispatcher-liveness'
|
|
1
2
|
import { configSchema, type Config } from '../config/schema'
|
|
2
3
|
import { humanActor } from '../events/envelope'
|
|
3
4
|
import type { RepositoryEvent } from '../events/repository'
|
|
@@ -114,6 +115,7 @@ export interface DispatchFrontendOptions {
|
|
|
114
115
|
/** Terminal controller with a deliberately narrow wiring surface: terminal,
|
|
115
116
|
* BuildStore, immutable repository identity, clock, and child supervision. */
|
|
116
117
|
export class DispatchFrontend {
|
|
118
|
+
private readonly standbyResolver: StandbyIdentityResolver
|
|
117
119
|
private readonly runId = crypto.randomUUID()
|
|
118
120
|
private readonly clock: Clock
|
|
119
121
|
private readonly region: LiveRegion
|
|
@@ -153,6 +155,7 @@ export class DispatchFrontend {
|
|
|
153
155
|
private operatorStopRequested = false
|
|
154
156
|
|
|
155
157
|
constructor(private readonly opts: DispatchFrontendOptions) {
|
|
158
|
+
this.standbyResolver = new StandbyIdentityResolver(opts.store, opts.repo)
|
|
156
159
|
this.clock = opts.clock ?? systemClock
|
|
157
160
|
this.keyboard = createKeyboardProtocol(
|
|
158
161
|
(chunk) => opts.terminal.write(chunk),
|
|
@@ -191,6 +194,7 @@ export class DispatchFrontend {
|
|
|
191
194
|
this.opts.store.getRepoEvents(this.opts.repo, 0),
|
|
192
195
|
)
|
|
193
196
|
: await this.opts.store.getRepoEvents(this.opts.repo, 0)
|
|
197
|
+
this.standbyResolver.ingest(seed)
|
|
194
198
|
this.repositorySeq = seed.at(-1)?.seq ?? 0
|
|
195
199
|
this.dispatchStatus = reduceDispatchStatus(seed, this.runId)
|
|
196
200
|
this.repositoryEvents.push(...seed.filter(isDashboardRepositoryEvent))
|
|
@@ -199,6 +203,7 @@ export class DispatchFrontend {
|
|
|
199
203
|
}
|
|
200
204
|
const delta = await this.opts.store.getRepoEvents(this.opts.repo, this.repositorySeq)
|
|
201
205
|
if (delta.length === 0) return
|
|
206
|
+
this.standbyResolver.ingest(delta)
|
|
202
207
|
this.repositorySeq = delta.at(-1)!.seq
|
|
203
208
|
this.dispatchStatus = reduceDispatchStatus(delta, this.runId, this.dispatchStatus)
|
|
204
209
|
this.repositoryEvents.push(...delta.filter(isDashboardRepositoryEvent))
|
|
@@ -367,10 +372,14 @@ export class DispatchFrontend {
|
|
|
367
372
|
...status.roleWarnings,
|
|
368
373
|
...(status.warningNotice !== undefined ? [status.warningNotice] : []),
|
|
369
374
|
]
|
|
375
|
+
const standby = this.opts.once
|
|
376
|
+
? undefined
|
|
377
|
+
: await this.standbyResolver.refresh(this.runId, status.yieldedHolder, this.clock())
|
|
370
378
|
const projected = buildDashboardFromProjected(
|
|
371
379
|
snapshot.builds,
|
|
372
380
|
{
|
|
373
381
|
repo: this.opts.repo,
|
|
382
|
+
...(standby !== undefined ? { standby } : {}),
|
|
374
383
|
queued: status.queued ?? 0,
|
|
375
384
|
activeCount: [...snapshot.states.values()].filter(
|
|
376
385
|
(state) => state.status !== 'done' && state.status !== 'aborted',
|
|
@@ -409,6 +418,7 @@ export class DispatchFrontend {
|
|
|
409
418
|
this.opts.terminal.columns,
|
|
410
419
|
paintableRows(this.opts.terminal.rows),
|
|
411
420
|
upgrade,
|
|
421
|
+
this.model?.standby !== undefined,
|
|
412
422
|
)
|
|
413
423
|
const current = view.follow ? limit : view.scroll
|
|
414
424
|
const scroll = moveSessionScroll(
|
|
@@ -418,6 +428,7 @@ export class DispatchFrontend {
|
|
|
418
428
|
current,
|
|
419
429
|
delta,
|
|
420
430
|
upgrade,
|
|
431
|
+
this.model?.standby !== undefined,
|
|
421
432
|
)
|
|
422
433
|
let follow = view.follow
|
|
423
434
|
if (delta < 0 && follow && scroll < limit) follow = false
|
|
@@ -437,6 +448,7 @@ export class DispatchFrontend {
|
|
|
437
448
|
this.view.scroll,
|
|
438
449
|
delta,
|
|
439
450
|
this.model?.availableUpgrade !== undefined,
|
|
451
|
+
this.model?.standby !== undefined,
|
|
440
452
|
),
|
|
441
453
|
}
|
|
442
454
|
} else if (this.view?.kind === 'detail' && this.model !== undefined) {
|
|
@@ -598,6 +610,7 @@ export class DispatchFrontend {
|
|
|
598
610
|
terminal.columns,
|
|
599
611
|
paintableRows(terminal.rows),
|
|
600
612
|
this.model?.availableUpgrade !== undefined,
|
|
613
|
+
this.model?.standby !== undefined,
|
|
601
614
|
)
|
|
602
615
|
this.view = {
|
|
603
616
|
...next,
|
|
@@ -977,7 +990,9 @@ export class DispatchFrontend {
|
|
|
977
990
|
private requestOperatorStop(): void {
|
|
978
991
|
this.operatorStopRequested = true
|
|
979
992
|
this.restorePresentationForStop()
|
|
980
|
-
void this.child?.stop(
|
|
993
|
+
void this.child?.stop(
|
|
994
|
+
typeof this.opts.signal?.reason === 'string' ? this.opts.signal.reason : undefined,
|
|
995
|
+
)
|
|
981
996
|
}
|
|
982
997
|
|
|
983
998
|
/** Stop terminal ownership synchronously before awaiting a kernel that may
|
|
@@ -25,7 +25,7 @@ export interface DispatchChildResult {
|
|
|
25
25
|
|
|
26
26
|
export interface DispatchChildHandle {
|
|
27
27
|
completed: Promise<DispatchChildResult>
|
|
28
|
-
stop(): Promise<void>
|
|
28
|
+
stop(signal?: string): Promise<void>
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
export interface DispatchSubprocess {
|
|
@@ -57,16 +57,20 @@ export interface DispatchKernelSignalBoundary {
|
|
|
57
57
|
* one-shot handlers would expose the child to the default disposition on a
|
|
58
58
|
* repeated Ctrl-C while it is still crossing an unsafe claim boundary. */
|
|
59
59
|
export function installDispatchKernelSignalHandlers(
|
|
60
|
-
onStop: () => void,
|
|
60
|
+
onStop: (signal: DispatchKernelSignal) => void,
|
|
61
61
|
boundary: DispatchKernelSignalBoundary = process,
|
|
62
62
|
): DispatchProcessHook {
|
|
63
|
-
|
|
63
|
+
const listeners = DISPATCH_KERNEL_SIGNALS.map((signal) => ({
|
|
64
|
+
signal,
|
|
65
|
+
listener: () => onStop(signal),
|
|
66
|
+
}))
|
|
67
|
+
for (const { signal, listener } of listeners) boundary.on(signal, listener)
|
|
64
68
|
let closed = false
|
|
65
69
|
return {
|
|
66
70
|
close(): void {
|
|
67
71
|
if (closed) return
|
|
68
72
|
closed = true
|
|
69
|
-
for (const signal of
|
|
73
|
+
for (const { signal, listener } of listeners) boundary.removeListener(signal, listener)
|
|
70
74
|
},
|
|
71
75
|
}
|
|
72
76
|
}
|
|
@@ -156,13 +160,14 @@ export function superviseDispatchChild(deps: DispatchChildSupervisorDeps): Dispa
|
|
|
156
160
|
env[DISPATCH_CHILD_OPTIONS_ENV] = JSON.stringify(deps.options)
|
|
157
161
|
const child = (deps.spawn ?? defaultSpawn)({ entrypoint, cwd: deps.cwd ?? deps.repo, env })
|
|
158
162
|
|
|
163
|
+
let requestedSignal: string | undefined
|
|
159
164
|
let stopping = false
|
|
160
165
|
let forced = false
|
|
161
166
|
let stopPromise: Promise<void> | undefined
|
|
162
167
|
// This is the sole supervisor writer of a missing stop fact. `stop()` only
|
|
163
168
|
// chooses/awaits process termination, avoiding check-then-append races.
|
|
164
169
|
const completed = child.exited.then(async (exitCode): Promise<DispatchChildResult> => {
|
|
165
|
-
const processSignal = child.signalCode === null ?
|
|
170
|
+
const processSignal = child.signalCode === null ? requestedSignal : String(child.signalCode)
|
|
166
171
|
const events = await deps.store.getRepoEvents(deps.repo)
|
|
167
172
|
const recorded = events.findLast(
|
|
168
173
|
(event) => event.type === 'dispatcher.run-stopped' && event.payload.run === deps.run,
|
|
@@ -179,7 +184,7 @@ export function superviseDispatchChild(deps: DispatchChildSupervisorDeps): Dispa
|
|
|
179
184
|
|
|
180
185
|
const outcome: DispatchChildOutcome = forced
|
|
181
186
|
? 'forced'
|
|
182
|
-
: stopping && exitCode === 0 &&
|
|
187
|
+
: stopping && exitCode === 0 && child.signalCode === null
|
|
183
188
|
? 'normal'
|
|
184
189
|
: 'abnormal'
|
|
185
190
|
const error =
|
|
@@ -209,11 +214,24 @@ export function superviseDispatchChild(deps: DispatchChildSupervisorDeps): Dispa
|
|
|
209
214
|
|
|
210
215
|
return {
|
|
211
216
|
completed,
|
|
212
|
-
stop(): Promise<void> {
|
|
217
|
+
stop(signal?: string): Promise<void> {
|
|
213
218
|
if (stopPromise !== undefined) return stopPromise
|
|
214
219
|
stopPromise = (async () => {
|
|
215
220
|
if (child.exitCode !== null) return
|
|
216
221
|
stopping = true
|
|
222
|
+
requestedSignal = signal
|
|
223
|
+
if (signal !== undefined) {
|
|
224
|
+
try {
|
|
225
|
+
await deps.store.appendRepo(deps.repo, {
|
|
226
|
+
actor: DISPATCHER,
|
|
227
|
+
type: 'dispatcher.stop-requested',
|
|
228
|
+
payload: { run: deps.run, signal },
|
|
229
|
+
})
|
|
230
|
+
} catch {
|
|
231
|
+
// Signal delivery must survive a transient Store failure. The
|
|
232
|
+
// kernel can still record its own abort reason after draining.
|
|
233
|
+
}
|
|
234
|
+
}
|
|
217
235
|
child.kill('SIGINT')
|
|
218
236
|
const timeout = deps.stopTimeoutMs ?? DEFAULT_STOP_TIMEOUT_MS
|
|
219
237
|
const stopped = await Promise.race([
|