@defrex/autobuild 0.8.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/LICENSE +201 -0
- package/README.md +192 -0
- package/SPEC.md +2269 -0
- package/bin/ab-build-runner.ts +47 -0
- package/bin/ab-dev.ts +27 -0
- package/bin/ab-dispatch-kernel.ts +92 -0
- package/bin/ab-harvest-runner.ts +29 -0
- package/bin/ab-init-probe.ts +19 -0
- package/bin/ab-process-group-reaper.ts +32 -0
- package/bin/ab.ts +6 -0
- package/bin/agent/ab +3 -0
- package/docs/answering-blocked-builds.md +89 -0
- package/docs/architecture.md +726 -0
- package/docs/assets/headline-wide.png +0 -0
- package/docs/configuration.md +1623 -0
- package/docs/hosted-dispatcher-cutover-validation.md +178 -0
- package/docs/hosted-dispatcher.md +204 -0
- package/docs/mcp.md +119 -0
- package/docs/operator-api.md +186 -0
- package/docs/remote-store-protocol.md +972 -0
- package/docs/remote-ticket-protocol.md +79 -0
- package/docs/setup.md +289 -0
- package/docs/spec-standard.md +63 -0
- package/docs/ticket-dependencies.md +59 -0
- package/docs/vercel-sandbox-rollout-probe.md +7 -0
- package/package.json +135 -0
- package/packages/core/src/cli/args.ts +74 -0
- package/packages/core/src/cli/artifact.ts +182 -0
- package/packages/core/src/cli/binary.ts +198 -0
- package/packages/core/src/cli/build-control.ts +789 -0
- package/packages/core/src/cli/build-progress.ts +55 -0
- package/packages/core/src/cli/bulk-control.ts +385 -0
- package/packages/core/src/cli/context.ts +470 -0
- package/packages/core/src/cli/dashboard/actions.ts +59 -0
- package/packages/core/src/cli/dashboard/cells.ts +92 -0
- package/packages/core/src/cli/dashboard/composer.ts +230 -0
- package/packages/core/src/cli/dashboard/detail.ts +100 -0
- package/packages/core/src/cli/dashboard/frame-image.ts +373 -0
- package/packages/core/src/cli/dashboard/live.ts +152 -0
- package/packages/core/src/cli/dashboard/model.ts +1173 -0
- package/packages/core/src/cli/dashboard/poll.ts +196 -0
- package/packages/core/src/cli/dashboard/render.ts +1568 -0
- package/packages/core/src/cli/dashboard/selection.ts +66 -0
- package/packages/core/src/cli/dashboard/session-feed.ts +244 -0
- package/packages/core/src/cli/dashboard/session-view.ts +506 -0
- package/packages/core/src/cli/dashboard/transcript.ts +108 -0
- package/packages/core/src/cli/dev.ts +46 -0
- package/packages/core/src/cli/dispatch-frontend.ts +1045 -0
- package/packages/core/src/cli/dispatch-process.ts +259 -0
- package/packages/core/src/cli/dispatch.ts +3692 -0
- package/packages/core/src/cli/dotenv.ts +48 -0
- package/packages/core/src/cli/env.ts +206 -0
- package/packages/core/src/cli/harvest.ts +698 -0
- package/packages/core/src/cli/help.ts +713 -0
- package/packages/core/src/cli/init-agent.ts +106 -0
- package/packages/core/src/cli/init-validation.ts +583 -0
- package/packages/core/src/cli/init.ts +756 -0
- package/packages/core/src/cli/installation.ts +333 -0
- package/packages/core/src/cli/keyboard.ts +261 -0
- package/packages/core/src/cli/main.ts +1399 -0
- package/packages/core/src/cli/mcp.ts +279 -0
- package/packages/core/src/cli/models.ts +65 -0
- package/packages/core/src/cli/observe.ts +55 -0
- package/packages/core/src/cli/plugin.ts +227 -0
- package/packages/core/src/cli/pr-attachments.ts +138 -0
- package/packages/core/src/cli/pr-summary.ts +124 -0
- package/packages/core/src/cli/publication-settlement.ts +188 -0
- package/packages/core/src/cli/repo-state.ts +213 -0
- package/packages/core/src/cli/repository-status.ts +80 -0
- package/packages/core/src/cli/self-update.ts +443 -0
- package/packages/core/src/cli/status.ts +895 -0
- package/packages/core/src/cli/store-opening.contract.ts +289 -0
- package/packages/core/src/cli/store-opening.ts +167 -0
- package/packages/core/src/cli/store-ref.ts +33 -0
- package/packages/core/src/cli/terminal-restore.ts +174 -0
- package/packages/core/src/cli/terminal.ts +361 -0
- package/packages/core/src/cli/terminals.ts +995 -0
- package/packages/core/src/cli/testkit.ts +194 -0
- package/packages/core/src/cli/ticket.ts +484 -0
- package/packages/core/src/cli/upgrade-agent.ts +180 -0
- package/packages/core/src/cli/upgrade-commit.ts +468 -0
- package/packages/core/src/cli/upgrade-notice.ts +91 -0
- package/packages/core/src/cli/upgrade-progress.ts +110 -0
- package/packages/core/src/cli/upgrade.ts +888 -0
- package/packages/core/src/cli/wait.ts +587 -0
- package/packages/core/src/cli/watch.ts +932 -0
- package/packages/core/src/config/live.ts +255 -0
- package/packages/core/src/config/load.ts +98 -0
- package/packages/core/src/config/roles.ts +438 -0
- package/packages/core/src/config/schema.ts +774 -0
- package/packages/core/src/distribution.ts +24 -0
- package/packages/core/src/events/catalog.ts +201 -0
- package/packages/core/src/events/envelope.ts +59 -0
- package/packages/core/src/events/payloads.ts +618 -0
- package/packages/core/src/events/repository.ts +559 -0
- package/packages/core/src/events/sessions.ts +180 -0
- package/packages/core/src/harvest/schema.ts +147 -0
- package/packages/core/src/ids.ts +45 -0
- package/packages/core/src/integration/harness.ts +681 -0
- package/packages/core/src/kernel/auto-merge.ts +259 -0
- package/packages/core/src/kernel/converge.ts +117 -0
- package/packages/core/src/kernel/dispatch-settings.ts +60 -0
- package/packages/core/src/kernel/dispatch-status.ts +141 -0
- package/packages/core/src/kernel/engine.ts +1047 -0
- package/packages/core/src/kernel/harvest.ts +823 -0
- package/packages/core/src/kernel/origin.ts +69 -0
- package/packages/core/src/kernel/phases.ts +196 -0
- package/packages/core/src/kernel/plan-verify-selection.ts +105 -0
- package/packages/core/src/kernel/pr-attachments.ts +82 -0
- package/packages/core/src/kernel/reducer.ts +752 -0
- package/packages/core/src/kernel/stall.ts +155 -0
- package/packages/core/src/kernel/verify-applicability.ts +52 -0
- package/packages/core/src/markdown.ts +67 -0
- package/packages/core/src/ontology.ts +288 -0
- package/packages/core/src/open-map.ts +203 -0
- package/packages/core/src/operator/annotations.ts +187 -0
- package/packages/core/src/operator/client.ts +311 -0
- package/packages/core/src/operator/control.ts +117 -0
- package/packages/core/src/operator/index.ts +62 -0
- package/packages/core/src/operator/presentation.ts +22 -0
- package/packages/core/src/operator/protocol.ts +98 -0
- package/packages/core/src/operator/query.ts +201 -0
- package/packages/core/src/operator/registry.ts +1043 -0
- package/packages/core/src/operator/requests.ts +138 -0
- package/packages/core/src/operator/sandbox.ts +458 -0
- package/packages/core/src/operator/server.ts +616 -0
- package/packages/core/src/operator/sessions.ts +223 -0
- package/packages/core/src/operator/tickets.ts +177 -0
- package/packages/core/src/plugin-sdk/index.ts +174 -0
- package/packages/core/src/plugins/contract-entry.ts +74 -0
- package/packages/core/src/plugins/contract-env.ts +3 -0
- package/packages/core/src/plugins/load.ts +259 -0
- package/packages/core/src/plugins/manifest.ts +236 -0
- package/packages/core/src/plugins/registry.ts +274 -0
- package/packages/core/src/plugins/runtimes.ts +75 -0
- package/packages/core/src/ports/forge/contract.ts +258 -0
- package/packages/core/src/ports/forge/create.ts +74 -0
- package/packages/core/src/ports/forge/fake.ts +488 -0
- package/packages/core/src/ports/forge/github-pr-attachments.ts +351 -0
- package/packages/core/src/ports/forge/github-transport.ts +352 -0
- package/packages/core/src/ports/forge/github.ts +1162 -0
- package/packages/core/src/ports/forge/local-git.ts +568 -0
- package/packages/core/src/ports/runner/claude.ts +873 -0
- package/packages/core/src/ports/runner/cli-stream.ts +90 -0
- package/packages/core/src/ports/runner/codex.ts +873 -0
- package/packages/core/src/ports/runner/contract.ts +492 -0
- package/packages/core/src/ports/runner/fake.ts +213 -0
- package/packages/core/src/ports/runner/json-record.ts +2 -0
- package/packages/core/src/ports/runner/live-contract-fixture.ts +176 -0
- package/packages/core/src/ports/runner/one-shot.ts +29 -0
- package/packages/core/src/ports/runner/pi-bridge-core.ts +136 -0
- package/packages/core/src/ports/runner/pi-bridge.js +6 -0
- package/packages/core/src/ports/runner/pi-rpc.ts +454 -0
- package/packages/core/src/ports/runner/pi.ts +617 -0
- package/packages/core/src/ports/runner/production.ts +74 -0
- package/packages/core/src/ports/runner/provider-error.ts +121 -0
- package/packages/core/src/ports/runner/routing.ts +234 -0
- package/packages/core/src/ports/runner/runtime.ts +214 -0
- package/packages/core/src/ports/runner/session-env.ts +34 -0
- package/packages/core/src/ports/runner/stream-parts.ts +103 -0
- package/packages/core/src/ports/tickets/contract.ts +570 -0
- package/packages/core/src/ports/tickets/create.ts +135 -0
- package/packages/core/src/ports/tickets/fake.ts +215 -0
- package/packages/core/src/ports/tickets/file.ts +559 -0
- package/packages/core/src/ports/tickets/linear.ts +811 -0
- package/packages/core/src/ports/tickets/operations.ts +161 -0
- package/packages/core/src/ports/tickets/remote-index.ts +13 -0
- package/packages/core/src/ports/tickets/remote-protocol.ts +80 -0
- package/packages/core/src/ports/tickets/remote-server.ts +185 -0
- package/packages/core/src/ports/tickets/remote.ts +130 -0
- package/packages/core/src/ports/tickets/update.ts +51 -0
- package/packages/core/src/ports/types.ts +528 -0
- package/packages/core/src/ports/workspace/build-execution.ts +123 -0
- package/packages/core/src/ports/workspace/contract.ts +136 -0
- package/packages/core/src/ports/workspace/create.ts +132 -0
- package/packages/core/src/ports/workspace/distribution-archive.ts +317 -0
- package/packages/core/src/ports/workspace/fake.ts +432 -0
- package/packages/core/src/ports/workspace/git-worktree.ts +848 -0
- package/packages/core/src/ports/workspace/harvest-execution.ts +111 -0
- package/packages/core/src/ports/workspace/in-process-build-execution.ts +30 -0
- package/packages/core/src/ports/workspace/local-build-execution.ts +88 -0
- package/packages/core/src/ports/workspace/operator-sandbox.ts +142 -0
- package/packages/core/src/ports/workspace/phase-scratch.ts +156 -0
- package/packages/core/src/ports/workspace/vercel-sandbox.ts +2541 -0
- package/packages/core/src/processes/build-child-exit.ts +78 -0
- package/packages/core/src/processes/build-child.ts +119 -0
- package/packages/core/src/processes/build-execution-state.ts +95 -0
- package/packages/core/src/processes/build-parent-watch.ts +35 -0
- package/packages/core/src/processes/build-runner.ts +2614 -0
- package/packages/core/src/processes/dispatcher.ts +2825 -0
- package/packages/core/src/processes/execution-settlement.ts +171 -0
- package/packages/core/src/processes/harvest-child.ts +115 -0
- package/packages/core/src/processes/harvest-execution-state.ts +121 -0
- package/packages/core/src/processes/harvest-runner.ts +1656 -0
- package/packages/core/src/processes/harvest.ts +595 -0
- package/packages/core/src/processes/infrastructure-failure-budget.ts +146 -0
- package/packages/core/src/processes/phase-failure-budget.ts +52 -0
- package/packages/core/src/processes/process-group-reaper.ts +35 -0
- package/packages/core/src/processes/process-group.ts +65 -0
- package/packages/core/src/processes/publication-loss.ts +62 -0
- package/packages/core/src/processes/publication-state.ts +93 -0
- package/packages/core/src/processes/sandbox-state.ts +101 -0
- package/packages/core/src/registry.ts +24 -0
- package/packages/core/src/sessions/reducer.ts +185 -0
- package/packages/core/src/skills.ts +7 -0
- package/packages/core/src/spec-standard.ts +47 -0
- package/packages/core/src/store/adapter.ts +80 -0
- package/packages/core/src/store/build-scope.ts +272 -0
- package/packages/core/src/store/contract.ts +2581 -0
- package/packages/core/src/store/local/blobs.ts +38 -0
- package/packages/core/src/store/local/schema.ts +180 -0
- package/packages/core/src/store/local/store.ts +1713 -0
- package/packages/core/src/store/memory.ts +1138 -0
- package/packages/core/src/store/phase-session.ts +425 -0
- package/packages/core/src/store/remote/client.ts +780 -0
- package/packages/core/src/store/remote/index.ts +12 -0
- package/packages/core/src/store/remote/protocol.ts +348 -0
- package/packages/core/src/store/remote/server.ts +923 -0
- package/packages/core/src/store/remote/token.ts +137 -0
- package/packages/core/src/store/remote/version.ts +10 -0
- package/packages/core/src/store/retention.ts +91 -0
- package/packages/core/src/store/session-handle.ts +265 -0
- package/packages/core/src/store/streams/assemble.ts +79 -0
- package/packages/core/src/store/streams/lock.ts +42 -0
- package/packages/core/src/store/streams/session-writer.ts +306 -0
- package/packages/core/src/store/streams/types.ts +171 -0
- package/packages/core/src/store/streams/wait.ts +127 -0
- package/packages/core/src/store/subscribe.ts +105 -0
- package/packages/core/src/store/types.ts +466 -0
- package/packages/core/src/testing/fixed.ts +44 -0
- package/packages/core/src/testing/store-failures.ts +32 -0
- package/packages/core/src/zod-issues.ts +91 -0
- package/skills/code-review/SKILL.md +100 -0
- package/skills/finalize/SKILL.md +44 -0
- package/skills/guide/SKILL.md +1781 -0
- package/skills/guide/references/operator-api.md +164 -0
- package/skills/guide/references/plugin-authoring.md +354 -0
- package/skills/guide/references/remote-store-protocol.md +972 -0
- package/skills/guide/references/remote-ticket-protocol.md +79 -0
- package/skills/guide/references/setup.md +289 -0
- package/skills/guide/references/spec-standard.md +63 -0
- package/skills/guide/references/ticket-dependencies.md +59 -0
- package/skills/harvest/SKILL.md +66 -0
- package/skills/harvest-review/SKILL.md +63 -0
- package/skills/implement/SKILL.md +82 -0
- package/skills/plan/SKILL.md +123 -0
- package/skills/plan-review/SKILL.md +121 -0
- package/skills/reconcile/SKILL.md +65 -0
- package/skills/spec/SKILL.md +132 -0
- package/skills/tickets/SKILL.md +139 -0
- package/templates/autobuild.toml +36 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import type { AbEvent } from '../events/catalog'
|
|
2
|
+
import { DISPATCHER } from '../events/envelope'
|
|
3
|
+
import type { EventPayload } from '../events/payloads'
|
|
4
|
+
import type { IdSource } from '../ids'
|
|
5
|
+
import type { BuildStore } from '../store/types'
|
|
6
|
+
|
|
7
|
+
export type InfrastructureOperation = EventPayload<'infrastructure.failed'>['operation']
|
|
8
|
+
export type InfrastructureCause = EventPayload<'infrastructure.failed'>['cause']
|
|
9
|
+
|
|
10
|
+
export interface InfrastructureFailureInput {
|
|
11
|
+
slug: string
|
|
12
|
+
events: readonly AbEvent[]
|
|
13
|
+
maxAttempts: number
|
|
14
|
+
provider: string
|
|
15
|
+
workspaceRef: string
|
|
16
|
+
instance: string
|
|
17
|
+
environmentId?: string
|
|
18
|
+
sessionId?: string
|
|
19
|
+
operation: InfrastructureOperation
|
|
20
|
+
error: unknown
|
|
21
|
+
cleanupPending: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface InfrastructureFailureDependencies {
|
|
25
|
+
store: Pick<BuildStore, 'append' | 'getEvents'>
|
|
26
|
+
ids: IdSource
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Infrastructure retries are re-armed only by a confirmed execution boundary
|
|
30
|
+
* or a retry answer to this policy's own previously-raised exhaustion escalation. */
|
|
31
|
+
export function infrastructureFailureResetSeq(events: readonly AbEvent[]): number {
|
|
32
|
+
const infrastructureEscalations = new Set<string>()
|
|
33
|
+
let resetSeq = 0
|
|
34
|
+
for (const event of events) {
|
|
35
|
+
if (
|
|
36
|
+
event.type === 'escalation.raised' &&
|
|
37
|
+
event.payload.policyCause === 'infrastructure-failure-limit'
|
|
38
|
+
) {
|
|
39
|
+
infrastructureEscalations.add(event.payload.id)
|
|
40
|
+
} else if (
|
|
41
|
+
event.type === 'execution.ended' ||
|
|
42
|
+
(event.type === 'escalation.answered' &&
|
|
43
|
+
event.payload.resolution === 'retry' &&
|
|
44
|
+
infrastructureEscalations.has(event.payload.id))
|
|
45
|
+
) {
|
|
46
|
+
resetSeq = event.seq
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return resetSeq
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function normalizeInfrastructureError(error: unknown): string {
|
|
53
|
+
const seen = new Set<unknown>()
|
|
54
|
+
const collect = (value: unknown): string[] => {
|
|
55
|
+
if ((typeof value === 'object' && value !== null) || typeof value === 'function') {
|
|
56
|
+
if (seen.has(value)) return []
|
|
57
|
+
seen.add(value)
|
|
58
|
+
}
|
|
59
|
+
if (value instanceof AggregateError) {
|
|
60
|
+
return [value.message, ...value.errors.flatMap(collect), ...collect(value.cause)].filter(
|
|
61
|
+
(part) => part.trim().length > 0,
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
if (value instanceof Error) {
|
|
65
|
+
return [value.message, ...collect(value.cause)].filter((part) => part.trim().length > 0)
|
|
66
|
+
}
|
|
67
|
+
if (value === undefined) return []
|
|
68
|
+
const text = String(value).trim()
|
|
69
|
+
return text === '' ? [] : [text]
|
|
70
|
+
}
|
|
71
|
+
const messages = [...new Set(collect(error).map((part) => part.trim()))]
|
|
72
|
+
return messages.join('; ') || 'provider operation failed without an error message'
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** One deterministic provider-failure taxonomy shared by every dispatch path. */
|
|
76
|
+
export function classifyInfrastructureFailure(
|
|
77
|
+
message: string,
|
|
78
|
+
cleanupPending: boolean,
|
|
79
|
+
): InfrastructureCause {
|
|
80
|
+
const lower = message.toLowerCase()
|
|
81
|
+
if (/limit|quota|cpu|duration/.test(lower)) return 'provider-limit'
|
|
82
|
+
if (/timeout|abort/.test(lower)) return 'timeout'
|
|
83
|
+
if (/no longer exists|not found|missing/.test(lower)) return 'missing'
|
|
84
|
+
return cleanupPending ? 'unknown-outcome' : 'provider-error'
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Append one durable infrastructure failure and, when its epoch is exhausted,
|
|
88
|
+
* the epoch's single policy escalation. Returned events let snapshot-based
|
|
89
|
+
* callers immediately include these writes in the rest of their tick. */
|
|
90
|
+
export async function recordInfrastructureFailure(
|
|
91
|
+
deps: InfrastructureFailureDependencies,
|
|
92
|
+
input: InfrastructureFailureInput,
|
|
93
|
+
): Promise<AbEvent[]> {
|
|
94
|
+
const lastReset = infrastructureFailureResetSeq(input.events)
|
|
95
|
+
const attempt =
|
|
96
|
+
input.events.filter((event) => event.type === 'infrastructure.failed' && event.seq > lastReset)
|
|
97
|
+
.length + 1
|
|
98
|
+
const message = normalizeInfrastructureError(input.error)
|
|
99
|
+
const failure = await deps.store.append(input.slug, {
|
|
100
|
+
actor: DISPATCHER,
|
|
101
|
+
type: 'infrastructure.failed',
|
|
102
|
+
payload: {
|
|
103
|
+
provider: input.provider,
|
|
104
|
+
workspaceRef: input.workspaceRef,
|
|
105
|
+
instance: input.instance,
|
|
106
|
+
...(input.environmentId !== undefined ? { environmentId: input.environmentId } : {}),
|
|
107
|
+
...(input.sessionId !== undefined ? { sessionId: input.sessionId } : {}),
|
|
108
|
+
operation: input.operation,
|
|
109
|
+
cause: classifyInfrastructureFailure(message, input.cleanupPending),
|
|
110
|
+
attempt,
|
|
111
|
+
retryable: true,
|
|
112
|
+
cleanupPending: input.cleanupPending,
|
|
113
|
+
error: message,
|
|
114
|
+
},
|
|
115
|
+
})
|
|
116
|
+
const appended: AbEvent[] = [failure]
|
|
117
|
+
|
|
118
|
+
if (attempt < input.maxAttempts) return appended
|
|
119
|
+
|
|
120
|
+
const currentEvents = await deps.store.getEvents(input.slug)
|
|
121
|
+
if (
|
|
122
|
+
!currentEvents.some(
|
|
123
|
+
(event) =>
|
|
124
|
+
event.seq > lastReset &&
|
|
125
|
+
event.type === 'escalation.raised' &&
|
|
126
|
+
event.payload.policyCause === 'infrastructure-failure-limit',
|
|
127
|
+
)
|
|
128
|
+
) {
|
|
129
|
+
appended.push(
|
|
130
|
+
await deps.store.append(input.slug, {
|
|
131
|
+
actor: DISPATCHER,
|
|
132
|
+
type: 'escalation.raised',
|
|
133
|
+
payload: {
|
|
134
|
+
id: deps.ids('esc'),
|
|
135
|
+
phase: 'setup',
|
|
136
|
+
source: 'policy',
|
|
137
|
+
policyCause: 'infrastructure-failure-limit',
|
|
138
|
+
question: `maxInfrastructureAttempts (${input.maxAttempts}) exhausted during ${input.operation}; provider cleanup/recovery must succeed before retry: ${message}`,
|
|
139
|
+
refs: [input.workspaceRef],
|
|
140
|
+
},
|
|
141
|
+
}),
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return appended
|
|
146
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { EscalationSource, EscalationTarget, Phase, PolicyEscalationCause } from '../ontology'
|
|
2
|
+
|
|
3
|
+
export type PhaseFailureBudgetResetScope = 'none' | 'matching-round' | 'all-phase-rounds'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Closed policy semantics for answering a phase-failure budget escalation.
|
|
7
|
+
* Adding a policy cause must choose its effect here rather than inheriting one
|
|
8
|
+
* from the incidental presence or absence of `round`.
|
|
9
|
+
*/
|
|
10
|
+
export const POLICY_PHASE_FAILURE_RESET_SCOPES = {
|
|
11
|
+
'review-round-limit': 'matching-round',
|
|
12
|
+
'verify-failure-limit': 'all-phase-rounds',
|
|
13
|
+
'reconcile-no-progress': 'none',
|
|
14
|
+
'setup-failure-limit': 'none',
|
|
15
|
+
'infrastructure-failure-limit': 'none',
|
|
16
|
+
'phase-attempt-limit': 'matching-round',
|
|
17
|
+
'non-retryable-phase-failure': 'matching-round',
|
|
18
|
+
} as const satisfies Record<PolicyEscalationCause, PhaseFailureBudgetResetScope>
|
|
19
|
+
|
|
20
|
+
export interface PhaseFailureResetEscalation {
|
|
21
|
+
phase: EscalationTarget
|
|
22
|
+
round?: number
|
|
23
|
+
source: EscalationSource
|
|
24
|
+
policyCause?: PolicyEscalationCause
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Whether answering one raise re-arms the queried phase+round failure budget.
|
|
29
|
+
* Cause-less historical policy raises and non-policy raises retain the legacy
|
|
30
|
+
* shape rule so old logs replay without migration.
|
|
31
|
+
*/
|
|
32
|
+
export function resetsPhaseFailureBudget(
|
|
33
|
+
raise: PhaseFailureResetEscalation,
|
|
34
|
+
phase: Phase,
|
|
35
|
+
round: number,
|
|
36
|
+
): boolean {
|
|
37
|
+
if (raise.phase !== phase) return false
|
|
38
|
+
|
|
39
|
+
if (raise.source !== 'policy' || raise.policyCause === undefined) {
|
|
40
|
+
return raise.round === undefined || raise.round === round
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const scope = POLICY_PHASE_FAILURE_RESET_SCOPES[raise.policyCause]
|
|
44
|
+
switch (scope) {
|
|
45
|
+
case 'none':
|
|
46
|
+
return false
|
|
47
|
+
case 'matching-round':
|
|
48
|
+
return raise.round === round
|
|
49
|
+
case 'all-phase-rounds':
|
|
50
|
+
return true
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { distributionPath } from '../distribution'
|
|
2
|
+
import { DEFAULT_PROCESS_GROUP_STOP_TIMEOUT_MS } from './process-group'
|
|
3
|
+
|
|
4
|
+
export const PROCESS_GROUP_REAPER_OPTIONS_ENV = 'AB_PROCESS_GROUP_REAPER_OPTIONS'
|
|
5
|
+
|
|
6
|
+
export interface ProcessGroupReaperOptions {
|
|
7
|
+
groupId: number
|
|
8
|
+
stopTimeoutMs: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function processGroupReaperOptions(groupId: number): ProcessGroupReaperOptions {
|
|
12
|
+
return { groupId, stopTimeoutMs: DEFAULT_PROCESS_GROUP_STOP_TIMEOUT_MS }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Start an owner outside the dying build's session so group-wide SIGKILL
|
|
16
|
+
* cannot kill the process responsible for escalating to it. The synchronous
|
|
17
|
+
* return means the target leader can retain its process-group identity until
|
|
18
|
+
* the helper has been created. */
|
|
19
|
+
export function launchProcessGroupReaper(
|
|
20
|
+
options: ProcessGroupReaperOptions,
|
|
21
|
+
spawn: typeof Bun.spawn = Bun.spawn,
|
|
22
|
+
): number {
|
|
23
|
+
const child = spawn([process.execPath, distributionPath('bin', 'ab-process-group-reaper.ts')], {
|
|
24
|
+
env: {
|
|
25
|
+
...process.env,
|
|
26
|
+
[PROCESS_GROUP_REAPER_OPTIONS_ENV]: JSON.stringify(options),
|
|
27
|
+
},
|
|
28
|
+
stdin: 'ignore',
|
|
29
|
+
stdout: 'ignore',
|
|
30
|
+
stderr: 'ignore',
|
|
31
|
+
detached: true,
|
|
32
|
+
})
|
|
33
|
+
child.unref()
|
|
34
|
+
return child.pid
|
|
35
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
export const DEFAULT_PROCESS_GROUP_STOP_TIMEOUT_MS = 5_000
|
|
2
|
+
|
|
3
|
+
function validateGroupId(groupId: number): void {
|
|
4
|
+
if (!Number.isSafeInteger(groupId) || groupId <= 0) {
|
|
5
|
+
throw new Error(`invalid process group id ${JSON.stringify(groupId)}`)
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function isGone(error: unknown): boolean {
|
|
10
|
+
return error instanceof Error && 'code' in error && error.code === 'ESRCH'
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Probe a POSIX process group without ever falling back to its leader PID. */
|
|
14
|
+
export function processGroupAlive(groupId: number): boolean {
|
|
15
|
+
validateGroupId(groupId)
|
|
16
|
+
try {
|
|
17
|
+
process.kill(-groupId, 0)
|
|
18
|
+
return true
|
|
19
|
+
} catch (error) {
|
|
20
|
+
if (isGone(error)) return false
|
|
21
|
+
throw error
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Signal a complete POSIX process group. An already-gone group is success. */
|
|
26
|
+
export function signalProcessGroup(groupId: number, signal: NodeJS.Signals): boolean {
|
|
27
|
+
validateGroupId(groupId)
|
|
28
|
+
try {
|
|
29
|
+
process.kill(-groupId, signal)
|
|
30
|
+
return true
|
|
31
|
+
} catch (error) {
|
|
32
|
+
if (isGone(error)) return false
|
|
33
|
+
throw error
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function waitForGroupExit(groupId: number, deadline: number): Promise<boolean> {
|
|
38
|
+
while (processGroupAlive(groupId)) {
|
|
39
|
+
const remaining = deadline - Date.now()
|
|
40
|
+
if (remaining <= 0) return false
|
|
41
|
+
await Bun.sleep(Math.min(10, remaining))
|
|
42
|
+
}
|
|
43
|
+
return true
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Gracefully stop a complete local execution group, then force the same group
|
|
48
|
+
* after the bounded stop delay. The force path waits until no member remains;
|
|
49
|
+
* callers may therefore use resolution as the workspace-teardown boundary.
|
|
50
|
+
*/
|
|
51
|
+
export async function terminateProcessGroup(
|
|
52
|
+
groupId: number,
|
|
53
|
+
stopTimeoutMs = DEFAULT_PROCESS_GROUP_STOP_TIMEOUT_MS,
|
|
54
|
+
): Promise<void> {
|
|
55
|
+
validateGroupId(groupId)
|
|
56
|
+
if (!Number.isFinite(stopTimeoutMs) || stopTimeoutMs < 0) {
|
|
57
|
+
throw new Error(`invalid process-group stop timeout ${JSON.stringify(stopTimeoutMs)}`)
|
|
58
|
+
}
|
|
59
|
+
if (!signalProcessGroup(groupId, 'SIGTERM')) return
|
|
60
|
+
|
|
61
|
+
if (await waitForGroupExit(groupId, Date.now() + stopTimeoutMs)) return
|
|
62
|
+
if (!signalProcessGroup(groupId, 'SIGKILL')) return
|
|
63
|
+
|
|
64
|
+
while (processGroupAlive(groupId)) await Bun.sleep(10)
|
|
65
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Last-chance publication settlement at the workspace-release boundary
|
|
3
|
+
* (AUT-328). A pending `publication.requested` whose workspace is destroyed
|
|
4
|
+
* takes its only copy of un-pushed commits with it, so every destructive
|
|
5
|
+
* release path must first attempt trusted settlement and then, if the request
|
|
6
|
+
* still cannot be completed, durably record the loss BEFORE the release fact.
|
|
7
|
+
* A release must never proceed past an unrecorded pending request: if the
|
|
8
|
+
* loss record cannot be written, the append failure propagates and the caller
|
|
9
|
+
* aborts the release (the next tick retries it).
|
|
10
|
+
*/
|
|
11
|
+
import { DISPATCHER } from '../events/envelope'
|
|
12
|
+
import type { AbEvent } from '../events/catalog'
|
|
13
|
+
import {
|
|
14
|
+
latestUncompletedPublicationRequest,
|
|
15
|
+
publicationLostRecorded,
|
|
16
|
+
publicationRequestCompleted,
|
|
17
|
+
} from './publication-state'
|
|
18
|
+
import type { BuildStore } from '../store/types'
|
|
19
|
+
|
|
20
|
+
export interface PublicationLossDeps {
|
|
21
|
+
store: BuildStore
|
|
22
|
+
/** Trusted publication settlement (dispatcher-injected). A throw means the
|
|
23
|
+
* request is uncompleted — the guard continues to the loss record. */
|
|
24
|
+
settlePublication?: (slug: string) => Promise<void>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Settle or record the loss for the latest uncompleted publication request
|
|
29
|
+
* ahead of a workspace release. Returns refreshed events for the caller's
|
|
30
|
+
* subsequent reads. Idempotent: a completed or already-recorded request
|
|
31
|
+
* changes nothing, and repeated runs append at most one `publication.lost`.
|
|
32
|
+
*/
|
|
33
|
+
export async function settlePublicationBeforeRelease(
|
|
34
|
+
deps: PublicationLossDeps,
|
|
35
|
+
slug: string,
|
|
36
|
+
events: AbEvent[],
|
|
37
|
+
reason: string,
|
|
38
|
+
): Promise<AbEvent[]> {
|
|
39
|
+
const request = latestUncompletedPublicationRequest(events)
|
|
40
|
+
if (request === undefined) return events
|
|
41
|
+
try {
|
|
42
|
+
await deps.settlePublication?.(slug)
|
|
43
|
+
} catch {
|
|
44
|
+
// A throw means the request is still uncompleted (or settlement failed);
|
|
45
|
+
// either way the loss record below is what makes the release safe.
|
|
46
|
+
}
|
|
47
|
+
const refreshed = await deps.store.getEvents(slug)
|
|
48
|
+
if (publicationRequestCompleted(refreshed, request)) return refreshed
|
|
49
|
+
if (publicationLostRecorded(refreshed, request)) return refreshed
|
|
50
|
+
const appended = await deps.store.append(slug, {
|
|
51
|
+
actor: DISPATCHER,
|
|
52
|
+
type: 'publication.lost',
|
|
53
|
+
payload: {
|
|
54
|
+
request: request.seq,
|
|
55
|
+
operation: request.payload.operation,
|
|
56
|
+
branch: request.payload.branch,
|
|
57
|
+
sha: request.payload.sha,
|
|
58
|
+
reason,
|
|
59
|
+
},
|
|
60
|
+
})
|
|
61
|
+
return [...refreshed, appended]
|
|
62
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { AbEvent } from '../events/catalog'
|
|
2
|
+
|
|
3
|
+
export type PublicationRequest = Extract<AbEvent, { type: 'publication.requested' }>
|
|
4
|
+
|
|
5
|
+
export function publicationRequestCompleted(
|
|
6
|
+
events: readonly AbEvent[],
|
|
7
|
+
request: PublicationRequest,
|
|
8
|
+
): boolean {
|
|
9
|
+
return events.some((event) => {
|
|
10
|
+
if (event.seq <= request.seq) return false
|
|
11
|
+
if (request.payload.operation === 'implement')
|
|
12
|
+
return (
|
|
13
|
+
event.type === 'implement.completed' &&
|
|
14
|
+
event.payload.round === request.payload.round &&
|
|
15
|
+
event.payload.commits.base === request.payload.base &&
|
|
16
|
+
event.payload.commits.head === request.payload.sha
|
|
17
|
+
)
|
|
18
|
+
if (request.payload.operation === 'reconcile')
|
|
19
|
+
return (
|
|
20
|
+
event.type === 'reconcile.completed' && event.payload.mergeCommit === request.payload.sha
|
|
21
|
+
)
|
|
22
|
+
if (request.payload.operation === 'finalize')
|
|
23
|
+
return event.type === 'finalize.completed' && event.payload.pr.headSha === request.payload.sha
|
|
24
|
+
return (
|
|
25
|
+
event.type === 'finalize.step-completed' &&
|
|
26
|
+
event.payload.step === request.payload.step &&
|
|
27
|
+
(!event.payload.ok || event.payload.headSha === request.payload.sha)
|
|
28
|
+
)
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A request is no longer publishable after its workspace is released. The
|
|
33
|
+
* commit may have existed only in that disposable environment, so replacement
|
|
34
|
+
* runners must rerun the phase rather than trying to settle the stale SHA. */
|
|
35
|
+
export function publicationRequestSettled(
|
|
36
|
+
events: readonly AbEvent[],
|
|
37
|
+
request: PublicationRequest,
|
|
38
|
+
): boolean {
|
|
39
|
+
let workspaceRef: string | undefined
|
|
40
|
+
for (const event of events) {
|
|
41
|
+
if (event.seq > request.seq) break
|
|
42
|
+
if (event.type === 'workspace.provisioned') workspaceRef = event.payload.ref
|
|
43
|
+
else if (event.type === 'workspace.released') workspaceRef = undefined
|
|
44
|
+
}
|
|
45
|
+
return (
|
|
46
|
+
publicationRequestCompleted(events, request) ||
|
|
47
|
+
events.some(
|
|
48
|
+
(event) =>
|
|
49
|
+
event.seq > request.seq &&
|
|
50
|
+
event.type === 'workspace.released' &&
|
|
51
|
+
(!('ref' in event.payload) ||
|
|
52
|
+
workspaceRef === undefined ||
|
|
53
|
+
event.payload.ref === workspaceRef),
|
|
54
|
+
)
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The latest `publication.requested` with no settling completion fact — the
|
|
59
|
+
* single settlement target for every caller that would previously re-derive it
|
|
60
|
+
* with a private `findLast`. */
|
|
61
|
+
export function latestUncompletedPublicationRequest(
|
|
62
|
+
events: readonly AbEvent[],
|
|
63
|
+
): PublicationRequest | undefined {
|
|
64
|
+
const request = events.findLast(
|
|
65
|
+
(event) =>
|
|
66
|
+
event.type === 'publication.requested' && !publicationRequestCompleted(events, event),
|
|
67
|
+
)
|
|
68
|
+
return request?.type === 'publication.requested' ? request : undefined
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Whether the durable loss record (AUT-328) has already been appended for
|
|
72
|
+
* `request`. The release guard and the settlement backstop both consult this
|
|
73
|
+
* so repeated ticks append at most one `publication.lost` per request. */
|
|
74
|
+
export function publicationLostRecorded(
|
|
75
|
+
events: readonly AbEvent[],
|
|
76
|
+
request: PublicationRequest,
|
|
77
|
+
): boolean {
|
|
78
|
+
return events.some(
|
|
79
|
+
(event) => event.type === 'publication.lost' && event.payload.request === request.seq,
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function abandonedPublicationPending(events: readonly AbEvent[]): boolean {
|
|
84
|
+
const request = latestUncompletedPublicationRequest(events)
|
|
85
|
+
return request !== undefined && publicationRequestSettled(events, request)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function publicationPending(events: readonly AbEvent[]): boolean {
|
|
89
|
+
return events.some(
|
|
90
|
+
(request) =>
|
|
91
|
+
request.type === 'publication.requested' && !publicationRequestSettled(events, request),
|
|
92
|
+
)
|
|
93
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable operator-sandbox state (AUT-340): the repository-journal projection
|
|
3
|
+
* the sandbox service, the dispatcher's idle-settlement stage, and the
|
|
4
|
+
* repository status projection read instead of process memory. The lifecycle
|
|
5
|
+
* facts (`orchestrator.sandbox.provisioned`/`resumed`/`activity`/`stopped`/
|
|
6
|
+
* `released`/`reset`) bracket one environment per operator × repository.
|
|
7
|
+
* Every helper here is a pure function over the journal, mirroring the
|
|
8
|
+
* harvest execution settlement, so any dispatcher invocation can settle an
|
|
9
|
+
* orphaned environment.
|
|
10
|
+
*/
|
|
11
|
+
import type { RepositoryEvent } from '../events/repository'
|
|
12
|
+
|
|
13
|
+
export type SandboxEnvironmentState = 'live' | 'stopped' | 'released'
|
|
14
|
+
|
|
15
|
+
export interface SandboxEnvironmentSnapshot {
|
|
16
|
+
operator: string
|
|
17
|
+
environmentId: string
|
|
18
|
+
provider: string
|
|
19
|
+
state: SandboxEnvironmentState
|
|
20
|
+
/** Journal timestamp of the latest lifecycle/activity fact for this
|
|
21
|
+
* environment — the evidence the idle settlement compares against
|
|
22
|
+
* `[orchestrator].sandbox.idleMinutes`. */
|
|
23
|
+
lastEvidenceTs: string
|
|
24
|
+
sessionId?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type MutableSnapshot = {
|
|
28
|
+
operator: string
|
|
29
|
+
environmentId: string
|
|
30
|
+
provider: string
|
|
31
|
+
state: SandboxEnvironmentState
|
|
32
|
+
lastEvidenceTs: string
|
|
33
|
+
sessionId?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Per-environment sandbox state, keyed deterministically by
|
|
37
|
+
* `environmentId` (one environment per operator × repository, and the id is
|
|
38
|
+
* derived from both). Latest fact wins; `released` environments stay in the
|
|
39
|
+
* result with their closing evidence so callers can distinguish them. */
|
|
40
|
+
export function sandboxStates(events: readonly RepositoryEvent[]): SandboxEnvironmentSnapshot[] {
|
|
41
|
+
const byEnvironment = new Map<string, MutableSnapshot>()
|
|
42
|
+
for (const event of events) {
|
|
43
|
+
const ts = event.ts
|
|
44
|
+
if (event.type === 'orchestrator.sandbox.provisioned') {
|
|
45
|
+
byEnvironment.set(event.payload.environmentId, {
|
|
46
|
+
operator: event.payload.operator,
|
|
47
|
+
environmentId: event.payload.environmentId,
|
|
48
|
+
provider: event.payload.provider,
|
|
49
|
+
state: 'live',
|
|
50
|
+
lastEvidenceTs: ts,
|
|
51
|
+
...(event.payload.sessionId !== undefined ? { sessionId: event.payload.sessionId } : {}),
|
|
52
|
+
})
|
|
53
|
+
} else if (event.type === 'orchestrator.sandbox.resumed') {
|
|
54
|
+
const previous = byEnvironment.get(event.payload.environmentId)
|
|
55
|
+
byEnvironment.set(event.payload.environmentId, {
|
|
56
|
+
operator: event.payload.operator,
|
|
57
|
+
environmentId: event.payload.environmentId,
|
|
58
|
+
provider: event.payload.provider,
|
|
59
|
+
state: 'live',
|
|
60
|
+
lastEvidenceTs: ts,
|
|
61
|
+
...(event.payload.sessionId !== undefined
|
|
62
|
+
? { sessionId: event.payload.sessionId }
|
|
63
|
+
: previous?.sessionId !== undefined
|
|
64
|
+
? { sessionId: previous.sessionId }
|
|
65
|
+
: {}),
|
|
66
|
+
})
|
|
67
|
+
} else if (event.type === 'orchestrator.sandbox.activity') {
|
|
68
|
+
const current = byEnvironment.get(event.payload.environmentId)
|
|
69
|
+
if (current !== undefined && current.lastEvidenceTs <= ts) {
|
|
70
|
+
current.lastEvidenceTs = ts
|
|
71
|
+
}
|
|
72
|
+
} else if (event.type === 'orchestrator.sandbox.stopped') {
|
|
73
|
+
const current = byEnvironment.get(event.payload.environmentId)
|
|
74
|
+
if (current !== undefined && current.lastEvidenceTs <= ts) {
|
|
75
|
+
current.state = 'stopped'
|
|
76
|
+
current.lastEvidenceTs = ts
|
|
77
|
+
}
|
|
78
|
+
} else if (event.type === 'orchestrator.sandbox.reset') {
|
|
79
|
+
// Destructive marker only: the following released/provisioned facts
|
|
80
|
+
// carry the evidence. Refresh the evidence timestamp so a reset alone
|
|
81
|
+
// counts as recent activity for the environment it names.
|
|
82
|
+
const current = byEnvironment.get(event.payload.environmentId)
|
|
83
|
+
if (current !== undefined && current.lastEvidenceTs <= ts) {
|
|
84
|
+
current.lastEvidenceTs = ts
|
|
85
|
+
}
|
|
86
|
+
} else if (event.type === 'orchestrator.sandbox.released') {
|
|
87
|
+
// The released payload carries no provider; keep the one the
|
|
88
|
+
// environment's earlier facts recorded, when one exists.
|
|
89
|
+
const existing = byEnvironment.get(event.payload.environmentId)
|
|
90
|
+
byEnvironment.set(event.payload.environmentId, {
|
|
91
|
+
operator: event.payload.operator,
|
|
92
|
+
environmentId: event.payload.environmentId,
|
|
93
|
+
provider: existing?.provider ?? '',
|
|
94
|
+
state: 'released',
|
|
95
|
+
lastEvidenceTs: ts,
|
|
96
|
+
...(existing?.sessionId !== undefined ? { sessionId: existing.sessionId } : {}),
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return [...byEnvironment.values()]
|
|
101
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* npm registry addressing shared by self-update (version lookup) and the
|
|
3
|
+
* guest distribution source (tarball download). Pure URL arithmetic; the
|
|
4
|
+
* callers own their transport seams.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_NPM_REGISTRY = 'https://registry.npmjs.org'
|
|
8
|
+
|
|
9
|
+
/** The registry to read: the operator's configured `NPM_CONFIG_REGISTRY`
|
|
10
|
+
* when set, else the public registry, without a trailing slash. */
|
|
11
|
+
export function registryBaseUrl(
|
|
12
|
+
env: Readonly<Record<string, string | undefined>> | undefined,
|
|
13
|
+
): string {
|
|
14
|
+
const configured = env?.NPM_CONFIG_REGISTRY?.trim()
|
|
15
|
+
return (
|
|
16
|
+
configured !== undefined && configured !== '' ? configured : DEFAULT_NPM_REGISTRY
|
|
17
|
+
).replace(/\/+$/, '')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** `<registry>/<encoded name>/<version|latest>` — the registry's version
|
|
21
|
+
* document endpoint, with the scope separator encoded as npm clients do. */
|
|
22
|
+
export function registryVersionUrl(base: string, packageName: string, version?: string): string {
|
|
23
|
+
return `${base}/${packageName.replace('/', '%2f')}/${version ?? 'latest'}`
|
|
24
|
+
}
|