Skip to content

Commit 89a6836

Browse files
committed
feat(orchestrator): add pre-flight check
1 parent a843790 commit 89a6836

15 files changed

Lines changed: 576 additions & 24 deletions

File tree

CLAUDE.md

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1 @@
11
@AGENTS.md
2-
3-
Root **`mise.toml`** configures a mise monorepo (`config_roots`: **`cdk`**, **`agent`**, **`cli`**, **`docs`**). Use **`MISE_EXPERIMENTAL=1`** for **`mise //pkg:task`**; run **`mise trust`** at the repo root after clone. **`scripts/`** is for cross-package helpers (often thin wrappers around **`mise run`**); **`scripts/ci-build.sh`** is the CI-equivalent full build.
4-
5-
## Repository layout (quick reference)
6-
7-
- **`cdk/`** — CDK application package: `cdk/src/main.ts`, `stacks/`, `constructs/`, `handlers/`.
8-
- **`cli/`**`@backgroundagent/cli` package (`bgagent`). Build from repo root: `mise //cli:build` (or `cd cli && mise run build`).
9-
- **`agent/`** — Python agent code, Dockerfile, and runtime for the compute environment.
10-
- **`cdk/test/`** — Jest tests for the CDK app (layout mirrors `cdk/src/`).
11-
- **`docs/`** — Authoritative Markdown under `docs/guides/` (developer, user, roadmap, prompt guides) and `docs/design/`; assets in `docs/imgs/` and `docs/diagrams/`. The docs website (Astro + Starlight) lives in `docs/`; `docs/src/content/docs/` is synced via `docs/scripts/sync-starlight.mjs` (run through `mise //docs:sync` or `mise //docs:build`).
12-
- **`CONTRIBUTING.md`** — Contribution guidelines at the **repository root**.
13-
14-
The CLI is at **`cli/`** (not `packages/cli`).

cdk/src/handlers/orchestrate-task.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
startSession,
3232
type PollState,
3333
} from './shared/orchestrator';
34+
import { runPreflightChecks } from './shared/preflight';
3435

3536
interface OrchestrateTaskEvent {
3637
readonly task_id: string;
@@ -76,6 +77,34 @@ const durableHandler: DurableExecutionHandler<OrchestrateTaskEvent, void> = asyn
7677
return;
7778
}
7879

80+
// Step 2b: Pre-flight checks — verify external dependencies before consuming AgentCore runtime
81+
const preflightPassed = await context.step('pre-flight', async () => {
82+
try {
83+
const current = await loadTask(taskId);
84+
if (TERMINAL_STATUSES.includes(current.status)) {
85+
return false;
86+
}
87+
const result = await runPreflightChecks(task.repo, blueprintConfig);
88+
if (!result.passed) {
89+
const errorMessage = `Pre-flight check failed: ${result.failureReason}${result.failureDetail ? ' — ' + result.failureDetail : ''}`;
90+
await failTask(taskId, current.status, errorMessage, task.user_id, true);
91+
await emitTaskEvent(taskId, 'preflight_failed', {
92+
reason: result.failureReason,
93+
detail: result.failureDetail,
94+
checks: result.checks,
95+
});
96+
}
97+
return result.passed;
98+
} catch (err) {
99+
await failTask(taskId, task.status, `Pre-flight failed: ${String(err)}`, task.user_id, true);
100+
throw err;
101+
}
102+
});
103+
104+
if (!preflightPassed) {
105+
return;
106+
}
107+
79108
// Step 3: Context hydration — assemble payload and transition to HYDRATING
80109
const payload = await context.step('hydrate-context', async () => {
81110
try {
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
/**
2+
* MIT No Attribution
3+
*
4+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy of
7+
* the Software without restriction, including without limitation the rights to
8+
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9+
* the Software, and to permit persons to whom the Software is furnished to do so.
10+
*
11+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
12+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
13+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
14+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
15+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
16+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
17+
* SOFTWARE.
18+
*/
19+
20+
import { resolveGitHubToken } from './context-hydration';
21+
import { logger } from './logger';
22+
import type { BlueprintConfig } from './repo-config';
23+
24+
// ---------------------------------------------------------------------------
25+
// Types
26+
// ---------------------------------------------------------------------------
27+
28+
export const PreflightFailureReason = {
29+
GITHUB_UNREACHABLE: 'GITHUB_UNREACHABLE',
30+
REPO_NOT_FOUND_OR_NO_ACCESS: 'REPO_NOT_FOUND_OR_NO_ACCESS',
31+
RUNTIME_UNAVAILABLE: 'RUNTIME_UNAVAILABLE',
32+
} as const;
33+
34+
export type PreflightFailureReasonType = typeof PreflightFailureReason[keyof typeof PreflightFailureReason];
35+
36+
export interface PreflightCheckResult {
37+
readonly check: string;
38+
readonly passed: boolean;
39+
readonly reason?: PreflightFailureReasonType;
40+
readonly detail?: string;
41+
readonly httpStatus?: number;
42+
readonly durationMs: number;
43+
}
44+
45+
export interface PreflightResult {
46+
readonly passed: boolean;
47+
readonly checks: readonly PreflightCheckResult[];
48+
readonly failureReason?: PreflightFailureReasonType;
49+
readonly failureDetail?: string;
50+
}
51+
52+
// ---------------------------------------------------------------------------
53+
// Constants
54+
// ---------------------------------------------------------------------------
55+
56+
const GITHUB_API_TIMEOUT_MS = 5_000;
57+
58+
// ---------------------------------------------------------------------------
59+
// Internal check functions
60+
// ---------------------------------------------------------------------------
61+
62+
async function checkGitHubReachability(token: string): Promise<PreflightCheckResult> {
63+
const start = Date.now();
64+
try {
65+
const resp = await fetch('https://api.github.com/rate_limit', {
66+
headers: {
67+
Authorization: `token ${token}`,
68+
Accept: 'application/vnd.github.v3+json',
69+
},
70+
signal: AbortSignal.timeout(GITHUB_API_TIMEOUT_MS),
71+
});
72+
const durationMs = Date.now() - start;
73+
if (resp.ok) {
74+
return { check: 'github_reachability', passed: true, durationMs };
75+
}
76+
return {
77+
check: 'github_reachability',
78+
passed: false,
79+
reason: PreflightFailureReason.GITHUB_UNREACHABLE,
80+
detail: `GitHub API returned HTTP ${resp.status}`,
81+
httpStatus: resp.status,
82+
durationMs,
83+
};
84+
} catch (err) {
85+
const detail = err instanceof Error ? err.message : String(err);
86+
logger.warn('GitHub reachability check failed', { error: detail });
87+
return {
88+
check: 'github_reachability',
89+
passed: false,
90+
reason: PreflightFailureReason.GITHUB_UNREACHABLE,
91+
detail,
92+
durationMs: Date.now() - start,
93+
};
94+
}
95+
}
96+
97+
async function checkRepoAccess(repo: string, token: string): Promise<PreflightCheckResult> {
98+
const start = Date.now();
99+
try {
100+
const resp = await fetch(`https://api.github.com/repos/${repo}`, {
101+
headers: {
102+
Authorization: `token ${token}`,
103+
Accept: 'application/vnd.github.v3+json',
104+
},
105+
signal: AbortSignal.timeout(GITHUB_API_TIMEOUT_MS),
106+
});
107+
const durationMs = Date.now() - start;
108+
if (resp.ok) {
109+
return { check: 'repo_access', passed: true, durationMs };
110+
}
111+
if (resp.status === 404 || resp.status === 403) {
112+
return {
113+
check: 'repo_access',
114+
passed: false,
115+
reason: PreflightFailureReason.REPO_NOT_FOUND_OR_NO_ACCESS,
116+
detail: `GitHub API returned HTTP ${resp.status} for ${repo}`,
117+
httpStatus: resp.status,
118+
durationMs,
119+
};
120+
}
121+
return {
122+
check: 'repo_access',
123+
passed: false,
124+
reason: PreflightFailureReason.GITHUB_UNREACHABLE,
125+
detail: `GitHub API returned HTTP ${resp.status} for ${repo}`,
126+
httpStatus: resp.status,
127+
durationMs,
128+
};
129+
} catch (err) {
130+
const detail = err instanceof Error ? err.message : String(err);
131+
logger.warn('Repo access check failed', { repo, error: detail });
132+
return {
133+
check: 'repo_access',
134+
passed: false,
135+
reason: PreflightFailureReason.GITHUB_UNREACHABLE,
136+
detail,
137+
durationMs: Date.now() - start,
138+
};
139+
}
140+
}
141+
142+
async function checkRuntimeAvailability(): Promise<PreflightCheckResult> {
143+
const start = Date.now();
144+
return { check: 'runtime_availability', passed: true, durationMs: Date.now() - start };
145+
}
146+
147+
// ---------------------------------------------------------------------------
148+
// Main pre-flight check runner
149+
// ---------------------------------------------------------------------------
150+
151+
export async function runPreflightChecks(repo: string, blueprintConfig: BlueprintConfig): Promise<PreflightResult> {
152+
const checks: PreflightCheckResult[] = [];
153+
154+
if (blueprintConfig.github_token_secret_arn) {
155+
// Resolve token — fail immediately if token resolution fails
156+
let token: string;
157+
const tokenStart = Date.now();
158+
try {
159+
token = await resolveGitHubToken(blueprintConfig.github_token_secret_arn);
160+
} catch (err) {
161+
const detail = err instanceof Error ? err.message : String(err);
162+
logger.error('GitHub token resolution failed', { repo, error: detail });
163+
checks.push({
164+
check: 'github_token_resolution',
165+
passed: false,
166+
reason: PreflightFailureReason.GITHUB_UNREACHABLE,
167+
detail,
168+
durationMs: Date.now() - tokenStart,
169+
});
170+
return {
171+
passed: false,
172+
checks,
173+
failureReason: PreflightFailureReason.GITHUB_UNREACHABLE,
174+
failureDetail: detail,
175+
};
176+
}
177+
178+
// Run reachability + repo access checks in parallel
179+
// eslint-disable-next-line @cdklabs/promiseall-no-unbounded-parallelism
180+
const results = await Promise.allSettled([
181+
checkGitHubReachability(token),
182+
checkRepoAccess(repo, token),
183+
]);
184+
185+
for (const result of results) {
186+
if (result.status === 'fulfilled') {
187+
checks.push(result.value);
188+
} else {
189+
// Defensive: inner check functions catch internally, but handle unexpected rejections fail-closed
190+
const errorDetail = result.reason instanceof Error ? result.reason.message : String(result.reason);
191+
logger.error('Pre-flight check promise rejected unexpectedly', { repo, error: errorDetail });
192+
checks.push({
193+
check: 'unknown',
194+
passed: false,
195+
reason: PreflightFailureReason.GITHUB_UNREACHABLE,
196+
detail: `Internal error: ${errorDetail}`,
197+
durationMs: 0,
198+
});
199+
}
200+
}
201+
} else {
202+
logger.warn('No GitHub token configured — skipping GitHub pre-flight checks', { repo });
203+
}
204+
205+
// Runtime check (behind feature flag — read at call time so tests can toggle)
206+
if (process.env.PREFLIGHT_CHECK_RUNTIME === 'true') {
207+
checks.push(await checkRuntimeAvailability());
208+
}
209+
210+
// Aggregate: passed only if all checks passed
211+
const failedChecks = checks.filter(c => !c.passed);
212+
if (failedChecks.length === 0) {
213+
return { passed: true, checks };
214+
}
215+
216+
// Prioritize GITHUB_UNREACHABLE over REPO_NOT_FOUND_OR_NO_ACCESS
217+
const primaryFailure = failedChecks.find(c => c.reason === PreflightFailureReason.GITHUB_UNREACHABLE)
218+
?? failedChecks[0];
219+
220+
return {
221+
passed: false,
222+
checks,
223+
failureReason: primaryFailure.reason,
224+
failureDetail: primaryFailure.detail,
225+
};
226+
}

0 commit comments

Comments
 (0)