@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,138 @@
|
|
|
1
|
+
import type { AbEvent, EventEnvelope } from '../events/catalog'
|
|
2
|
+
import { KERNEL } from '../events/envelope'
|
|
3
|
+
import type { IdSource } from '../ids'
|
|
4
|
+
import {
|
|
5
|
+
currentPrAttachments,
|
|
6
|
+
frozenPrImageHost,
|
|
7
|
+
hostedPrAttachments,
|
|
8
|
+
} from '../kernel/pr-attachments'
|
|
9
|
+
import { hostedPrAttachmentAssetSchema, type HostedPrAttachmentAsset } from '../ontology'
|
|
10
|
+
import type { Forge } from '../ports/types'
|
|
11
|
+
import type { BuildStore } from '../store/types'
|
|
12
|
+
import type { CliEnv } from './env'
|
|
13
|
+
|
|
14
|
+
export interface PrAttachmentDeps {
|
|
15
|
+
store: BuildStore
|
|
16
|
+
env: CliEnv
|
|
17
|
+
workspacePath: string
|
|
18
|
+
forge: Forge
|
|
19
|
+
ids: IdSource
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function errorMessage(error: unknown): string {
|
|
23
|
+
return error instanceof Error ? error.message : String(error)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function recordHostingFailure(
|
|
27
|
+
deps: PrAttachmentDeps,
|
|
28
|
+
events: AbEvent[],
|
|
29
|
+
designation: ReturnType<typeof currentPrAttachments>[number],
|
|
30
|
+
error: unknown,
|
|
31
|
+
extraRefs: string[] = [],
|
|
32
|
+
): Promise<void> {
|
|
33
|
+
const { artifact } = designation.payload
|
|
34
|
+
try {
|
|
35
|
+
const observed = await deps.store.append(deps.env.build, {
|
|
36
|
+
actor: KERNEL,
|
|
37
|
+
type: 'observation.recorded',
|
|
38
|
+
payload: {
|
|
39
|
+
id: deps.ids('obs'),
|
|
40
|
+
kind: 'followup',
|
|
41
|
+
summary:
|
|
42
|
+
`PR attachment hosting failed for ${designation.payload.filename} ` +
|
|
43
|
+
`(${artifact.kind}@${artifact.rev}): ${errorMessage(error)}`,
|
|
44
|
+
refs: [`${artifact.kind}@${artifact.rev}`, ...extraRefs],
|
|
45
|
+
},
|
|
46
|
+
})
|
|
47
|
+
events.push(observed)
|
|
48
|
+
} catch {
|
|
49
|
+
// Hosting is a best-effort review affordance. A secondary failure to
|
|
50
|
+
// record its follow-up must not hide the complete text projection or turn
|
|
51
|
+
// a verification/finalize result red.
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Host every newly designated image independently. Text projection never
|
|
57
|
+
* depends on this operation; provider failures become follow-up observations.
|
|
58
|
+
* A successful external upload's durable event append is intentionally not
|
|
59
|
+
* swallowed, preserving deterministic adoption across the upload/write crash
|
|
60
|
+
* window.
|
|
61
|
+
*/
|
|
62
|
+
export async function preparePrAttachments(
|
|
63
|
+
deps: PrAttachmentDeps,
|
|
64
|
+
events: AbEvent[],
|
|
65
|
+
prUrl: string,
|
|
66
|
+
): Promise<void> {
|
|
67
|
+
const target = frozenPrImageHost(events)
|
|
68
|
+
if (target === undefined) return
|
|
69
|
+
|
|
70
|
+
const designations = currentPrAttachments(events)
|
|
71
|
+
const hosted = hostedPrAttachments(events, designations)
|
|
72
|
+
for (const designation of designations) {
|
|
73
|
+
if (!designation.payload.mediaType.startsWith('image/')) continue
|
|
74
|
+
if (hosted.has(designation.seq)) continue
|
|
75
|
+
|
|
76
|
+
let asset: HostedPrAttachmentAsset
|
|
77
|
+
try {
|
|
78
|
+
const capability = deps.forge.prAttachments
|
|
79
|
+
if (capability === undefined) {
|
|
80
|
+
throw new Error(`forge ${deps.forge.name} does not support PR attachment hosting`)
|
|
81
|
+
}
|
|
82
|
+
const ref = designation.payload.artifact
|
|
83
|
+
const artifact = await deps.store.getArtifact(deps.env.build, ref.kind, ref.rev)
|
|
84
|
+
if (artifact === null) {
|
|
85
|
+
throw new Error(`designated artifact ${ref.kind}@${ref.rev} is missing`)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
asset = hostedPrAttachmentAssetSchema.parse(
|
|
89
|
+
await capability.upload({
|
|
90
|
+
workspacePath: deps.workspacePath,
|
|
91
|
+
target,
|
|
92
|
+
prUrl,
|
|
93
|
+
attachment: designation.payload,
|
|
94
|
+
content: artifact.content,
|
|
95
|
+
sha256: artifact.meta.blobRef,
|
|
96
|
+
}),
|
|
97
|
+
)
|
|
98
|
+
if (
|
|
99
|
+
asset.provider !== target.provider ||
|
|
100
|
+
asset.repository !== target.repository ||
|
|
101
|
+
asset.releaseId !== target.releaseId
|
|
102
|
+
) {
|
|
103
|
+
throw new Error('PR attachment host returned a deletion handle for a different target')
|
|
104
|
+
}
|
|
105
|
+
} catch (error) {
|
|
106
|
+
await recordHostingFailure(deps, events, designation, error)
|
|
107
|
+
continue
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const assetRef =
|
|
111
|
+
`${asset.provider}:${asset.repository}:release/${asset.releaseId}:` + `asset/${asset.assetId}`
|
|
112
|
+
let event: EventEnvelope<'pr-attachment.hosted'>
|
|
113
|
+
try {
|
|
114
|
+
event = await deps.store.append(deps.env.build, {
|
|
115
|
+
actor: KERNEL,
|
|
116
|
+
type: 'pr-attachment.hosted',
|
|
117
|
+
payload: { designationSeq: designation.seq, asset },
|
|
118
|
+
})
|
|
119
|
+
} catch (error) {
|
|
120
|
+
// A late attachment has no phase retry after its external upload. Leave
|
|
121
|
+
// an exact cleanup pointer before preserving finalize's throw-and-adopt
|
|
122
|
+
// behavior for this upload/write crash window.
|
|
123
|
+
await recordHostingFailure(
|
|
124
|
+
deps,
|
|
125
|
+
events,
|
|
126
|
+
designation,
|
|
127
|
+
new Error(
|
|
128
|
+
`public copy ${assetRef} was uploaded, but its durable hosted fact ` +
|
|
129
|
+
`could not be recorded: ${errorMessage(error)}`,
|
|
130
|
+
),
|
|
131
|
+
[assetRef],
|
|
132
|
+
)
|
|
133
|
+
throw error
|
|
134
|
+
}
|
|
135
|
+
events.push(event)
|
|
136
|
+
hosted.set(designation.seq, event)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { AbEvent } from '../events/catalog'
|
|
2
|
+
import {
|
|
3
|
+
currentPrAttachments,
|
|
4
|
+
frozenPrImageHost,
|
|
5
|
+
hostedPrAttachments,
|
|
6
|
+
} from '../kernel/pr-attachments'
|
|
7
|
+
import { normalizeVerifyCompletion } from '../events/payloads'
|
|
8
|
+
import type { CliEnv } from './env'
|
|
9
|
+
|
|
10
|
+
/** Replace controls that could alter a forge-rendered comment. */
|
|
11
|
+
export function printableCommentText(value: string): string {
|
|
12
|
+
let out = ''
|
|
13
|
+
for (const character of value) {
|
|
14
|
+
const code = character.codePointAt(0)!
|
|
15
|
+
if (character === '\n') out += character
|
|
16
|
+
else if (code < 0x20 || code === 0x7f) out += `\\u{${code.toString(16)}}`
|
|
17
|
+
else out += character
|
|
18
|
+
}
|
|
19
|
+
return out
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function html(value: string): string {
|
|
23
|
+
return printableCommentText(value)
|
|
24
|
+
.replaceAll('&', '&')
|
|
25
|
+
.replaceAll('<', '<')
|
|
26
|
+
.replaceAll('>', '>')
|
|
27
|
+
.replaceAll('"', '"')
|
|
28
|
+
.replaceAll("'", ''')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function shellQuote(value: string): string {
|
|
32
|
+
return `'${value.replaceAll("'", `'"'"'`)}'`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function renderPrAttachmentSection(env: CliEnv, events: readonly AbEvent[]): string[] {
|
|
36
|
+
const designations = currentPrAttachments(events)
|
|
37
|
+
if (designations.length === 0) return []
|
|
38
|
+
|
|
39
|
+
const target = frozenPrImageHost(events)
|
|
40
|
+
const hosted = hostedPrAttachments(events, designations)
|
|
41
|
+
const lines = [
|
|
42
|
+
'',
|
|
43
|
+
'### PR attachments',
|
|
44
|
+
'',
|
|
45
|
+
'Download each exact BuildStore revision with the pinned command below.',
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
for (const designation of designations) {
|
|
49
|
+
const { artifact, filename, mediaType } = designation.payload
|
|
50
|
+
const ref = `${artifact.kind}@${artifact.rev}`
|
|
51
|
+
// Command-scoped parsing rejects flag-shaped values; retain the exact
|
|
52
|
+
// basename while making a leading-dash filename unambiguously a path.
|
|
53
|
+
const outputPath = filename.startsWith('-') ? `./${filename}` : filename
|
|
54
|
+
const command =
|
|
55
|
+
`ab artifact download ${shellQuote(env.build)} ${shellQuote(ref)} ` +
|
|
56
|
+
`--output ${shellQuote(outputPath)} --store ${shellQuote(env.store)}`
|
|
57
|
+
const hostedEvent = hosted.get(designation.seq)
|
|
58
|
+
const imageUrl =
|
|
59
|
+
mediaType.startsWith('image/') &&
|
|
60
|
+
target !== undefined &&
|
|
61
|
+
hostedEvent?.payload.asset.provider === target.provider &&
|
|
62
|
+
hostedEvent.payload.asset.repository === target.repository &&
|
|
63
|
+
hostedEvent.payload.asset.releaseId === target.releaseId
|
|
64
|
+
? hostedEvent.payload.asset.url
|
|
65
|
+
: undefined
|
|
66
|
+
|
|
67
|
+
lines.push(
|
|
68
|
+
'',
|
|
69
|
+
`#### <code>${html(filename)}</code>`,
|
|
70
|
+
'',
|
|
71
|
+
...(imageUrl === undefined
|
|
72
|
+
? []
|
|
73
|
+
: [`<img src="${html(imageUrl)}" alt="PR attachment ${html(filename)}">`, '']),
|
|
74
|
+
`- artifact: <code>${html(ref)}</code>`,
|
|
75
|
+
`- media type: <code>${html(mediaType)}</code>`,
|
|
76
|
+
`<pre><code>${html(command)}</code></pre>`,
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
return lines
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The complete PR summary projection, rendered only from durable facts. */
|
|
83
|
+
export function renderPrSummary(env: CliEnv, events: readonly AbEvent[]): string {
|
|
84
|
+
const verdicts: string[] = []
|
|
85
|
+
const verifies: string[] = []
|
|
86
|
+
for (const event of events) {
|
|
87
|
+
if (event.type === 'plan-review.verdict' || event.type === 'code-review.verdict') {
|
|
88
|
+
const phase = event.type === 'plan-review.verdict' ? 'plan-review' : 'code-review'
|
|
89
|
+
const count = event.payload.findings.length
|
|
90
|
+
const detail =
|
|
91
|
+
event.payload.verdict === 'revise' ? ` (${count} finding${count === 1 ? '' : 's'})` : ''
|
|
92
|
+
verdicts.push(`- ${phase} r${event.payload.round}: ${event.payload.verdict}${detail}`)
|
|
93
|
+
}
|
|
94
|
+
if (event.type === 'verify.completed') {
|
|
95
|
+
const result = normalizeVerifyCompletion(event.payload)
|
|
96
|
+
const detail =
|
|
97
|
+
result.outcome === 'skipped'
|
|
98
|
+
? ` — ${html(result.reason ?? '')}`
|
|
99
|
+
: result.report !== undefined
|
|
100
|
+
? ` — <code>${html(`${result.report.kind}@${result.report.rev}`)}</code>`
|
|
101
|
+
: ''
|
|
102
|
+
verifies.push(
|
|
103
|
+
`- <code>${html(result.step)}</code> (attempt ${result.attempt}): ${result.outcome}${detail}`,
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return [
|
|
109
|
+
`## Autobuild: ${html(env.build)}`,
|
|
110
|
+
'',
|
|
111
|
+
'### Verdict history',
|
|
112
|
+
...(verdicts.length > 0 ? verdicts : ['- (none)']),
|
|
113
|
+
'',
|
|
114
|
+
'### Verify',
|
|
115
|
+
...(verifies.length > 0 ? verifies : ['- (none)']),
|
|
116
|
+
...renderPrAttachmentSection(env, events),
|
|
117
|
+
'',
|
|
118
|
+
'### Store',
|
|
119
|
+
`- store: <code>${html(env.store)}</code>`,
|
|
120
|
+
`- build: <code>${html(env.build)}</code>`,
|
|
121
|
+
'',
|
|
122
|
+
'The full audit trail is queryable in the build store (§7.5).',
|
|
123
|
+
].join('\n')
|
|
124
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import type { IdSource } from '../ids'
|
|
2
|
+
import { DISPATCHER, KERNEL } from '../events/envelope'
|
|
3
|
+
import type { Forge, WorkspacePublication } from '../ports/types'
|
|
4
|
+
import type { Exec } from '../ports/workspace/git-worktree'
|
|
5
|
+
import { openExecution } from '../processes/execution-settlement'
|
|
6
|
+
import {
|
|
7
|
+
latestUncompletedPublicationRequest,
|
|
8
|
+
publicationLostRecorded,
|
|
9
|
+
publicationRequestCompleted,
|
|
10
|
+
publicationRequestSettled,
|
|
11
|
+
} from '../processes/publication-state'
|
|
12
|
+
import { systemClock, type BuildStore, type Clock } from '../store/types'
|
|
13
|
+
import { completeFinalizePr } from './terminals'
|
|
14
|
+
|
|
15
|
+
export interface PublicationSettlementDeps {
|
|
16
|
+
store: BuildStore
|
|
17
|
+
storeRef: string
|
|
18
|
+
publication?: WorkspacePublication
|
|
19
|
+
forge: Forge
|
|
20
|
+
workspacePath: string
|
|
21
|
+
exec: Exec
|
|
22
|
+
ids: IdSource
|
|
23
|
+
runId: string
|
|
24
|
+
/** Time source for the durable lease-liveness guard; defaults to the
|
|
25
|
+
* system clock. The same injected clock the dispatcher uses for lease
|
|
26
|
+
* liveness. */
|
|
27
|
+
clock?: Clock
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Settle one durable request after the caller has observed environment
|
|
31
|
+
* completion and released its exact execution lease. External effects precede
|
|
32
|
+
* completion facts, making every crash gap safely retryable.
|
|
33
|
+
*
|
|
34
|
+
* Durable guard: before the publish path, the guest execution must be provably
|
|
35
|
+
* over — either the log has no open execution, or the build's lease is
|
|
36
|
+
* absent/expired. Publication is never attempted against a live guest and is
|
|
37
|
+
* never performed twice for one request; no in-memory set is the sole guard. */
|
|
38
|
+
export async function settlePendingPublication(
|
|
39
|
+
deps: PublicationSettlementDeps,
|
|
40
|
+
slug: string,
|
|
41
|
+
): Promise<void> {
|
|
42
|
+
const publication = deps.publication
|
|
43
|
+
if (publication === undefined) return
|
|
44
|
+
let events = await deps.store.getEvents(slug)
|
|
45
|
+
const request = latestUncompletedPublicationRequest(events)
|
|
46
|
+
if (request === undefined) return
|
|
47
|
+
|
|
48
|
+
const abandoned = publicationRequestSettled(events, request)
|
|
49
|
+
// Consulted for every uncompleted request, not only abandoned ones: a guest
|
|
50
|
+
// that pushed its branch before dying is owed its completion fact, not a
|
|
51
|
+
// re-push or a loss record.
|
|
52
|
+
const alreadyPublished =
|
|
53
|
+
publication.isPublished !== undefined
|
|
54
|
+
? await publication.isPublished({ sha: request.payload.sha, branch: request.payload.branch })
|
|
55
|
+
: false
|
|
56
|
+
if (abandoned && !alreadyPublished) {
|
|
57
|
+
// Last-chance backstop (AUT-328): the workspace is gone with this request
|
|
58
|
+
// uncompleted. Record the loss durably (deduped per request) instead of
|
|
59
|
+
// returning silently — the exact drop that lost the validate-hosted-
|
|
60
|
+
// dispatcher generation-1 work. The finalize-step publish-failure path
|
|
61
|
+
// below is unchanged: it already settles loudly via its failed outcome.
|
|
62
|
+
if (!publicationLostRecorded(events, request)) {
|
|
63
|
+
await deps.store.append(slug, {
|
|
64
|
+
actor: DISPATCHER,
|
|
65
|
+
type: 'publication.lost',
|
|
66
|
+
payload: {
|
|
67
|
+
request: request.seq,
|
|
68
|
+
operation: request.payload.operation,
|
|
69
|
+
branch: request.payload.branch,
|
|
70
|
+
sha: request.payload.sha,
|
|
71
|
+
reason: 'workspace released with the publication uncompleted',
|
|
72
|
+
},
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
if (!alreadyPublished) {
|
|
80
|
+
// Never publish against a live guest: require durable proof the guest
|
|
81
|
+
// execution ended (a recorded end or an expired/absent lease). The
|
|
82
|
+
// adapter's process-local active/uncertain sets remain a second belt.
|
|
83
|
+
const record = await deps.store.getBuild(slug)
|
|
84
|
+
const clock = deps.clock ?? systemClock
|
|
85
|
+
const leaseLive =
|
|
86
|
+
record?.lease !== undefined &&
|
|
87
|
+
new Date(record.lease.expiresAt).getTime() > clock().getTime()
|
|
88
|
+
if (openExecution(events) !== null && leaseLive) return
|
|
89
|
+
let ref: string | undefined
|
|
90
|
+
for (const event of events) {
|
|
91
|
+
if (event.type === 'workspace.provisioned') ref = event.payload.ref
|
|
92
|
+
else if (event.type === 'workspace.released') ref = undefined
|
|
93
|
+
}
|
|
94
|
+
if (ref === undefined)
|
|
95
|
+
throw new Error(`build ${slug} has a publication request but no open workspace`)
|
|
96
|
+
await publication.publish({ ref, sha: request.payload.sha, branch: request.payload.branch })
|
|
97
|
+
}
|
|
98
|
+
} catch (error) {
|
|
99
|
+
if (request.payload.operation !== 'finalize-step') throw error
|
|
100
|
+
const detail = error instanceof Error ? error.message : String(error)
|
|
101
|
+
const note = `finalize publication failed: ${detail}`
|
|
102
|
+
await deps.store.append(slug, {
|
|
103
|
+
actor: KERNEL,
|
|
104
|
+
type: 'finalize.step-completed',
|
|
105
|
+
payload: { step: request.payload.step, ok: false, note },
|
|
106
|
+
})
|
|
107
|
+
await deps.store.append(slug, {
|
|
108
|
+
actor: KERNEL,
|
|
109
|
+
type: 'observation.recorded',
|
|
110
|
+
payload: {
|
|
111
|
+
id: deps.ids('o'),
|
|
112
|
+
kind: 'followup',
|
|
113
|
+
summary: `finalize step "${request.payload.step}" failed — needs manual follow-up: ${note}`,
|
|
114
|
+
},
|
|
115
|
+
})
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
events = await deps.store.getEvents(slug)
|
|
119
|
+
if (publicationRequestCompleted(events, request)) return
|
|
120
|
+
|
|
121
|
+
if (request.payload.operation === 'implement') {
|
|
122
|
+
await deps.store.append(slug, {
|
|
123
|
+
actor: DISPATCHER,
|
|
124
|
+
type: 'implement.completed',
|
|
125
|
+
payload: {
|
|
126
|
+
round: request.payload.round,
|
|
127
|
+
commits: { base: request.payload.base, head: request.payload.sha },
|
|
128
|
+
artifact: request.payload.artifact,
|
|
129
|
+
},
|
|
130
|
+
})
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
if (request.payload.operation === 'reconcile') {
|
|
134
|
+
await deps.store.append(slug, {
|
|
135
|
+
actor: DISPATCHER,
|
|
136
|
+
type: 'reconcile.completed',
|
|
137
|
+
payload: { mergeCommit: request.payload.sha, artifact: request.payload.artifact },
|
|
138
|
+
})
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
if (request.payload.operation === 'finalize-step') {
|
|
142
|
+
await deps.store.append(slug, {
|
|
143
|
+
actor: KERNEL,
|
|
144
|
+
type: 'finalize.step-completed',
|
|
145
|
+
payload: { step: request.payload.step, ok: true, headSha: request.payload.sha },
|
|
146
|
+
})
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const description = await deps.store.getArtifact(
|
|
151
|
+
slug,
|
|
152
|
+
request.payload.description.kind,
|
|
153
|
+
request.payload.description.rev,
|
|
154
|
+
)
|
|
155
|
+
if (description === null) throw new Error(`missing PR description for ${slug}`)
|
|
156
|
+
const text = new TextDecoder().decode(description.content)
|
|
157
|
+
const newline = text.indexOf('\n')
|
|
158
|
+
const title = (newline === -1 ? text : text.slice(0, newline)).replace(/^#+\s*/, '').trim()
|
|
159
|
+
if (title === '') throw new Error(`empty PR title for ${slug}`)
|
|
160
|
+
const created = events.findLast((event) => event.type === 'build.created')
|
|
161
|
+
if (created?.type !== 'build.created') throw new Error(`missing build.created for ${slug}`)
|
|
162
|
+
|
|
163
|
+
await completeFinalizePr(
|
|
164
|
+
{
|
|
165
|
+
store: deps.store,
|
|
166
|
+
env: {
|
|
167
|
+
store: deps.storeRef,
|
|
168
|
+
build: slug,
|
|
169
|
+
phase: 'finalize',
|
|
170
|
+
round: 1,
|
|
171
|
+
session: `dispatcher-${deps.runId}`,
|
|
172
|
+
},
|
|
173
|
+
workspacePath: deps.workspacePath,
|
|
174
|
+
forge: deps.forge,
|
|
175
|
+
exec: deps.exec,
|
|
176
|
+
ids: deps.ids,
|
|
177
|
+
},
|
|
178
|
+
events,
|
|
179
|
+
{
|
|
180
|
+
branch: request.payload.branch,
|
|
181
|
+
baseBranch: created.payload.baseBranch,
|
|
182
|
+
title,
|
|
183
|
+
body: newline === -1 ? '' : text.slice(newline + 1).replace(/^\n+/, ''),
|
|
184
|
+
mergeMessage: text,
|
|
185
|
+
expectedHeadSha: request.payload.sha,
|
|
186
|
+
},
|
|
187
|
+
)
|
|
188
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repository identity and sessionless local-state resolution.
|
|
3
|
+
*
|
|
4
|
+
* Repository identity (SPEC §7.2, §12) is the checkout's normalized origin
|
|
5
|
+
* URL (`normalizeGitRemoteUrl`), not the checkout path: a path-based identity
|
|
6
|
+
* makes two hosts disagree about which repository they operate. A checkout
|
|
7
|
+
* with no `origin` remote falls back to its resolved path, so origin-less
|
|
8
|
+
* local fixtures keep today's behavior. `RepoStatePaths` therefore carries
|
|
9
|
+
* BOTH: `repo` is the store-keyed identity, `checkout` is the physical main
|
|
10
|
+
* checkout every filesystem consumer (config path, plugin loading, forge
|
|
11
|
+
* `repoRoot`, worktree root, live-reload source) keeps using.
|
|
12
|
+
*
|
|
13
|
+
* Records written before the identity change are keyed by checkout path and
|
|
14
|
+
* are NOT migrated; they remain visible where their recorded `repoOrigin`
|
|
15
|
+
* matches the querying checkout (see `buildInRepository`).
|
|
16
|
+
*
|
|
17
|
+
* One resolver owns both concepts because they must agree in linked worktrees:
|
|
18
|
+
* Git's repository/worktree metadata identifies the main checkout, whose
|
|
19
|
+
* `.autobuild/` directory is the implicit state root. Local overrides are
|
|
20
|
+
* normalized against that checkout so the dispatcher and agents cannot
|
|
21
|
+
* interpret a relative path from different working directories.
|
|
22
|
+
*/
|
|
23
|
+
import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
|
|
24
|
+
import type { Exec } from '../ports/workspace/git-worktree'
|
|
25
|
+
import type { BuildRecord } from '../store/types'
|
|
26
|
+
import { normalizeGitRemoteUrl } from '../kernel/origin'
|
|
27
|
+
|
|
28
|
+
export { normalizeGitRemoteUrl } from '../kernel/origin'
|
|
29
|
+
|
|
30
|
+
export const LOCAL_STATE_DIR = '.autobuild'
|
|
31
|
+
|
|
32
|
+
export function isRemoteStoreRef(ref: string): boolean {
|
|
33
|
+
return /^https?:\/\//i.test(ref)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function absoluteGitPath(path: string, target: string): string {
|
|
37
|
+
return isAbsolute(path) ? resolve(path) : resolve(target, path)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Resolve the main checkout from Git's repository/worktree topology.
|
|
42
|
+
*
|
|
43
|
+
* A normal checkout (including a submodule or `--separate-git-dir` checkout)
|
|
44
|
+
* has equal Git and common directories, so `--show-toplevel` is authoritative.
|
|
45
|
+
* A linked worktree has a per-worktree Git directory and a shared common
|
|
46
|
+
* directory; Git's worktree registry lists the main checkout first. This avoids
|
|
47
|
+
* assuming the common directory is `<checkout>/.git`, which is false for
|
|
48
|
+
* submodules and separately stored Git directories.
|
|
49
|
+
*
|
|
50
|
+
* Outside Git (or when Git cannot be executed), the resolved target directory
|
|
51
|
+
* is the deterministic fallback.
|
|
52
|
+
*/
|
|
53
|
+
export async function resolveMainRepo(targetRepo: string, exec: Exec): Promise<string> {
|
|
54
|
+
const target = resolve(targetRepo)
|
|
55
|
+
try {
|
|
56
|
+
const result = await exec(
|
|
57
|
+
[
|
|
58
|
+
'git',
|
|
59
|
+
'rev-parse',
|
|
60
|
+
'--path-format=absolute',
|
|
61
|
+
'--git-dir',
|
|
62
|
+
'--git-common-dir',
|
|
63
|
+
'--show-toplevel',
|
|
64
|
+
],
|
|
65
|
+
{ cwd: target },
|
|
66
|
+
)
|
|
67
|
+
if (result.exitCode !== 0) return target
|
|
68
|
+
const [gitDirRaw, commonDirRaw, topLevelRaw] = result.stdout.trimEnd().split('\n')
|
|
69
|
+
if (!gitDirRaw || !commonDirRaw || !topLevelRaw) return target
|
|
70
|
+
|
|
71
|
+
const gitDir = absoluteGitPath(gitDirRaw, target)
|
|
72
|
+
const commonDir = absoluteGitPath(commonDirRaw, target)
|
|
73
|
+
const topLevel = absoluteGitPath(topLevelRaw, target)
|
|
74
|
+
if (gitDir === commonDir) return topLevel
|
|
75
|
+
|
|
76
|
+
const worktrees = await exec(['git', 'worktree', 'list', '--porcelain', '-z'], { cwd: target })
|
|
77
|
+
if (worktrees.exitCode === 0) {
|
|
78
|
+
const main = worktrees.stdout
|
|
79
|
+
.split('\0')
|
|
80
|
+
.find((entry) => entry.startsWith('worktree '))
|
|
81
|
+
?.slice('worktree '.length)
|
|
82
|
+
if (main) return absoluteGitPath(main, target)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Old Git versions without porcelain -z still have the ordinary linked
|
|
86
|
+
// layout. Only derive from dirname when the common dir is literally .git;
|
|
87
|
+
// otherwise the current worktree is safer than writing inside Git metadata.
|
|
88
|
+
return basename(commonDir) === '.git' ? dirname(commonDir) : topLevel
|
|
89
|
+
} catch {
|
|
90
|
+
return target
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface RepoStatePaths {
|
|
95
|
+
/** Repository identity in BuildStore records/journals: the checkout's
|
|
96
|
+
* normalized origin URL, falling back to the resolved checkout path only
|
|
97
|
+
* when the checkout has no origin remote. Opaque to the store schema. */
|
|
98
|
+
repo: string
|
|
99
|
+
/** The physical main checkout — the value `repo` held before the origin
|
|
100
|
+
* identity. Every filesystem consumer (config path, plugin loading, forge
|
|
101
|
+
* `repoRoot`, worktree root, live-reload source) uses this. */
|
|
102
|
+
checkout: string
|
|
103
|
+
/** The only implicit local state root. */
|
|
104
|
+
defaultLocalRoot: string
|
|
105
|
+
/** Normalized local path, or an unchanged HTTP(S) URL. */
|
|
106
|
+
storeRef: string
|
|
107
|
+
/** Root for local-only state (tickets and worktrees). */
|
|
108
|
+
localStateRoot: string
|
|
109
|
+
/** Local scratch root used by GitWorktreeProvider. */
|
|
110
|
+
worktreeRoot: string
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function nonBlank(value: string | undefined): string | undefined {
|
|
114
|
+
return value !== undefined && value.trim() !== '' ? value : undefined
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Select state with one precedence rule for every sessionless command:
|
|
119
|
+
* non-blank explicit `--store` > non-blank `AB_STORE` > repository-local default.
|
|
120
|
+
*
|
|
121
|
+
* A local selection relocates the whole local tree, including worktrees. A
|
|
122
|
+
* remote store has no filesystem root, so its worktrees remain local beneath
|
|
123
|
+
* the repository's implicit state root.
|
|
124
|
+
*/
|
|
125
|
+
export function resolveRepoStatePaths(opts: {
|
|
126
|
+
/** Repository identity: a normalized origin URL, or a local checkout path. */
|
|
127
|
+
repo: string
|
|
128
|
+
/** The physical checkout. Defaults to `repo` so origin-less fixtures —
|
|
129
|
+
* where identity IS the path — need no extra argument. */
|
|
130
|
+
checkout?: string
|
|
131
|
+
storeRef?: string
|
|
132
|
+
envStore?: string
|
|
133
|
+
}): RepoStatePaths {
|
|
134
|
+
const checkout = resolve(opts.checkout ?? opts.repo)
|
|
135
|
+
const defaultLocalRoot = join(checkout, LOCAL_STATE_DIR)
|
|
136
|
+
const selected = nonBlank(opts.storeRef) ?? nonBlank(opts.envStore) ?? defaultLocalRoot
|
|
137
|
+
const remote = isRemoteStoreRef(selected)
|
|
138
|
+
const storeRef = remote ? selected : resolve(checkout, selected)
|
|
139
|
+
const localStateRoot = remote ? defaultLocalRoot : storeRef
|
|
140
|
+
return {
|
|
141
|
+
repo: opts.repo,
|
|
142
|
+
checkout,
|
|
143
|
+
defaultLocalRoot,
|
|
144
|
+
storeRef,
|
|
145
|
+
localStateRoot,
|
|
146
|
+
worktreeRoot: join(localStateRoot, 'worktrees'),
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The normalized origin remote of a checkout, or undefined when the checkout
|
|
152
|
+
* has no `origin` remote or git fails (non-zero exit or thrown). Never throws.
|
|
153
|
+
*/
|
|
154
|
+
export async function resolveRepoOrigin(repo: string, exec: Exec): Promise<string | undefined> {
|
|
155
|
+
try {
|
|
156
|
+
const result = await exec(['git', 'remote', 'get-url', 'origin'], { cwd: repo })
|
|
157
|
+
if (result.exitCode !== 0) return undefined
|
|
158
|
+
const raw = result.stdout.trim()
|
|
159
|
+
return raw === '' ? undefined : normalizeGitRemoteUrl(raw)
|
|
160
|
+
} catch {
|
|
161
|
+
return undefined
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Whether a build record belongs to the repository checked out at `checkout`.
|
|
167
|
+
* Identity is the checkout's normalized origin (falling back to the checkout
|
|
168
|
+
* path when there is no origin remote); for new records `record.repo` IS that
|
|
169
|
+
* origin, so equality is the whole test. Records written before the origin
|
|
170
|
+
* identity are keyed by checkout path and are NOT migrated (decision
|
|
171
|
+
* 2026-09-10); a mismatch is forgiven only when the record carries a
|
|
172
|
+
* normalized origin equal to the checkout's identity — a differently located
|
|
173
|
+
* checkout of the same repository (a sandbox guest, a second host clone) is
|
|
174
|
+
* the same repository, while a different origin (or an origin-less record)
|
|
175
|
+
* is foreign.
|
|
176
|
+
*/
|
|
177
|
+
export async function buildInRepository(
|
|
178
|
+
record: BuildRecord,
|
|
179
|
+
checkout: string,
|
|
180
|
+
exec: Exec,
|
|
181
|
+
): Promise<boolean> {
|
|
182
|
+
const origin = await resolveRepoOrigin(checkout, exec)
|
|
183
|
+
const identity = origin ?? resolve(checkout)
|
|
184
|
+
if (record.repo === identity) return true
|
|
185
|
+
if (record.repoOrigin === undefined) return false
|
|
186
|
+
// The recorded side is normalized too (idempotent for records written by
|
|
187
|
+
// this code) so a guard never depends on the writer's normalizer vintage —
|
|
188
|
+
// an ssh-spelled recorded origin still equals the https spelling computed
|
|
189
|
+
// here.
|
|
190
|
+
return normalizeGitRemoteUrl(record.repoOrigin) === identity
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Resolve repository identity, then select all state paths from it.
|
|
194
|
+
*
|
|
195
|
+
* Identity is the resolved main checkout's normalized origin remote, falling
|
|
196
|
+
* back to the checkout path when the checkout has no origin (local fixtures,
|
|
197
|
+
* tests). `checkout` carries the resolved path for every filesystem
|
|
198
|
+
* consumer. */
|
|
199
|
+
export async function resolveRepoState(opts: {
|
|
200
|
+
targetRepo: string
|
|
201
|
+
exec: Exec
|
|
202
|
+
storeRef?: string
|
|
203
|
+
envStore?: string
|
|
204
|
+
}): Promise<RepoStatePaths> {
|
|
205
|
+
const checkout = await resolveMainRepo(opts.targetRepo, opts.exec)
|
|
206
|
+
const origin = await resolveRepoOrigin(checkout, opts.exec)
|
|
207
|
+
return resolveRepoStatePaths({
|
|
208
|
+
repo: origin ?? checkout,
|
|
209
|
+
checkout,
|
|
210
|
+
...(opts.storeRef !== undefined ? { storeRef: opts.storeRef } : {}),
|
|
211
|
+
...(opts.envStore !== undefined ? { envStore: opts.envStore } : {}),
|
|
212
|
+
})
|
|
213
|
+
}
|