CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
crontech-client.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| a6d8fd5 | 1 | /** |
| 2 | * Block K — Crontech HTTP client. | |
| 3 | * | |
| 4 | * Typed tool primitives that K-agents call to drive Crontech (external | |
| 5 | * runtime / deploy platform). Each primitive hits the documented endpoint | |
| 6 | * when `CRONTECH_API_KEY` is set and falls back to a deterministic offline | |
| 7 | * mode otherwise. No method throws. | |
| 8 | * | |
| 9 | * Env vars (read lazily via getters so tests can flip them per-case): | |
| 10 | * CRONTECH_API_KEY — bearer token for the Crontech API (required) | |
| 11 | * CRONTECH_BASE_URL — override base URL (default `https://crontech.ai`) | |
| 12 | * | |
| 13 | * Endpoint shapes assumed (documented for the Crontech team): | |
| 14 | * GET {base}/api/v1/deployments?repo=<owner/name>&sha=<commitSha> | |
| 15 | * 200 -> Deployment | null | |
| 16 | * POST {base}/api/v1/deployments | |
| 17 | * body: { repo, commitSha, environment? } | |
| 18 | * 200 -> Deployment | |
| 19 | * POST {base}/api/v1/deployments/:id/rollback | |
| 20 | * body: { repo } | |
| 21 | * 200 -> { ok: true } | |
| 22 | * GET {base}/api/v1/deployments/:id/status | |
| 23 | * 200 -> { status: Deployment["status"], finishedAt?, url? } | |
| 24 | * GET {base}/api/v1/deployments/:id/errors | |
| 25 | * 200 -> { errors: [{ message, stackTrace?, count }] } | |
| 26 | */ | |
| 27 | ||
| 28 | // --------------------------------------------------------------------------- | |
| 29 | // Env getters | |
| 30 | // --------------------------------------------------------------------------- | |
| 31 | ||
| 32 | export const crontechEnv = { | |
| 33 | get apiKey(): string { | |
| 34 | return process.env.CRONTECH_API_KEY || ""; | |
| 35 | }, | |
| 36 | get baseUrl(): string { | |
| 37 | return (process.env.CRONTECH_BASE_URL || "https://crontech.ai").replace( | |
| 38 | /\/+$/, | |
| 39 | "" | |
| 40 | ); | |
| 41 | }, | |
| 42 | }; | |
| 43 | ||
| 44 | export function isConfigured(): boolean { | |
| 45 | return !!crontechEnv.apiKey; | |
| 46 | } | |
| 47 | ||
| 48 | export function buildAuthHeaders(): Record<string, string> { | |
| 49 | const headers: Record<string, string> = { | |
| 50 | "Content-Type": "application/json", | |
| 51 | }; | |
| 52 | const key = crontechEnv.apiKey; | |
| 53 | if (key) headers["Authorization"] = `Bearer ${key}`; | |
| 54 | return headers; | |
| 55 | } | |
| 56 | ||
| 57 | // --------------------------------------------------------------------------- | |
| 58 | // Types | |
| 59 | // --------------------------------------------------------------------------- | |
| 60 | ||
| 61 | export type DeploymentStatus = | |
| 62 | | "pending" | |
| 63 | | "deploying" | |
| 64 | | "live" | |
| 65 | | "failed" | |
| 66 | | "rolled_back"; | |
| 67 | ||
| 68 | export type Deployment = { | |
| 69 | deployId: string; | |
| 70 | commitSha: string; | |
| 71 | status: DeploymentStatus; | |
| 72 | environment: string; | |
| 73 | url?: string; | |
| 74 | startedAt: string; | |
| 75 | finishedAt?: string; | |
| 76 | }; | |
| 77 | ||
| 78 | export type DeployError = { | |
| 79 | message: string; | |
| 80 | stackTrace?: string; | |
| 81 | count: number; | |
| 82 | }; | |
| 83 | ||
| 84 | export type DeployWatchResult = { | |
| 85 | deployId: string; | |
| 86 | finalStatus: DeploymentStatus; | |
| 87 | errors: DeployError[]; | |
| 88 | watchedForMs: number; | |
| 89 | offline: boolean; | |
| 90 | }; | |
| 91 | ||
| 92 | const TERMINAL_STATUSES: DeploymentStatus[] = ["live", "failed", "rolled_back"]; | |
| 93 | ||
| 94 | function isTerminal(s: DeploymentStatus): boolean { | |
| 95 | return TERMINAL_STATUSES.includes(s); | |
| 96 | } | |
| 97 | ||
| 98 | function coerceStatus(raw: unknown): DeploymentStatus { | |
| 99 | const s = String(raw || "").toLowerCase(); | |
| 100 | if ( | |
| 101 | s === "pending" || | |
| 102 | s === "deploying" || | |
| 103 | s === "live" || | |
| 104 | s === "failed" || | |
| 105 | s === "rolled_back" | |
| 106 | ) { | |
| 107 | return s; | |
| 108 | } | |
| 109 | return "pending"; | |
| 110 | } | |
| 111 | ||
| 112 | function coerceDeployment(data: unknown): Deployment | null { | |
| 113 | if (!data || typeof data !== "object") return null; | |
| 114 | const d = data as Record<string, unknown>; | |
| 115 | const deployId = typeof d.deployId === "string" ? d.deployId : ""; | |
| 116 | if (!deployId) return null; | |
| 117 | return { | |
| 118 | deployId, | |
| 119 | commitSha: typeof d.commitSha === "string" ? d.commitSha : "", | |
| 120 | status: coerceStatus(d.status), | |
| 121 | environment: | |
| 122 | typeof d.environment === "string" ? d.environment : "production", | |
| 123 | url: typeof d.url === "string" ? d.url : undefined, | |
| 124 | startedAt: | |
| 125 | typeof d.startedAt === "string" | |
| 126 | ? d.startedAt | |
| 127 | : new Date().toISOString(), | |
| 128 | finishedAt: typeof d.finishedAt === "string" ? d.finishedAt : undefined, | |
| 129 | }; | |
| 130 | } | |
| 131 | ||
| 132 | // --------------------------------------------------------------------------- | |
| 133 | // Shared fetch-with-timeout helpers. Never throw — return null on any | |
| 134 | // failure so callers flip to the offline branch. | |
| 135 | // --------------------------------------------------------------------------- | |
| 136 | ||
| 137 | async function request( | |
| 138 | url: string, | |
| 139 | method: "GET" | "POST", | |
| 140 | body: unknown, | |
| 141 | timeoutMs: number | |
| 142 | ): Promise<unknown | null> { | |
| 143 | const controller = new AbortController(); | |
| 144 | const timer = setTimeout(() => controller.abort(), timeoutMs); | |
| 145 | try { | |
| 146 | const init: RequestInit = { | |
| 147 | method, | |
| 148 | headers: buildAuthHeaders(), | |
| 149 | signal: controller.signal, | |
| 150 | }; | |
| 151 | if (method !== "GET") init.body = JSON.stringify(body ?? {}); | |
| 152 | const res = await fetch(url, init); | |
| 153 | if (!res.ok) return null; | |
| 154 | return await res.json().catch(() => null); | |
| 155 | } catch { | |
| 156 | return null; | |
| 157 | } finally { | |
| 158 | clearTimeout(timer); | |
| 159 | } | |
| 160 | } | |
| 161 | ||
| 162 | // --------------------------------------------------------------------------- | |
| 163 | // Public primitives | |
| 164 | // --------------------------------------------------------------------------- | |
| 165 | ||
| 166 | export async function getDeploymentForCommit(params: { | |
| 167 | repo: string; | |
| 168 | commitSha: string; | |
| 169 | }): Promise<Deployment | null> { | |
| 170 | if (!isConfigured()) return null; | |
| 171 | const qs = new URLSearchParams({ | |
| 172 | repo: params.repo, | |
| 173 | sha: params.commitSha, | |
| 174 | }); | |
| 175 | const url = `${crontechEnv.baseUrl}/api/v1/deployments?${qs.toString()}`; | |
| 176 | const data = await request(url, "GET", undefined, 30_000); | |
| 177 | return coerceDeployment(data); | |
| 178 | } | |
| 179 | ||
| 180 | export async function triggerRedeploy(params: { | |
| 181 | repo: string; | |
| 182 | commitSha: string; | |
| 183 | environment?: string; | |
| 184 | }): Promise<Deployment | null> { | |
| 185 | if (!isConfigured()) return null; | |
| 186 | const url = `${crontechEnv.baseUrl}/api/v1/deployments`; | |
| 187 | const data = await request(url, "POST", params, 60_000); | |
| 188 | return coerceDeployment(data); | |
| 189 | } | |
| 190 | ||
| 191 | export async function rollbackDeployment(params: { | |
| 192 | repo: string; | |
| 193 | deployId: string; | |
| 194 | }): Promise<boolean> { | |
| 195 | if (!isConfigured()) return false; | |
| 196 | if (!params.deployId) return false; | |
| 197 | const url = `${crontechEnv.baseUrl}/api/v1/deployments/${encodeURIComponent( | |
| 198 | params.deployId | |
| 199 | )}/rollback`; | |
| 200 | const data = await request(url, "POST", { repo: params.repo }, 60_000); | |
| 201 | if (!data || typeof data !== "object") return false; | |
| 202 | // Accept either `{ok: true}` or any 200 JSON body as success. | |
| 203 | const ok = (data as Record<string, unknown>).ok; | |
| 204 | return ok === undefined ? true : !!ok; | |
| 205 | } | |
| 206 | ||
| 207 | export async function watchDeployment(params: { | |
| 208 | repo: string; | |
| 209 | deployId: string; | |
| 210 | maxWaitMs?: number; | |
| 211 | pollIntervalMs?: number; | |
| 212 | }): Promise<DeployWatchResult> { | |
| 213 | const maxWaitMs = params.maxWaitMs ?? 300_000; | |
| 214 | const pollIntervalMs = params.pollIntervalMs ?? 10_000; | |
| 215 | ||
| 216 | if (!isConfigured()) { | |
| 217 | return { | |
| 218 | deployId: params.deployId, | |
| 219 | finalStatus: "failed", | |
| 220 | errors: [], | |
| 221 | watchedForMs: 0, | |
| 222 | offline: true, | |
| 223 | }; | |
| 224 | } | |
| 225 | if (!params.deployId) { | |
| 226 | return { | |
| 227 | deployId: "", | |
| 228 | finalStatus: "failed", | |
| 229 | errors: [], | |
| 230 | watchedForMs: 0, | |
| 231 | offline: true, | |
| 232 | }; | |
| 233 | } | |
| 234 | ||
| 235 | const started = Date.now(); | |
| 236 | const statusUrl = `${crontechEnv.baseUrl}/api/v1/deployments/${encodeURIComponent( | |
| 237 | params.deployId | |
| 238 | )}/status`; | |
| 239 | const errorsUrl = `${crontechEnv.baseUrl}/api/v1/deployments/${encodeURIComponent( | |
| 240 | params.deployId | |
| 241 | )}/errors`; | |
| 242 | ||
| 243 | let currentStatus: DeploymentStatus = "pending"; | |
| 244 | let errors: DeployError[] = []; | |
| 245 | let pollsFailed = 0; | |
| 246 | const maxPollFailures = 3; | |
| 247 | ||
| 248 | while (Date.now() - started < maxWaitMs) { | |
| 249 | const statusRes = await request(statusUrl, "GET", undefined, 30_000); | |
| 250 | if (!statusRes || typeof statusRes !== "object") { | |
| 251 | pollsFailed++; | |
| 252 | if (pollsFailed >= maxPollFailures) { | |
| 253 | return { | |
| 254 | deployId: params.deployId, | |
| 255 | finalStatus: "failed", | |
| 256 | errors, | |
| 257 | watchedForMs: Date.now() - started, | |
| 258 | offline: true, | |
| 259 | }; | |
| 260 | } | |
| 261 | } else { | |
| 262 | pollsFailed = 0; | |
| 263 | currentStatus = coerceStatus( | |
| 264 | (statusRes as Record<string, unknown>).status | |
| 265 | ); | |
| 266 | if (isTerminal(currentStatus)) { | |
| 267 | // On terminal state, fetch latest errors so the caller has context. | |
| 268 | const errRes = await request(errorsUrl, "GET", undefined, 30_000); | |
| 269 | if (errRes && typeof errRes === "object") { | |
| 270 | const list = (errRes as Record<string, unknown>).errors; | |
| 271 | if (Array.isArray(list)) { | |
| 272 | errors = list | |
| 273 | .filter((e) => e && typeof e === "object") | |
| 274 | .map((e) => { | |
| 275 | const obj = e as Record<string, unknown>; | |
| 276 | return { | |
| 277 | message: | |
| 278 | typeof obj.message === "string" ? obj.message : "", | |
| 279 | stackTrace: | |
| 280 | typeof obj.stackTrace === "string" | |
| 281 | ? obj.stackTrace | |
| 282 | : undefined, | |
| 283 | count: Number(obj.count || 1), | |
| 284 | }; | |
| 285 | }); | |
| 286 | } | |
| 287 | } | |
| 288 | return { | |
| 289 | deployId: params.deployId, | |
| 290 | finalStatus: currentStatus, | |
| 291 | errors, | |
| 292 | watchedForMs: Date.now() - started, | |
| 293 | offline: false, | |
| 294 | }; | |
| 295 | } | |
| 296 | } | |
| 297 | ||
| 298 | // Sleep until next poll (bounded by remaining budget). | |
| 299 | const remaining = maxWaitMs - (Date.now() - started); | |
| 300 | if (remaining <= 0) break; | |
| 301 | await new Promise((r) => setTimeout(r, Math.min(pollIntervalMs, remaining))); | |
| 302 | } | |
| 303 | ||
| 304 | return { | |
| 305 | deployId: params.deployId, | |
| 306 | finalStatus: isTerminal(currentStatus) ? currentStatus : "failed", | |
| 307 | errors, | |
| 308 | watchedForMs: Date.now() - started, | |
| 309 | offline: false, | |
| 310 | }; | |
| 311 | } | |
| 312 | ||
| 313 | export const __internal = { | |
| 314 | isTerminal, | |
| 315 | coerceStatus, | |
| 316 | coerceDeployment, | |
| 317 | request, | |
| 318 | TERMINAL_STATUSES, | |
| 319 | }; |