Skip to content

Commit 3c28fa2

Browse files
committed
feat(worker): apply real edits and resolve vercel preview url
1 parent 67d822c commit 3c28fa2

2 files changed

Lines changed: 251 additions & 10 deletions

File tree

.github/scripts/ai-inspector-worker.mjs

Lines changed: 236 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,19 @@ const runGit = (args, options = {}) => {
3131

3232
const runGitOutput = (args, options = {}) => runCommandOutput("git", args, options);
3333

34+
const runCommand = (command, args, options = {}) => {
35+
execFileSync(command, args, {
36+
stdio: "inherit",
37+
encoding: "utf8",
38+
...options,
39+
});
40+
};
41+
42+
const sleep = (ms) =>
43+
new Promise((resolve) => {
44+
setTimeout(resolve, ms);
45+
});
46+
3447
const resolveGitHubToken = () => {
3548
const envToken = process.env.GITHUB_TOKEN?.trim() || process.env.GH_TOKEN?.trim();
3649
if (envToken) {
@@ -78,6 +91,26 @@ const escapeMarkdown = (value) => String(value ?? "").replace(/`/g, "\\`");
7891

7992
const toIso = () => new Date().toISOString();
8093

94+
const listChangedFiles = () =>
95+
runGitOutput(["status", "--porcelain"])
96+
.split("\n")
97+
.map((line) => line.trimEnd())
98+
.filter(Boolean)
99+
.map((line) => {
100+
const rawPath = line.slice(3);
101+
const targetPath = rawPath.includes(" -> ") ? rawPath.split(" -> ").pop() : rawPath;
102+
return String(targetPath ?? "").replace(/^"/, "").replace(/"$/, "");
103+
});
104+
105+
const assertCleanWorkingTree = () => {
106+
const status = runGitOutput(["status", "--porcelain"]).trim();
107+
if (status) {
108+
throw new Error(
109+
"Working tree must be clean before running ai-inspector worker. Use a dedicated clean worktree.",
110+
);
111+
}
112+
};
113+
81114
const buildTaskMarkdown = (taskId, task) => {
82115
const selector = task.selector ?? task.element?.selector ?? "";
83116
const pageUrl = task.pageUrl ?? "";
@@ -195,10 +228,7 @@ const sendDiscordNotification = async ({ taskId, prUrl, previewUrl, instruction
195228
const requestPatchFromAiEndpoint = async ({ taskId, task, branchName, repository, baseBranch }) => {
196229
const endpoint = process.env.AI_INSPECTOR_PATCH_ENDPOINT;
197230
if (!endpoint) {
198-
return {
199-
patch: "",
200-
summary: "AI_INSPECTOR_PATCH_ENDPOINT 미설정: 작업 파일만 커밋했습니다.",
201-
};
231+
return null;
202232
}
203233

204234
const apiKey = process.env.AI_INSPECTOR_PATCH_API_KEY;
@@ -227,9 +257,188 @@ const requestPatchFromAiEndpoint = async ({ taskId, task, branchName, repository
227257
patch: typeof data.patch === "string" ? data.patch : "",
228258
summary: typeof data.summary === "string" ? data.summary : "AI patch applied",
229259
title: typeof data.title === "string" ? data.title : "",
260+
appliedBy: "patch-endpoint",
261+
};
262+
};
263+
264+
const buildCodexPrompt = ({ taskId, task, repository, baseBranch, branchName }) => {
265+
const selector = task.selector ?? task.element?.selector ?? "";
266+
const pageUrl = task.pageUrl ?? "";
267+
const textSnippet = task.element?.textSnippet ?? "";
268+
const instruction = task.instruction ?? "";
269+
270+
return [
271+
"You are implementing one AI inspector UI task in this repository.",
272+
"Apply real code changes that satisfy the request, keeping edits minimal and scoped.",
273+
"Do not create commits, do not push, and do not modify environment files.",
274+
"",
275+
`Repository: ${repository}`,
276+
`Base branch: ${baseBranch}`,
277+
`Working branch: ${branchName}`,
278+
`Task ID: ${taskId}`,
279+
`Page URL: ${pageUrl}`,
280+
`Selector: ${selector}`,
281+
`Text snippet: ${textSnippet}`,
282+
"",
283+
"Instruction:",
284+
instruction,
285+
"",
286+
"After applying changes, ensure files are saved and leave the repo ready for git add/commit.",
287+
].join("\n");
288+
};
289+
290+
const runLocalCodexEdit = async ({ taskId, task, branchName, repository, baseBranch }) => {
291+
const codexEnabled = process.env.AI_INSPECTOR_LOCAL_CODEX_ENABLED?.trim() ?? "true";
292+
if (codexEnabled.toLowerCase() === "false") {
293+
return null;
294+
}
295+
296+
const outputPath = path.resolve(".ai-inspector", `codex-last-message-${taskId}.txt`);
297+
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
298+
299+
const args = [
300+
"exec",
301+
"--dangerously-bypass-approvals-and-sandbox",
302+
"--color",
303+
"never",
304+
"--output-last-message",
305+
outputPath,
306+
];
307+
308+
const model = process.env.AI_INSPECTOR_CODEX_MODEL?.trim();
309+
if (model) {
310+
args.push("--model", model);
311+
}
312+
313+
args.push(buildCodexPrompt({ taskId, task, repository, baseBranch, branchName }));
314+
runCommand("codex", args, { env: process.env });
315+
316+
const summary = fs.existsSync(outputPath) ? fs.readFileSync(outputPath, "utf8").trim() : "";
317+
fs.rmSync(outputPath, { force: true });
318+
319+
return {
320+
patch: "",
321+
summary: summary || "Local Codex edit applied.",
322+
title: "",
323+
appliedBy: "local-codex",
230324
};
231325
};
232326

327+
const resolveAiResult = async (context) => {
328+
const endpointResult = await requestPatchFromAiEndpoint(context);
329+
if (endpointResult) {
330+
return endpointResult;
331+
}
332+
333+
const localCodexResult = await runLocalCodexEdit(context);
334+
if (localCodexResult) {
335+
return localCodexResult;
336+
}
337+
338+
throw new Error(
339+
"No AI edit backend available. Configure AI_INSPECTOR_PATCH_ENDPOINT or enable local Codex execution.",
340+
);
341+
};
342+
343+
const getPreviewUrlFromVercel = async (branchName) => {
344+
const token = process.env.VERCEL_TOKEN?.trim();
345+
const projectId = process.env.VERCEL_PROJECT_ID?.trim();
346+
347+
if (!token || !projectId) {
348+
return "";
349+
}
350+
351+
const intervalMs = Number(process.env.AI_INSPECTOR_VERCEL_PREVIEW_INTERVAL_MS ?? "10000");
352+
const timeoutMs = Number(process.env.AI_INSPECTOR_VERCEL_PREVIEW_TIMEOUT_MS ?? "240000");
353+
const pollingIntervalMs = Number.isFinite(intervalMs) && intervalMs > 0 ? intervalMs : 10000;
354+
const pollingTimeoutMs = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 240000;
355+
const deadline = Date.now() + pollingTimeoutMs;
356+
357+
while (Date.now() < deadline) {
358+
const params = new URLSearchParams({
359+
projectId,
360+
limit: "20",
361+
target: "preview",
362+
"meta-githubCommitRef": branchName,
363+
});
364+
365+
const teamId = process.env.VERCEL_TEAM_ID?.trim();
366+
if (teamId) {
367+
params.set("teamId", teamId);
368+
}
369+
370+
const response = await fetch(`https://api.vercel.com/v6/deployments?${params.toString()}`, {
371+
headers: {
372+
Authorization: `Bearer ${token}`,
373+
},
374+
});
375+
376+
if (!response.ok) {
377+
const body = await response.text();
378+
throw new Error(`Failed to fetch Vercel deployment (${response.status}): ${body}`);
379+
}
380+
381+
const data = await response.json();
382+
const deployments = Array.isArray(data?.deployments) ? data.deployments : [];
383+
const deployment =
384+
deployments.find((item) => item?.meta?.githubCommitRef === branchName) ?? deployments[0] ?? null;
385+
386+
if (deployment?.readyState === "READY" && deployment?.url) {
387+
return `https://${deployment.url}`;
388+
}
389+
390+
if (deployment?.readyState === "ERROR" || deployment?.readyState === "CANCELED") {
391+
return "";
392+
}
393+
394+
await sleep(pollingIntervalMs);
395+
}
396+
397+
return "";
398+
};
399+
400+
const getPreviewUrlFromGitHubCommitStatus = async (token, owner, repo, commitSha) => {
401+
const intervalMs = Number(process.env.AI_INSPECTOR_VERCEL_PREVIEW_INTERVAL_MS ?? "10000");
402+
const timeoutMs = Number(process.env.AI_INSPECTOR_VERCEL_PREVIEW_TIMEOUT_MS ?? "240000");
403+
const pollingIntervalMs = Number.isFinite(intervalMs) && intervalMs > 0 ? intervalMs : 10000;
404+
const pollingTimeoutMs = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 240000;
405+
const deadline = Date.now() + pollingTimeoutMs;
406+
407+
while (Date.now() < deadline) {
408+
const response = await fetch(`https://api.github.com/repos/${owner}/${repo}/commits/${commitSha}/status`, {
409+
headers: {
410+
Accept: "application/vnd.github+json",
411+
Authorization: `Bearer ${token}`,
412+
"X-GitHub-Api-Version": "2022-11-28",
413+
},
414+
});
415+
416+
if (!response.ok) {
417+
const body = await response.text();
418+
throw new Error(`Failed to fetch commit statuses from GitHub (${response.status}): ${body}`);
419+
}
420+
421+
const data = await response.json();
422+
const statuses = Array.isArray(data?.statuses) ? data.statuses : [];
423+
const urlStatus =
424+
statuses.find((status) => typeof status?.target_url === "string" && status.target_url.includes("vercel.app")) ??
425+
statuses.find(
426+
(status) =>
427+
typeof status?.context === "string" &&
428+
status.context.toLowerCase().includes("vercel") &&
429+
typeof status?.target_url === "string",
430+
);
431+
432+
if (urlStatus?.target_url) {
433+
return urlStatus.target_url;
434+
}
435+
436+
await sleep(pollingIntervalMs);
437+
}
438+
439+
return "";
440+
};
441+
233442
const applyPatch = (patch) => {
234443
if (!patch.trim()) {
235444
return false;
@@ -307,6 +516,8 @@ const main = async () => {
307516
const baseBranch = process.env.AI_INSPECTOR_BASE_BRANCH || "main";
308517
const collectionName = process.env.AI_INSPECTOR_FIRESTORE_COLLECTION || "aiInspectorTasks";
309518

519+
assertCleanWorkingTree();
520+
310521
if (!owner || !repoName) {
311522
throw new Error(`Invalid GITHUB_REPOSITORY: ${repo}`);
312523
}
@@ -341,7 +552,7 @@ const main = async () => {
341552
fs.writeFileSync(filePath, buildTaskMarkdown(taskId, task), "utf8");
342553
runGit(["add", filePath]);
343554

344-
const aiResult = await requestPatchFromAiEndpoint({
555+
const aiResult = await resolveAiResult({
345556
taskId,
346557
task,
347558
branchName,
@@ -350,12 +561,19 @@ const main = async () => {
350561
});
351562
const patchApplied = applyPatch(aiResult.patch);
352563

353-
const hasChanges = runGitOutput(["status", "--porcelain"]).trim().length > 0;
354-
if (!hasChanges) {
564+
runGit(["add", "-A"]);
565+
const changedFiles = listChangedFiles();
566+
if (changedFiles.length === 0) {
355567
throw new Error("No changes to commit after AI inspector processing.");
356568
}
357569

358-
const commitMessage = patchApplied
570+
const hasRealCodeChange = changedFiles.some((file) => file !== filePath);
571+
if (!hasRealCodeChange) {
572+
throw new Error("AI result did not include real code changes outside the task metadata file.");
573+
}
574+
575+
const isRealAiApply = patchApplied || aiResult.appliedBy === "local-codex";
576+
const commitMessage = isRealAiApply
359577
? `feat(ai-inspector): apply task ${taskId}`
360578
: `chore(ai-inspector): capture task ${taskId}`;
361579
runGit(["commit", "-m", commitMessage]);
@@ -387,7 +605,16 @@ const main = async () => {
387605
}));
388606

389607
const prUrl = pr.html_url;
390-
const previewUrl = getPreviewUrl(branchName);
608+
const commitSha = runGitOutput(["rev-parse", "HEAD"]).trim();
609+
let previewUrl = getPreviewUrl(branchName);
610+
try {
611+
previewUrl =
612+
(await getPreviewUrlFromVercel(branchName)) ||
613+
(await getPreviewUrlFromGitHubCommitStatus(githubToken, owner, repoName, commitSha)) ||
614+
previewUrl;
615+
} catch (previewError) {
616+
console.error(`Task ${taskId} preview URL resolution failed`, previewError);
617+
}
391618

392619
await taskRef.update({
393620
status: "completed",

README.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,15 +112,29 @@ Admin 계정일 때 좌측 하단에 AI 인스펙터 플로팅 버튼이 노출
112112
- `AI_INSPECTOR_BASE_BRANCH` (optional, default: `main`)
113113
- `GITHUB_TOKEN` or `GH_TOKEN` (required for PR 생성)
114114
- `GITHUB_REPOSITORY` (optional, 미지정 시 `git remote origin`에서 자동 추론)
115-
- `AI_INSPECTOR_PATCH_ENDPOINT` (optional: AI patch 생성 endpoint)
115+
- `AI_INSPECTOR_PATCH_ENDPOINT` (optional: external AI patch 생성 endpoint)
116116
- `AI_INSPECTOR_PATCH_API_KEY` (optional)
117+
- `AI_INSPECTOR_LOCAL_CODEX_ENABLED` (optional, default: `true`)
118+
- `AI_INSPECTOR_CODEX_MODEL` (optional, local codex 모델 고정)
117119
- `AI_INSPECTOR_PREVIEW_URL_TEMPLATE` (optional, example: `https://your-app-git-{branch}.vercel.app`)
120+
- `VERCEL_TOKEN` (optional, 설정 시 실제 Vercel deployment URL 조회)
121+
- `VERCEL_PROJECT_ID` (optional, `VERCEL_TOKEN`과 함께 필요)
122+
- `VERCEL_TEAM_ID` (optional)
123+
- `AI_INSPECTOR_VERCEL_PREVIEW_TIMEOUT_MS` (optional, default: `240000`)
124+
- `AI_INSPECTOR_VERCEL_PREVIEW_INTERVAL_MS` (optional, default: `10000`)
118125
- `AI_INSPECTOR_DISCORD_WEBHOOK_URL` (optional)
119126
- `AI_INSPECTOR_POLL_INTERVAL_SECONDS` (optional, loop 실행시 기본 900초)
120127

121128
등록 위치:
122129
- 로컬 개발: `apps/web/.env.local`
123130

131+
참고:
132+
- `AI_INSPECTOR_PATCH_ENDPOINT`가 없으면 로컬 `codex exec`로 실제 코드 수정을 시도합니다.
133+
- task 메타 파일만 변경되고 실제 코드 변경이 없으면 워커는 실패 처리합니다.
134+
- 워커 실행 전 git working tree가 clean 상태여야 합니다.
135+
- `VERCEL_TOKEN`/`VERCEL_PROJECT_ID`가 있으면 Vercel API로 실제 Preview URL을 조회합니다.
136+
- 위 값이 없어도 GitHub commit status의 Vercel `target_url`을 폴링해 Preview URL을 찾습니다.
137+
124138
실행:
125139

126140
```bash

0 commit comments

Comments
 (0)