Skip to content
File

Blob: src/worker/dispatch/shared/run-environment.ts

typescript164 lines
1import type { CommitSha as CommitShaType } from "@/contracts";
2import type { ReplaceRunStepsInput, RepoConfig } from "@/worker/contracts";
3import { decryptSecret } from "@/worker/security/secrets";
4import { buildGitCheckoutAuth, parseRepoConfigFile, resolveWorkingDirectory } from "@/worker/sandbox";
5import {
6 logger,
7 toPositiveInteger,
8 type PreparedExecutionEnvironment,
9 type RunExecutionContext,
10} from "@/worker/dispatch/shared/run-execution-context";
11import type { RunLeaseControl } from "@/worker/dispatch/shared/run-lease";
12import { deleteSandboxSessionIfExists, getOrCreateSandboxSession } from "@/worker/dispatch/shared/sandbox-errors";
13import {
14 addOriginRemote,
15 ensureBranchCheckout,
16 ensureBranchCheckoutDepth,
17 ensurePinnedCheckoutDepth,
18 ensurePinnedCommitCheckout,
19 initializeRepository,
20 resolveCheckedOutCommitSha,
21} from "./run-environment/git";
22 
23type RunEnvironmentContext = Pick<
24 RunExecutionContext,
25 "control" | "projectControl" | "runStore" | "runtime" | "scope" | "state"
26>;
27 
28interface PrepareExecutionEnvironmentOptions {
29 executionSessionId?: string;
30}
31 
32const decodeFileText = (content: string | Uint8Array): string =>
33 typeof content === "string" ? content : new TextDecoder().decode(content);
34 
35const buildReplaceStepsInput = (repoConfig: RepoConfig): Omit<ReplaceRunStepsInput, "runId"> => ({
36 steps: repoConfig.run.steps.map((step, index) => ({
37 position: toPositiveInteger(index + 1),
38 name: step.name,
39 command: step.run,
40 })),
41});
42 
43export const prepareExecutionEnvironment = async (
44 context: RunEnvironmentContext,
45 lease: RunLeaseControl,
46 options: PrepareExecutionEnvironmentOptions = {},
47): Promise<PreparedExecutionEnvironment | null> => {
48 context.state.phase = "checking_out";
49 const checkoutToken = context.scope.executionMaterial.encryptedRepoToken
50 ? await decryptSecret(context.scope.env, context.scope.executionMaterial.encryptedRepoToken)
51 : null;
52 const checkoutAuth = buildGitCheckoutAuth(context.scope.snapshot.repoUrl, checkoutToken);
53 context.state.redactionSecrets = checkoutAuth.redactionSecrets;
54 await context.runtime.sandbox.setKeepAlive(true);
55 const checkoutSession = await context.runtime.sandbox.createSession({
56 cwd: "/workspace",
57 env: checkoutAuth.sessionEnv,
58 });
59 context.state.session = checkoutSession;
60 let repoConfig: RepoConfig | null = null;
61 let workingDirectory = context.scope.repoRoot;
62 try {
63 await lease.applyCancellationIfNeeded();
64 lease.throwIfOwnershipLost();
65 if (!lease.isCancellationRequested()) {
66 const persistedCommitSha = context.scope.snapshot.commitSha ?? (await context.runStore.getMeta()).commitSha;
67 let commitSha: CommitShaType;
68 if (persistedCommitSha === null) {
69 await ensureBranchCheckout(
70 checkoutSession,
71 context.scope.repoRoot,
72 context.scope.snapshot.repoUrl,
73 context.scope.snapshot.branch,
74 checkoutAuth.hasAuthHeader,
75 );
76 commitSha = await resolveCheckedOutCommitSha(checkoutSession, context.scope.repoRoot);
77 } else {
78 await initializeRepository(checkoutSession, context.scope.repoRoot);
79 await addOriginRemote(checkoutSession, context.scope.repoRoot, context.scope.snapshot.repoUrl);
80 await ensurePinnedCommitCheckout(
81 checkoutSession,
82 context.scope.repoRoot,
83 persistedCommitSha,
84 checkoutAuth.hasAuthHeader,
85 );
86 commitSha = await resolveCheckedOutCommitSha(checkoutSession, context.scope.repoRoot);
87 if (commitSha !== persistedCommitSha) {
88 throw new Error(`Pinned checkout resolved ${commitSha}, expected ${persistedCommitSha}.`);
89 }
90 }
91 await lease.applyCancellationIfNeeded();
92 lease.throwIfOwnershipLost();
93 const recordResult = await context.projectControl.recordResolvedCommit(commitSha);
94 if (recordResult.kind === "stale") {
95 context.control.markOwnershipLost(recordResult.status);
96 lease.throwIfOwnershipLost();
97 }
98 lease.throwIfOwnershipLost();
99 const configFile = await checkoutSession.readFile(
100 `${context.scope.repoRoot}/${context.scope.snapshot.configPath}`,
101 );
102 repoConfig = parseRepoConfigFile(decodeFileText(configFile.content));
103 workingDirectory = resolveWorkingDirectory(context.scope.repoRoot, repoConfig.run.workingDirectory);
104 if (persistedCommitSha === null) {
105 await ensureBranchCheckoutDepth(
106 checkoutSession,
107 context.scope.repoRoot,
108 context.scope.snapshot.branch,
109 repoConfig.checkout.depth,
110 checkoutAuth.hasAuthHeader,
111 );
112 } else {
113 await ensurePinnedCheckoutDepth(
114 checkoutSession,
115 context.scope.repoRoot,
116 commitSha,
117 repoConfig.checkout.depth,
118 checkoutAuth.hasAuthHeader,
119 );
120 }
121 lease.throwIfOwnershipLost();
122 }
123 } finally {
124 if (context.state.session === checkoutSession) {
125 context.state.session = null;
126 }
127 try {
128 await deleteSandboxSessionIfExists(context.runtime.sandbox, checkoutSession.id);
129 } catch (error) {
130 logger.warn("checkout_session_delete_failed", {
131 ...context.scope.logContext,
132 error: error instanceof Error ? error.message : String(error),
133 });
134 } finally {
135 context.runtime.disposeSession(checkoutSession);
136 }
137 }
138 if (repoConfig === null) {
139 if (!lease.isCancellationRequested()) {
140 throw new Error(`Repository config was not loaded for run ${context.scope.runId}.`);
141 }
142 return null;
143 }
144 if (!lease.isCancellationRequested()) {
145 if (options.executionSessionId) {
146 context.state.session = await getOrCreateSandboxSession(context.runtime, {
147 id: options.executionSessionId,
148 cwd: workingDirectory,
149 });
150 } else {
151 context.state.session = await context.runtime.sandbox.createSession({
152 cwd: workingDirectory,
153 });
154 }
155 await lease.applyCancellationIfNeeded();
156 lease.throwIfOwnershipLost();
157 }
158 await context.runStore.replaceSteps(buildReplaceStepsInput(repoConfig));
159 return {
160 repoConfig,
161 workingDirectory,
162 };
163};