Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

ai-workspace.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.

ai-workspace.tsBlame587 lines · 1 contributor
77cf834Claude1/**
2 * AI Copilot Workspace — issue-to-PR autonomous agent.
3 *
4 * Pipeline:
5 * 1. Load issue context (title + body + recent comments).
6 * 2. Explore the codebase — ls-tree then ask Claude which files to read.
7 * 3. Generate an implementation plan (Claude call) + post as issue comment.
8 * 4. Implement: create branch, apply file edits via git plumbing.
9 * 5. Open a draft PR linked to the issue.
10 *
11 * The hot path is fully in-memory (WorkspaceJob map). A workspace_jobs table
12 * is written for auditability via `src/db/schema.ts` + migration 0103.
13 *
14 * Never throws externally. All failures set job.status = "failed".
15 */
16
17import { join } from "path";
18import { eq, desc } from "drizzle-orm";
19import { db } from "../db";
20import {
21 issues,
22 issueComments,
23 repositories,
24 users,
25 pullRequests,
26 workspaceJobs,
27} from "../db/schema";
28import { getAnthropic, isAiAvailable, MODEL_SONNET, extractText, parseJsonResponse } from "./ai-client";
29import { applyEditsToNewBranch, type FileEdit } from "./spec-git";
30import { getBlob } from "../git/repository";
31import { getBotUserIdOrFallback } from "./bot-user";
32import { config } from "./config";
33
34// ---------------------------------------------------------------------------
35// Public types
36// ---------------------------------------------------------------------------
37
38export type WorkspaceStatus =
39 | "pending"
40 | "planning"
41 | "implementing"
42 | "opening_pr"
43 | "done"
44 | "failed";
45
46export interface WorkspaceJob {
47 id: string;
48 repoId: string;
49 issueId: string;
50 issueNumber: number;
51 ownerName: string;
52 repoName: string;
53 status: WorkspaceStatus;
54 planComment?: string; // the plan posted as issue comment
55 branchName?: string; // e.g. "workspace/issue-42-fix-auth"
56 prNumber?: number;
57 errorMessage?: string;
58 startedAt: Date;
59 updatedAt: Date;
60}
61
62// ---------------------------------------------------------------------------
63// In-memory job store (LRU, max 100 jobs)
64// ---------------------------------------------------------------------------
65
66const MAX_JOBS = 100;
67const jobById = new Map<string, WorkspaceJob>();
68// issueId → jobId for O(1) lookup by issue
69const jobByIssueId = new Map<string, string>();
70// repoId → jobId for "one active job per repo" guard
71const activeJobByRepoId = new Map<string, string>();
72
73function evictOldestIfNeeded(): void {
74 if (jobById.size < MAX_JOBS) return;
75 // Evict oldest (first inserted) entry
76 const firstKey = jobById.keys().next().value;
77 if (firstKey) {
78 const old = jobById.get(firstKey);
79 if (old) {
80 jobByIssueId.delete(old.issueId);
81 if (activeJobByRepoId.get(old.repoId) === firstKey) {
82 activeJobByRepoId.delete(old.repoId);
83 }
84 }
85 jobById.delete(firstKey);
86 }
87}
88
89function storeJob(job: WorkspaceJob): void {
90 evictOldestIfNeeded();
91 jobById.set(job.id, job);
92 jobByIssueId.set(job.issueId, job.id);
93 if (job.status !== "done" && job.status !== "failed") {
94 activeJobByRepoId.set(job.repoId, job.id);
95 }
96}
97
98function updateJob(job: WorkspaceJob, patch: Partial<WorkspaceJob>): void {
99 Object.assign(job, patch, { updatedAt: new Date() });
100 if (job.status === "done" || job.status === "failed") {
101 if (activeJobByRepoId.get(job.repoId) === job.id) {
102 activeJobByRepoId.delete(job.repoId);
103 }
104 }
105}
106
107// ---------------------------------------------------------------------------
108// Public accessors
109// ---------------------------------------------------------------------------
110
111export function getWorkspaceJob(jobId: string): WorkspaceJob | undefined {
112 return jobById.get(jobId);
113}
114
115export function getWorkspaceJobForIssue(issueId: string): WorkspaceJob | undefined {
116 const jobId = jobByIssueId.get(issueId);
117 return jobId ? jobById.get(jobId) : undefined;
118}
119
120// ---------------------------------------------------------------------------
121// Entry point
122// ---------------------------------------------------------------------------
123
124export async function startWorkspace(
125 issueId: string,
126 issueNumber: number,
127 repoId: string,
128 ownerName: string,
129 repoName: string,
130 triggeredByUserId: string
131): Promise<WorkspaceJob> {
132 // Guard: AI must be available
133 if (!isAiAvailable()) {
134 const failed: WorkspaceJob = {
135 id: crypto.randomUUID(),
136 repoId,
137 issueId,
138 issueNumber,
139 ownerName,
140 repoName,
141 status: "failed",
142 errorMessage: "ANTHROPIC_API_KEY is not configured",
143 startedAt: new Date(),
144 updatedAt: new Date(),
145 };
146 storeJob(failed);
147 return failed;
148 }
149
150 // Guard: max 1 active job per repo
151 const existingJobId = activeJobByRepoId.get(repoId);
152 if (existingJobId) {
153 const existing = jobById.get(existingJobId);
154 if (existing && existing.status !== "done" && existing.status !== "failed") {
155 return existing;
156 }
157 // Stale entry — clear it
158 activeJobByRepoId.delete(repoId);
159 }
160
161 const job: WorkspaceJob = {
162 id: crypto.randomUUID(),
163 repoId,
164 issueId,
165 issueNumber,
166 ownerName,
167 repoName,
168 status: "pending",
169 startedAt: new Date(),
170 updatedAt: new Date(),
171 };
172
173 storeJob(job);
174
175 // Persist to DB for auditability (best-effort)
176 try {
177 await db.insert(workspaceJobs).values({
178 id: job.id,
179 repoId,
180 issueId,
181 triggeredBy: triggeredByUserId,
182 status: "pending",
183 });
184 } catch {
185 /* non-fatal — in-memory store is the source of truth */
186 }
187
188 // Fire-and-forget pipeline
189 runWorkspacePipeline(job, triggeredByUserId).catch(() => {
190 /* errors are captured inside the pipeline */
191 });
192
193 return job;
194}
195
196// ---------------------------------------------------------------------------
197// Pipeline
198// ---------------------------------------------------------------------------
199
200async function runWorkspacePipeline(
201 job: WorkspaceJob,
202 triggeredByUserId: string
203): Promise<void> {
204 try {
205 // Step 1 — Load issue context
206 const issueCtx = await loadIssueContext(job.issueId);
207 if (!issueCtx) {
208 throw new Error("Issue not found or DB read failed");
209 }
210
211 // Step 2 — Explore codebase
212 updateJob(job, { status: "planning" });
213 await persistJobStatus(job);
214
215 const reposBase = config.gitReposPath;
216 const repoDiskPath = join(reposBase, job.ownerName, `${job.repoName}.git`);
217
218 const fileTree = await getFileTree(repoDiskPath);
219 const relevantFiles = await pickRelevantFiles(issueCtx, fileTree);
220 const fileContents = await readFileContents(
221 job.ownerName,
222 job.repoName,
223 relevantFiles
224 );
225
226 // Step 3 — Generate implementation plan
227 const plan = await generatePlan(issueCtx, fileContents);
228
229 // Post plan as issue comment
230 const planBody = formatPlanComment(plan);
231 const botUserId = await getBotUserIdOrFallback(triggeredByUserId);
232 const commentId = await postIssueComment(job.issueId, botUserId, planBody);
233 updateJob(job, { planComment: commentId ?? undefined });
234
235 // Step 4 — Implement
236 updateJob(job, { status: "implementing" });
237 await persistJobStatus(job);
238
239 // Resolve default branch
240 const repoRow = await db
241 .select({ defaultBranch: repositories.defaultBranch })
242 .from(repositories)
243 .where(eq(repositories.id, job.repoId))
244 .limit(1);
245 const baseBranch = repoRow[0]?.defaultBranch ?? "main";
246
247 const branchName = plan.branchName || sanitizeBranchName(
248 `workspace/issue-${job.issueNumber}-${slugify(issueCtx.title)}`
249 );
250 updateJob(job, { branchName });
251
252 const edits = buildEdits(plan);
253 if (edits.length === 0) {
254 throw new Error("AI plan produced no file changes");
255 }
256
257 const commitMsg =
258 `feat: implement #${job.issueNumber} — ${plan.summary.slice(0, 60)}\n\n` +
259 `Closes #${job.issueNumber}\n\n` +
260 `Generated by Gluecron AI Workspace.`;
261
262 const applied = await applyEditsToNewBranch({
263 repoDiskPath,
264 baseRef: baseBranch,
265 edits,
266 branchName,
267 commitMessage: commitMsg,
268 authorName: "Gluecron Workspace",
269 authorEmail: "workspace@gluecron.com",
270 });
271
272 if (!applied.ok) {
273 throw new Error(`git apply failed: ${applied.error}`);
274 }
275
276 // Step 5 — Open draft PR
277 updateJob(job, { status: "opening_pr" });
278 await persistJobStatus(job);
279
280 const prBody =
281 `Closes #${job.issueNumber}\n\n` +
282 `${plan.summary}\n\n` +
283 `**Files changed:**\n` +
284 applied.filesChanged.map((f) => `- \`${f}\``).join("\n") +
285 `\n\n<!-- gluecron:workspace:pr -->`;
286
287 const prTitle = `feat: ${plan.summary.slice(0, 140)}`;
288
289 const [pr] = await db
290 .insert(pullRequests)
291 .values({
292 repositoryId: job.repoId,
293 authorId: botUserId,
294 title: prTitle.slice(0, 200),
295 body: prBody,
296 baseBranch,
297 headBranch: applied.branchName,
298 isDraft: true,
299 })
300 .returning({ number: pullRequests.number });
301
302 if (!pr?.number) {
303 throw new Error("PR insert returned no number");
304 }
305
306 updateJob(job, { status: "done", prNumber: pr.number });
307 await persistJobStatus(job);
308 } catch (err: unknown) {
309 const msg = err instanceof Error ? err.message : String(err);
310 updateJob(job, { status: "failed", errorMessage: msg });
311 await persistJobStatus(job);
312 }
313}
314
315// ---------------------------------------------------------------------------
316// Step helpers
317// ---------------------------------------------------------------------------
318
319interface IssueContext {
320 title: string;
321 body: string;
322 comments: string[];
323}
324
325async function loadIssueContext(issueId: string): Promise<IssueContext | null> {
326 try {
327 const issueRows = await db
328 .select({ title: issues.title, body: issues.body })
329 .from(issues)
330 .where(eq(issues.id, issueId))
331 .limit(1);
332
333 if (!issueRows[0]) return null;
334 const { title, body } = issueRows[0];
335
336 const commentRows = await db
337 .select({ body: issueComments.body })
338 .from(issueComments)
339 .where(eq(issueComments.issueId, issueId))
340 .orderBy(desc(issueComments.createdAt))
341 .limit(5);
342
343 return {
344 title,
345 body: body ?? "",
346 comments: commentRows.map((r) => r.body),
347 };
348 } catch {
349 return null;
350 }
351}
352
353async function getFileTree(repoDiskPath: string): Promise<string[]> {
354 try {
355 const proc = Bun.spawn(
356 ["git", "ls-tree", "-r", "--name-only", "HEAD"],
357 { cwd: repoDiskPath, stdout: "pipe", stderr: "pipe" }
358 );
359 const text = await new Response(proc.stdout).text();
360 await proc.exited;
361 const lines = text
362 .split("\n")
363 .map((l) => l.trim())
364 .filter(Boolean);
365 return lines.slice(0, 500);
366 } catch {
367 return [];
368 }
369}
370
371async function pickRelevantFiles(
372 ctx: IssueContext,
373 fileTree: string[]
374): Promise<string[]> {
375 if (fileTree.length === 0) return [];
376
377 const prompt =
378 `Given this issue:\n${ctx.title}\n${ctx.body}\n\n` +
379 `And this file tree:\n${fileTree.join("\n")}\n\n` +
380 `List the 20 most relevant file paths to read in order to implement the issue. ` +
381 `Return JSON: {"files": string[]}`;
382
383 try {
384 const client = getAnthropic();
385 const msg = await client.messages.create({
386 model: MODEL_SONNET,
387 max_tokens: 512,
388 temperature: 0,
389 messages: [{ role: "user", content: prompt }],
390 });
391 const text = extractText(msg);
392 const parsed = parseJsonResponse<{ files: string[] }>(text);
393 if (parsed && Array.isArray(parsed.files)) {
394 return parsed.files.slice(0, 20).filter((f: string) => typeof f === "string");
395 }
396 } catch {
397 /* fall through — return empty */
398 }
399 // Fallback: first 10 files
400 return fileTree.slice(0, 10);
401}
402
403const MAX_FILE_BYTES = 8 * 1024; // 8 KB per file
404const MAX_TOTAL_BYTES = 80 * 1024; // 80 KB total
405
406async function readFileContents(
407 ownerName: string,
408 repoName: string,
409 filePaths: string[]
410): Promise<Array<{ path: string; content: string }>> {
411 const results: Array<{ path: string; content: string }> = [];
412 let totalBytes = 0;
413
414 for (const filePath of filePaths) {
415 if (totalBytes >= MAX_TOTAL_BYTES) break;
416 try {
417 const blob = await getBlob(ownerName, repoName, "HEAD", filePath);
418 if (!blob || blob.isBinary) continue;
419 const content = blob.content.slice(0, MAX_FILE_BYTES);
420 totalBytes += content.length;
421 results.push({ path: filePath, content });
422 } catch {
423 /* skip unreadable files */
424 }
425 }
426
427 return results;
428}
429
430interface FilePlan {
431 path: string;
432 action: "create" | "modify" | "delete";
433 description: string;
434 patch: string;
435}
436
437interface WorkspacePlan {
438 summary: string;
439 files: FilePlan[];
440 branchName: string;
441}
442
443async function generatePlan(
444 ctx: IssueContext,
445 fileContents: Array<{ path: string; content: string }>
446): Promise<WorkspacePlan> {
447 const fileSection = fileContents
448 .map((f) => `=== ${f.path} ===\n${f.content}`)
449 .join("\n\n");
450
451 const commentSection = ctx.comments.length > 0
452 ? `\n\nComments:\n${ctx.comments.map((c, i) => `[${i + 1}] ${c}`).join("\n\n")}`
453 : "";
454
455 const userPrompt =
456 `Issue: ${ctx.title}\n${ctx.body}${commentSection}\n\n` +
457 `Relevant code:\n${fileSection}\n\n` +
458 `Return JSON:\n` +
459 `{\n` +
460 ` "summary": string,\n` +
461 ` "files": Array<{\n` +
462 ` "path": string,\n` +
463 ` "action": "create" | "modify" | "delete",\n` +
464 ` "description": string,\n` +
465 ` "patch": string\n` +
466 ` }>,\n` +
467 ` "branchName": string\n` +
468 `}`;
469
470 const systemPrompt =
471 "You are an expert software engineer. Generate a detailed implementation plan for the given issue. " +
472 "For each file, provide the full new content (not a diff) in the 'patch' field. " +
473 "Branch name should follow the pattern: workspace/issue-<number>-<short-slug>. " +
474 "Return only valid JSON — no prose, no markdown fences.";
475
476 const client = getAnthropic();
477 const msg = await client.messages.create({
478 model: MODEL_SONNET,
479 max_tokens: 4096,
480 temperature: 0.2,
481 system: systemPrompt,
482 messages: [{ role: "user", content: userPrompt }],
483 });
484
485 const text = extractText(msg);
486 const parsed = parseJsonResponse<WorkspacePlan>(text);
487
488 if (!parsed || typeof parsed.summary !== "string") {
489 throw new Error("AI returned invalid plan JSON");
490 }
491
492 return {
493 summary: parsed.summary || "AI-generated implementation",
494 files: Array.isArray(parsed.files) ? parsed.files : [],
495 branchName: typeof parsed.branchName === "string" && parsed.branchName
496 ? sanitizeBranchName(parsed.branchName)
497 : "",
498 };
499}
500
501function buildEdits(plan: WorkspacePlan): FileEdit[] {
502 const edits: FileEdit[] = [];
503 for (const f of plan.files) {
504 if (!f.path || typeof f.path !== "string") continue;
505 // Reject traversal attempts
506 if (f.path.startsWith("/") || f.path.includes("..")) continue;
507
508 if (f.action === "delete") {
509 edits.push({ action: "delete", path: f.path });
510 } else {
511 // create or modify — we always write the full content
512 const content = typeof f.patch === "string" ? f.patch : "";
513 edits.push({ action: f.action === "create" ? "create" : "edit", path: f.path, content });
514 }
515 }
516 return edits;
517}
518
519function formatPlanComment(plan: WorkspacePlan): string {
520 const fileLines = plan.files
521 .map((f) => `- **${f.action}** \`${f.path}\` — ${f.description}`)
522 .join("\n");
523
524 return (
525 `<!-- gluecron:workspace:plan -->\n` +
526 `## AI Workspace Plan\n\n` +
527 `${plan.summary}\n\n` +
528 `**Files to change:**\n` +
529 (fileLines || "_No files identified_") +
530 `\n\n` +
531 `_Implementing now... a draft PR will be opened when complete._`
532 );
533}
534
535async function postIssueComment(
536 issueId: string,
537 authorId: string,
538 body: string
539): Promise<string | null> {
540 try {
541 const [row] = await db
542 .insert(issueComments)
543 .values({ issueId, authorId, body })
544 .returning({ id: issueComments.id });
545 return row?.id ?? null;
546 } catch {
547 return null;
548 }
549}
550
551async function persistJobStatus(job: WorkspaceJob): Promise<void> {
552 try {
553 await db
554 .update(workspaceJobs)
555 .set({
556 status: job.status,
557 planComment: job.planComment ?? null,
558 branchName: job.branchName ?? null,
559 prNumber: job.prNumber ?? null,
560 errorMessage: job.errorMessage ?? null,
561 updatedAt: job.updatedAt,
562 })
563 .where(eq(workspaceJobs.id, job.id));
564 } catch {
565 /* non-fatal */
566 }
567}
568
569// ---------------------------------------------------------------------------
570// Utilities
571// ---------------------------------------------------------------------------
572
573function slugify(text: string): string {
574 return text
575 .toLowerCase()
576 .replace(/[^a-z0-9]+/g, "-")
577 .replace(/^-+|-+$/g, "")
578 .slice(0, 40) || "change";
579}
580
581function sanitizeBranchName(name: string): string {
582 return name
583 .replace(/[^a-zA-Z0-9/_-]/g, "-")
584 .replace(/--+/g, "-")
585 .replace(/^-+|-+$/g, "")
586 .slice(0, 100);
587}