Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
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.

crontech-client.tsBlame319 lines · 1 contributor
a6d8fd5Claude1/**
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
32export 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
44export function isConfigured(): boolean {
45 return !!crontechEnv.apiKey;
46}
47
48export 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
61export type DeploymentStatus =
62 | "pending"
63 | "deploying"
64 | "live"
65 | "failed"
66 | "rolled_back";
67
68export 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
78export type DeployError = {
79 message: string;
80 stackTrace?: string;
81 count: number;
82};
83
84export type DeployWatchResult = {
85 deployId: string;
86 finalStatus: DeploymentStatus;
87 errors: DeployError[];
88 watchedForMs: number;
89 offline: boolean;
90};
91
92const TERMINAL_STATUSES: DeploymentStatus[] = ["live", "failed", "rolled_back"];
93
94function isTerminal(s: DeploymentStatus): boolean {
95 return TERMINAL_STATUSES.includes(s);
96}
97
98function 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
112function 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
137async 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
166export 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
180export 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
191export 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
207export 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
313export const __internal = {
314 isTerminal,
315 coerceStatus,
316 coerceDeployment,
317 request,
318 TERMINAL_STATUSES,
319};