@@ -31,6 +31,19 @@ const runGit = (args, options = {}) => {
3131
3232const 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+
3447const 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
7992const 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+
81114const 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
195228const 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+
233442const 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" ,
0 commit comments