Skip to content

Commit b5e07e9

Browse files
authored
fix: stabilize ai inspector worker execution (#490)
* chore(turbo): include build env groups for web warnings * fix(worker): avoid firestore composite index for queued tasks * fix(worker): configure git author in action runner * refactor(ai-inspector): run worker locally without github actions
1 parent 6c57cb9 commit b5e07e9

File tree

7 files changed

+151
-63
lines changed

7 files changed

+151
-63
lines changed

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

Lines changed: 72 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -14,20 +14,65 @@ const required = (name) => {
1414
return value;
1515
};
1616

17-
const runGit = (args, options = {}) => {
18-
execFileSync("git", args, {
17+
const runCommandOutput = (command, args, options = {}) =>
18+
execFileSync(command, args, {
1919
stdio: "pipe",
2020
encoding: "utf8",
2121
...options,
2222
});
23-
};
2423

25-
const runGitOutput = (args, options = {}) =>
24+
const runGit = (args, options = {}) => {
2625
execFileSync("git", args, {
2726
stdio: "pipe",
2827
encoding: "utf8",
2928
...options,
3029
});
30+
};
31+
32+
const runGitOutput = (args, options = {}) => runCommandOutput("git", args, options);
33+
34+
const resolveGitHubToken = () => {
35+
const envToken = process.env.GITHUB_TOKEN?.trim() || process.env.GH_TOKEN?.trim();
36+
if (envToken) {
37+
return envToken;
38+
}
39+
40+
try {
41+
const ghToken = runCommandOutput("gh", ["auth", "token"]).trim();
42+
if (ghToken) {
43+
return ghToken;
44+
}
45+
} catch {
46+
// Ignore and throw with clear message below.
47+
}
48+
49+
throw new Error("Missing GitHub token. Set GITHUB_TOKEN or GH_TOKEN, or run `gh auth login`.");
50+
};
51+
52+
const resolveGitHubRepository = () => {
53+
const repository = process.env.GITHUB_REPOSITORY?.trim();
54+
if (repository) {
55+
return repository;
56+
}
57+
58+
const remoteUrl = runGitOutput(["remote", "get-url", "origin"]).trim();
59+
const normalizedUrl = remoteUrl.startsWith("git@github.com:")
60+
? remoteUrl.replace("git@github.com:", "https://github.com/")
61+
: remoteUrl;
62+
const parsedUrl = new URL(normalizedUrl);
63+
64+
if (parsedUrl.hostname !== "github.com") {
65+
throw new Error(`Unsupported remote host for origin: ${parsedUrl.hostname}`);
66+
}
67+
68+
const pathname = parsedUrl.pathname.replace(/^\/+/, "").replace(/\.git$/, "");
69+
const [owner, repoName] = pathname.split("/");
70+
if (!owner || !repoName) {
71+
throw new Error(`Failed to infer GITHUB_REPOSITORY from origin remote: ${remoteUrl}`);
72+
}
73+
74+
return `${owner}/${repoName}`;
75+
};
3176

3277
const escapeMarkdown = (value) => String(value ?? "").replace(/`/g, "\\`");
3378

@@ -63,8 +108,7 @@ const getPreviewUrl = (branchName) => {
63108
return template.replaceAll("{branch}", branchName.replaceAll("/", "-"));
64109
};
65110

66-
const githubRequest = async (method, pathName, body) => {
67-
const token = required("GITHUB_TOKEN");
111+
const githubRequest = async (token, method, pathName, body) => {
68112
const response = await fetch(`https://api.github.com${pathName}`, {
69113
method,
70114
headers: {
@@ -84,8 +128,7 @@ const githubRequest = async (method, pathName, body) => {
84128
return response.json();
85129
};
86130

87-
const findOpenPrByHead = async (owner, repo, branchName) => {
88-
const token = required("GITHUB_TOKEN");
131+
const findOpenPrByHead = async (token, owner, repo, branchName) => {
89132
const response = await fetch(
90133
`https://api.github.com/repos/${owner}/${repo}/pulls?state=open&head=${owner}:${encodeURIComponent(branchName)}`,
91134
{
@@ -149,7 +192,7 @@ const sendDiscordNotification = async ({ taskId, prUrl, previewUrl, instruction
149192
}
150193
};
151194

152-
const requestPatchFromAiEndpoint = async ({ taskId, task, branchName }) => {
195+
const requestPatchFromAiEndpoint = async ({ taskId, task, branchName, repository, baseBranch }) => {
153196
const endpoint = process.env.AI_INSPECTOR_PATCH_ENDPOINT;
154197
if (!endpoint) {
155198
return {
@@ -168,9 +211,9 @@ const requestPatchFromAiEndpoint = async ({ taskId, task, branchName }) => {
168211
body: JSON.stringify({
169212
taskId,
170213
task,
171-
repository: process.env.GITHUB_REPOSITORY,
214+
repository,
172215
branchName,
173-
baseBranch: process.env.AI_INSPECTOR_BASE_BRANCH || "main",
216+
baseBranch,
174217
}),
175218
});
176219

@@ -215,11 +258,16 @@ const claimQueuedTask = async (db, collectionName) => {
215258
const queued = await db
216259
.collection(collectionName)
217260
.where("status", "==", "queued")
218-
.orderBy("createdAt", "asc")
219261
.limit(10)
220262
.get();
221263

222-
for (const doc of queued.docs) {
264+
const orderedDocs = [...queued.docs].sort((a, b) => {
265+
const aMillis = a.get("createdAt")?.toMillis?.() ?? Number.MAX_SAFE_INTEGER;
266+
const bMillis = b.get("createdAt")?.toMillis?.() ?? Number.MAX_SAFE_INTEGER;
267+
return aMillis - bMillis;
268+
});
269+
270+
for (const doc of orderedDocs) {
223271
const taskRef = doc.ref;
224272
const claimedTask = await db.runTransaction(async (transaction) => {
225273
const snapshot = await transaction.get(taskRef);
@@ -253,7 +301,8 @@ const claimQueuedTask = async (db, collectionName) => {
253301
};
254302

255303
const main = async () => {
256-
const repo = required("GITHUB_REPOSITORY");
304+
const githubToken = resolveGitHubToken();
305+
const repo = resolveGitHubRepository();
257306
const [owner, repoName] = repo.split("/");
258307
const baseBranch = process.env.AI_INSPECTOR_BASE_BRANCH || "main";
259308
const collectionName = process.env.AI_INSPECTOR_FIRESTORE_COLLECTION || "aiInspectorTasks";
@@ -292,7 +341,13 @@ const main = async () => {
292341
fs.writeFileSync(filePath, buildTaskMarkdown(taskId, task), "utf8");
293342
runGit(["add", filePath]);
294343

295-
const aiResult = await requestPatchFromAiEndpoint({ taskId, task, branchName });
344+
const aiResult = await requestPatchFromAiEndpoint({
345+
taskId,
346+
task,
347+
branchName,
348+
repository: repo,
349+
baseBranch,
350+
});
296351
const patchApplied = applyPatch(aiResult.patch);
297352

298353
const hasChanges = runGitOutput(["status", "--porcelain"]).trim().length > 0;
@@ -306,7 +361,7 @@ const main = async () => {
306361
runGit(["commit", "-m", commitMessage]);
307362
runGit(["push", "-u", "origin", branchName]);
308363

309-
const existingPr = await findOpenPrByHead(owner, repoName, branchName);
364+
const existingPr = await findOpenPrByHead(githubToken, owner, repoName, branchName);
310365
const title =
311366
aiResult.title || `[AI Inspector] ${String(task.instruction ?? "UI update request").slice(0, 72)}`.trim();
312367
const body = [
@@ -324,7 +379,7 @@ const main = async () => {
324379

325380
const pr =
326381
existingPr ??
327-
(await githubRequest("POST", `/repos/${owner}/${repoName}/pulls`, {
382+
(await githubRequest(githubToken, "POST", `/repos/${owner}/${repoName}/pulls`, {
328383
title,
329384
head: branchName,
330385
base: baseBranch,

.github/workflows/ai-inspector-worker.yml

Lines changed: 0 additions & 41 deletions
This file was deleted.

README.md

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ Admin 계정일 때 좌측 하단에 AI 인스펙터 플로팅 버튼이 노출
7373
2. 자연어 수정 요청 입력
7474
3. `POST /api/ai-inspector-requests` 호출 (클라이언트는 Firebase 직접 접근하지 않음)
7575
4. API 서버가 관리자 권한을 검증하고 Firestore에 `queued` 상태로 저장
76-
5. GitHub Actions 크론(`.github/workflows/ai-inspector-worker.yml`)이 `queued -> processing`으로 작업 클레임 후 처리
76+
5. 로컬 워커(`pnpm ai-inspector:worker` 또는 `pnpm ai-inspector:worker:loop`)가 `queued -> processing`으로 작업 클레임 후 처리
7777
6. 작업 결과를 `completed` 또는 `failed`로 업데이트하고 PR/프리뷰 링크를 Discord webhook으로 전송
7878

7979
### Shared package
@@ -101,17 +101,32 @@ Admin 계정일 때 좌측 하단에 AI 인스펙터 플로팅 버튼이 노출
101101
- 로컬 개발: `apps/web/.env.local`
102102
- Vercel 배포: Vercel Project > Settings > Environment Variables (Preview/Production 둘 다)
103103

104-
### GitHub secrets (worker)
104+
### Local worker env
105105

106-
`.github/workflows/ai-inspector-worker.yml`에서 사용합니다.
106+
로컬 워커는 아래 환경변수를 사용합니다.
107107

108108
- `AI_INSPECTOR_FIREBASE_PROJECT_ID`
109109
- `AI_INSPECTOR_FIREBASE_CLIENT_EMAIL`
110110
- `AI_INSPECTOR_FIREBASE_PRIVATE_KEY`
111+
- `AI_INSPECTOR_FIRESTORE_COLLECTION` (optional, default: `aiInspectorTasks`)
112+
- `AI_INSPECTOR_BASE_BRANCH` (optional, default: `main`)
113+
- `GITHUB_TOKEN` or `GH_TOKEN` (required for PR 생성)
114+
- `GITHUB_REPOSITORY` (optional, 미지정 시 `git remote origin`에서 자동 추론)
111115
- `AI_INSPECTOR_PATCH_ENDPOINT` (optional: AI patch 생성 endpoint)
112116
- `AI_INSPECTOR_PATCH_API_KEY` (optional)
113117
- `AI_INSPECTOR_PREVIEW_URL_TEMPLATE` (optional, example: `https://your-app-git-{branch}.vercel.app`)
114118
- `AI_INSPECTOR_DISCORD_WEBHOOK_URL` (optional)
119+
- `AI_INSPECTOR_POLL_INTERVAL_SECONDS` (optional, loop 실행시 기본 900초)
115120

116121
등록 위치:
117-
- GitHub Repository > Settings > Secrets and variables > Actions > Repository secrets
122+
- 로컬 개발: `apps/web/.env.local`
123+
124+
실행:
125+
126+
```bash
127+
# 단발 실행 (큐에서 1건 클레임 후 종료)
128+
pnpm ai-inspector:worker
129+
130+
# 반복 실행 (기본 15분 주기)
131+
pnpm ai-inspector:worker:loop
132+
```

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
"dev:web": "pnpm --filter @solid-connect/web run dev",
77
"dev:admin": "pnpm --filter @solid-connect/admin run dev",
88
"dev:debug": "turbo dev --filter=@solid-connect/web --filter=@solid-connect/admin",
9+
"ai-inspector:worker": "node --env-file-if-exists=apps/web/.env.local .github/scripts/ai-inspector-worker.mjs",
10+
"ai-inspector:worker:loop": "node --env-file-if-exists=apps/web/.env.local scripts/ai-inspector-worker-loop.mjs",
911
"build": "turbo build",
1012
"sync:bruno": "turbo run sync:bruno",
1113
"lint": "turbo lint",
@@ -22,6 +24,7 @@
2224
"@biomejs/biome": "^2.3.11",
2325
"@commitlint/cli": "^20.2.0",
2426
"@commitlint/config-conventional": "^20.2.0",
27+
"firebase-admin": "^13.7.0",
2528
"husky": "^9.1.7",
2629
"turbo": "^2.3.0"
2730
},

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { execFileSync } from "node:child_process";
2+
import process from "node:process";
3+
4+
const ONE_SECOND = 1_000;
5+
const defaultIntervalSeconds = 15 * 60;
6+
7+
const parseIntervalSeconds = () => {
8+
const rawValue = process.env.AI_INSPECTOR_POLL_INTERVAL_SECONDS;
9+
if (!rawValue) {
10+
return defaultIntervalSeconds;
11+
}
12+
13+
const value = Number(rawValue);
14+
if (!Number.isFinite(value) || value <= 0) {
15+
throw new Error("AI_INSPECTOR_POLL_INTERVAL_SECONDS must be a positive number.");
16+
}
17+
18+
return value;
19+
};
20+
21+
const runOnce = async () => {
22+
execFileSync("node", [".github/scripts/ai-inspector-worker.mjs"], {
23+
stdio: "inherit",
24+
env: process.env,
25+
});
26+
};
27+
28+
const sleep = async (ms) =>
29+
new Promise((resolve) => {
30+
setTimeout(resolve, ms);
31+
});
32+
33+
const loop = async () => {
34+
const intervalSeconds = parseIntervalSeconds();
35+
while (true) {
36+
const startedAt = Date.now();
37+
try {
38+
await runOnce();
39+
} catch (error) {
40+
const message = error instanceof Error ? error.message : String(error);
41+
console.error(`[ai-inspector-worker-loop] run failed: ${message}`);
42+
}
43+
44+
const elapsed = Date.now() - startedAt;
45+
const waitMs = Math.max(ONE_SECOND, intervalSeconds * ONE_SECOND - elapsed);
46+
await sleep(waitMs);
47+
}
48+
};
49+
50+
loop().catch((error) => {
51+
console.error(error);
52+
process.exit(1);
53+
});

turbo.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
"@solid-connect/web#build": {
1111
"dependsOn": ["^build"],
1212
"outputs": [".next/**", "!.next/cache/**"],
13-
"env": ["NODE_ENV", "NEXT_PUBLIC_*"]
13+
"env": ["NODE_ENV", "NEXT_PUBLIC_*", "SENTRY_*", "FIREBASE_*", "AI_INSPECTOR_*"]
1414
},
1515
"sync:bruno": {
1616
"outputs": ["src/apis/**"],

0 commit comments

Comments
 (0)