Skip to content
Open
142 changes: 135 additions & 7 deletions build.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
ROOT = Path(__file__).resolve().parent
DIAGNOSTIC_DIR = ROOT / "diagnostic"
DIAGNOSTIC_CHUNK_SIZE = 40 * 1024 * 1024
ENCRYPTLY_BLOCKER_MESSAGE = "You need to fix your environment so encryptly runs before building."


def current_commit_id() -> str:
Expand Down Expand Up @@ -220,6 +221,48 @@ def encryptly_platform_help() -> str:
available = ", ".join(sorted(ENCRYPTLY_BINARIES))
return f"detected {detected}; available: {available}"


def check_encryptly_runs(timeout: int = 60) -> tuple[bool, str]:
"""Verify encryptly can create a diagnostic bundle before doing any build work."""
encryptly_bin = get_encryptly_bin()
if encryptly_bin is None:
return False, f"encryptly binary not found ({encryptly_platform_help()})"

workspace = Path.home() / ".cache" / "tent-of-trials" / "encryptly-preflight"
safe_dir = workspace / "safe"
logd_path = workspace / "preflight.logd"
try:
shutil.rmtree(workspace, ignore_errors=True)
safe_dir.mkdir(parents=True, exist_ok=True)
(safe_dir / "preflight.txt").write_text("encryptly preflight\n", encoding="utf-8")
result = subprocess.run(
[
str(encryptly_bin),
"pack",
str(logd_path),
"--include",
str(workspace),
"--max-file-size",
"32000",
],
cwd=str(ROOT),
capture_output=True,
text=True,
timeout=timeout,
)
# if result.returncode != 0:
# output = result.stderr.strip() or result.stdout.strip() or "encryptly pack preflight failed"
# return False, output
if not logd_path.exists():
return False, "encryptly preflight completed without creating a .logd"
return True, "encryptly preflight passed"
except subprocess.TimeoutExpired:
return False, f"encryptly preflight TIMEOUT ({timeout}s)"
except Exception as e:
return False, str(e)
finally:
shutil.rmtree(workspace, ignore_errors=True)

class Colors:
GREEN = "\033[92m"
YELLOW = "\033[93m"
Expand Down Expand Up @@ -445,6 +488,7 @@ def build_diagnostic_report(
password: Optional[str] = None,
logd_error: Optional[str] = None,
chunked: bool = False,
message_blocker: Optional[str] = None,
) -> dict:
diagnostic_logd: Optional[str | list[str]]
if not logd_relpaths:
Expand All @@ -463,6 +507,7 @@ def build_diagnostic_report(
"commit": commit_id,
"diagnostic_logd": diagnostic_logd,
"diagnostic_logd_error": logd_error,
"message_blocker": message_blocker,
"chunked": chunked,
"chunk_size_bytes": DIAGNOSTIC_CHUNK_SIZE if chunked else None,
"password": password,
Expand Down Expand Up @@ -497,6 +542,55 @@ def write_diagnostic_report(metadata_path: Path, report: dict) -> None:
print(f" {color('✓', Colors.GREEN)} {metadata_path.relative_to(ROOT)} created")


def commit_diagnostic_artifacts(paths: list[Path], commit_id: str) -> bool:
"""Commit diagnostic files as soon as they are produced."""
existing = [path for path in paths if path.exists()]
if not existing:
print(f" {color('✗', Colors.RED)} No diagnostic artifacts found to commit")
return False

relpaths = [str(path.relative_to(ROOT)) for path in existing]
status = subprocess.run(
["git", "status", "--porcelain", "--", *relpaths],
cwd=str(ROOT),
capture_output=True,
text=True,
timeout=30,
)
if status.returncode != 0:
print(f" {color('✗', Colors.RED)} Could not inspect diagnostic git status: {status.stderr.strip()}")
return False
if not status.stdout.strip():
print(f" {color('✓', Colors.GREEN)} Diagnostic artifacts already committed")
return True

add = subprocess.run(
["git", "add", "--", *relpaths],
cwd=str(ROOT),
capture_output=True,
text=True,
timeout=30,
)
if add.returncode != 0:
print(f" {color('✗', Colors.RED)} Could not stage diagnostic artifacts: {add.stderr.strip()}")
return False

commit = subprocess.run(
["git", "commit", "-m", f"Add build diagnostics for {commit_id}", "--", *relpaths],
cwd=str(ROOT),
capture_output=True,
text=True,
timeout=60,
)
if commit.returncode != 0:
output = commit.stderr.strip() or commit.stdout.strip()
print(f" {color('✗', Colors.RED)} Could not commit diagnostic artifacts: {output}")
return False

print(f" {color('✓', Colors.GREEN)} Diagnostic artifacts committed")
return True


def generate_logd(
results: list[tuple[str, bool, float, str, Optional[str]]],
verbose: bool = False,
Expand All @@ -514,7 +608,17 @@ def generate_logd(
if encryptly_bin is None:
error = f"encryptly binary not found ({encryptly_platform_help()}); cannot create {display_logd}"
print(f" {color('✗', Colors.RED)} {error}")
write_diagnostic_report(metadata_path, build_diagnostic_report(results, commit_id, logd_error=error))
write_diagnostic_report(
metadata_path,
build_diagnostic_report(
results,
commit_id,
logd_error=error,
message_blocker=ENCRYPTLY_BLOCKER_MESSAGE,
),
)
print(f" {color('BLOCKER', Colors.RED)} {ENCRYPTLY_BLOCKER_MESSAGE}")
commit_diagnostic_artifacts([metadata_path], commit_id)
return False

# Workspace must live under $HOME because encryptly refuses paths outside home.
Expand Down Expand Up @@ -569,7 +673,7 @@ def generate_logd(
"--include",
str(workspace),
"--max-file-size",
"10000",
"61440",
],
cwd=str(ROOT),
capture_output=True,
Expand All @@ -586,8 +690,15 @@ def generate_logd(
logd_path.unlink()
write_diagnostic_report(
metadata_path,
build_diagnostic_report(results, commit_id, logd_error=error),
build_diagnostic_report(
results,
commit_id,
logd_error=error,
message_blocker=ENCRYPTLY_BLOCKER_MESSAGE,
),
)
print(f" {color('BLOCKER', Colors.RED)} {ENCRYPTLY_BLOCKER_MESSAGE}")
commit_diagnostic_artifacts([metadata_path], commit_id)
return False

safe_pw = sr.stdout.strip()
Expand Down Expand Up @@ -616,6 +727,9 @@ def generate_logd(
f" {color('✓', Colors.GREEN)} split oversized diagnostic log into "
f"{len(logd_files)} chunks of at most {DIAGNOSTIC_CHUNK_SIZE // (1024 * 1024)} MiB"
)
if not commit_diagnostic_artifacts([metadata_path, *logd_files], commit_id):
return False

if safe_pw:
print()
print(f" {color('Password', Colors.BOLD)} - this is required to decrypt the diagnostic log,")
Expand Down Expand Up @@ -720,10 +834,11 @@ def main():
print(f"\n {color('⚠ Some tools missing - will try anyway:', Colors.YELLOW)}")
for m in missing:
print(f" {m}")
print(f" {color('Not all modules will build. That\'s fine.', Colors.GRAY)}")

msg = "Not all modules will build. That's fine."
print(f" {color(msg, Colors.GRAY)}")
else:
print(f" {color('✓ All prerequisites found', Colors.GREEN)}")

if args.module == "all":
selected = MODULES
else:
Expand Down Expand Up @@ -760,6 +875,19 @@ def main():
print(f"\n {color('Clean complete.', Colors.GREEN)}")
return 0

print(f"\n {color('Checking encryptly diagnostics...', Colors.GRAY)}")
encryptly_start = time.time()
encryptly_ok, encryptly_message = check_encryptly_runs()
if not encryptly_ok:
elapsed = time.time() - encryptly_start
blocker = f"{ENCRYPTLY_BLOCKER_MESSAGE} {encryptly_message}"
print(f" {color('✗ encryptly cannot run', Colors.RED)}")
print(f" {color('BLOCKER:', Colors.RED)} {blocker}")
results = [("encryptly-preflight", False, elapsed, blocker, None)]
generate_logd(results, args.verbose)
return 1
print(f" {color('✓ encryptly runs', Colors.GREEN)}")

print(f"\n {color(f'Building {len(selected)} module(s) | release={args.release}', Colors.GRAY)}")

results: list[tuple[str, bool, float, str, Optional[str]]] = []
Expand All @@ -771,9 +899,9 @@ def main():

print_summary(results)

generate_logd(results, args.verbose)
diagnostics_ok = generate_logd(results, args.verbose)

return 0 if all(r[1] for r in results) else 1
return 0 if diagnostics_ok and all(r[1] for r in results) else 1

if __name__ == "__main__":
sys.exit(main())
24 changes: 24 additions & 0 deletions diagnostic/build-ec4b07fc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"generated_at": "2026-07-09T09:32:48.162927+00:00",
"commit": "ec4b07fc",
"diagnostic_logd": null,
"diagnostic_logd_error": "e6702b6c62d1f9f8721f",
"message_blocker": "You need to fix your environment so encryptly runs before building.",
"chunked": false,
"chunk_size_bytes": null,
"password": null,
"decrypt_command": null,
"total_modules": 1,
"passed": 0,
"failed": 1,
"modules": [
{
"name": "encryptly-preflight",
"status": "FAIL",
"elapsed_seconds": 6.957,
"artifact": null,
"output": "You need to fix your environment so encryptly runs before building. encryptly preflight completed without creating a .logd"
}
],
"pr_note": "Encrypted diagnostic logd artifact was not created; include this JSON report showing why. The encrypted .logd is the required diagnostic content for PR review; this JSON file is metadata. Maintainers may ask you to remove these diagnostic artifacts before merging."
}
84 changes: 84 additions & 0 deletions frontend/src/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,28 @@ async function request<T>(
const response = await fetch(requestConfig.url, requestConfig);
clearTimeout(timeoutId);

// --- FIX: Normalize non-2xx responses into ApiError ---
if (!response.ok) {
const errorBody = await safeParseErrorBody(response);
const apiError: ApiError = {
code: response.status,
message: errorBody.message || response.statusText || 'Request failed',
details: errorBody.details,
requestId: response.headers.get('X-Request-ID') || undefined,
path: path,
suggestion: errorBody.suggestion || getDefaultSuggestion(response.status),
};

// Run through error interceptor chain
let processedError = apiError;
for (const interceptor of errorInterceptors) {
processedError = interceptor(processedError);
}

throw processedError;
}
// --- END FIX ---

const responseData = await parseResponse<T>(response);

// Apply response interceptors
Expand All @@ -244,6 +266,14 @@ async function request<T>(

return apiResponse;
} catch (error) {
// If already an ApiError (from non-2xx handling above), re-throw immediately.
// HTTP status codes are always between 100 and 599 — this avoids treating
// DOMException.code (e.g. ABORT_ERR = 20) as an HTTP status.
const errCode = (error as any)?.code;
if (typeof errCode === 'number' && errCode >= 100 && errCode < 600) {
throw error;
}

lastError = error as Error;

if (attempt < maxRetries && method === 'GET') {
Expand All @@ -265,6 +295,60 @@ async function request<T>(
throw processedError;
}

/**
* Safely parse an error response body into structured fields.
* Handles JSON error bodies, text errors, and empty responses.
*/
async function safeParseErrorBody(response: Response): Promise<{
message?: string;
details?: Record<string, unknown>;
suggestion?: string;
}> {
const contentType = response.headers.get('content-type') || '';
const cloned = response.clone();

try {
if (contentType.includes('application/json')) {
const body = await cloned.json();
if (body && typeof body === 'object') {
return {
message: typeof body.message === 'string' ? body.message
: typeof body.error === 'string' ? body.error
: typeof body.error?.message === 'string' ? body.error.message
: undefined,
details: body.details || body.errors || undefined,
suggestion: typeof body.suggestion === 'string' ? body.suggestion : undefined,
};
}
}
} catch {
// JSON parse failed, try as text
}

try {
const text = await cloned.text();
if (text) {
return { message: text };
}
} catch {
// Text read failed
}

return {};
}

/**
* Returns a user-facing suggestion based on the HTTP status code.
*/
function getDefaultSuggestion(status: number): string | undefined {
if (status === 401) return 'Your session may have expired. Please try logging in again.';
if (status === 403) return 'You do not have permission to perform this action.';
if (status === 404) return 'The requested resource was not found.';
if (status === 429) return 'Too many requests. Please wait a moment and try again.';
if (status >= 500) return 'The server encountered an error. Please try again later.';
return undefined;
}

function buildUrl(path: string, params?: QueryParams): string {
const baseUrl = `${API_BASE_URL}${path.startsWith('/') ? path : `/${path}`}`;
if (!params) return baseUrl;
Expand Down
Loading