Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 72 additions & 17 deletions .github/scripts/ai-inspector-worker.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,65 @@ const required = (name) => {
return value;
};

const runGit = (args, options = {}) => {
execFileSync("git", args, {
const runCommandOutput = (command, args, options = {}) =>
execFileSync(command, args, {
stdio: "pipe",
encoding: "utf8",
...options,
});
};

const runGitOutput = (args, options = {}) =>
const runGit = (args, options = {}) => {
execFileSync("git", args, {
stdio: "pipe",
encoding: "utf8",
...options,
});
};

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

const resolveGitHubToken = () => {
const envToken = process.env.GITHUB_TOKEN?.trim() || process.env.GH_TOKEN?.trim();
if (envToken) {
return envToken;
}

try {
const ghToken = runCommandOutput("gh", ["auth", "token"]).trim();
if (ghToken) {
return ghToken;
}
} catch {
// Ignore and throw with clear message below.
}

throw new Error("Missing GitHub token. Set GITHUB_TOKEN or GH_TOKEN, or run `gh auth login`.");
};

const resolveGitHubRepository = () => {
const repository = process.env.GITHUB_REPOSITORY?.trim();
if (repository) {
return repository;
}

const remoteUrl = runGitOutput(["remote", "get-url", "origin"]).trim();
const normalizedUrl = remoteUrl.startsWith("git@github.com:")
? remoteUrl.replace("git@github.com:", "https://github.com/")
: remoteUrl;
const parsedUrl = new URL(normalizedUrl);

if (parsedUrl.hostname !== "github.com") {
throw new Error(`Unsupported remote host for origin: ${parsedUrl.hostname}`);
}

const pathname = parsedUrl.pathname.replace(/^\/+/, "").replace(/\.git$/, "");
const [owner, repoName] = pathname.split("/");
if (!owner || !repoName) {
throw new Error(`Failed to infer GITHUB_REPOSITORY from origin remote: ${remoteUrl}`);
}

return `${owner}/${repoName}`;
};

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

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

const githubRequest = async (method, pathName, body) => {
const token = required("GITHUB_TOKEN");
const githubRequest = async (token, method, pathName, body) => {
const response = await fetch(`https://api.github.com${pathName}`, {
method,
headers: {
Expand All @@ -84,8 +128,7 @@ const githubRequest = async (method, pathName, body) => {
return response.json();
};

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

const requestPatchFromAiEndpoint = async ({ taskId, task, branchName }) => {
const requestPatchFromAiEndpoint = async ({ taskId, task, branchName, repository, baseBranch }) => {
const endpoint = process.env.AI_INSPECTOR_PATCH_ENDPOINT;
if (!endpoint) {
return {
Expand All @@ -168,9 +211,9 @@ const requestPatchFromAiEndpoint = async ({ taskId, task, branchName }) => {
body: JSON.stringify({
taskId,
task,
repository: process.env.GITHUB_REPOSITORY,
repository,
branchName,
baseBranch: process.env.AI_INSPECTOR_BASE_BRANCH || "main",
baseBranch,
}),
});

Expand Down Expand Up @@ -215,11 +258,16 @@ const claimQueuedTask = async (db, collectionName) => {
const queued = await db
.collection(collectionName)
.where("status", "==", "queued")
.orderBy("createdAt", "asc")
.limit(10)
.get();
Comment on lines 260 to 262
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Order queued tasks before applying limit

This query now applies limit(10) without any Firestore orderBy, then sorts only the fetched subset in memory, so when more than 10 tasks are queued the worker no longer sees the true oldest tasks. In production backlogs, newer requests can be processed ahead of older ones and older tasks can be delayed indefinitely under sustained load, which is a regression from the previous FIFO behavior.

Useful? React with 👍 / 👎.


for (const doc of queued.docs) {
const orderedDocs = [...queued.docs].sort((a, b) => {
const aMillis = a.get("createdAt")?.toMillis?.() ?? Number.MAX_SAFE_INTEGER;
const bMillis = b.get("createdAt")?.toMillis?.() ?? Number.MAX_SAFE_INTEGER;
return aMillis - bMillis;
});

for (const doc of orderedDocs) {
const taskRef = doc.ref;
const claimedTask = await db.runTransaction(async (transaction) => {
const snapshot = await transaction.get(taskRef);
Expand Down Expand Up @@ -253,7 +301,8 @@ const claimQueuedTask = async (db, collectionName) => {
};

const main = async () => {
const repo = required("GITHUB_REPOSITORY");
const githubToken = resolveGitHubToken();
const repo = resolveGitHubRepository();
const [owner, repoName] = repo.split("/");
const baseBranch = process.env.AI_INSPECTOR_BASE_BRANCH || "main";
const collectionName = process.env.AI_INSPECTOR_FIRESTORE_COLLECTION || "aiInspectorTasks";
Expand Down Expand Up @@ -292,7 +341,13 @@ const main = async () => {
fs.writeFileSync(filePath, buildTaskMarkdown(taskId, task), "utf8");
runGit(["add", filePath]);

const aiResult = await requestPatchFromAiEndpoint({ taskId, task, branchName });
const aiResult = await requestPatchFromAiEndpoint({
taskId,
task,
branchName,
repository: repo,
baseBranch,
});
const patchApplied = applyPatch(aiResult.patch);

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

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

const pr =
existingPr ??
(await githubRequest("POST", `/repos/${owner}/${repoName}/pulls`, {
(await githubRequest(githubToken, "POST", `/repos/${owner}/${repoName}/pulls`, {
title,
head: branchName,
base: baseBranch,
Expand Down
41 changes: 0 additions & 41 deletions .github/workflows/ai-inspector-worker.yml

This file was deleted.

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

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

### GitHub secrets (worker)
### Local worker env

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

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

등록 위치:
- GitHub Repository > Settings > Secrets and variables > Actions > Repository secrets
- 로컬 개발: `apps/web/.env.local`

실행:

```bash
# 단발 실행 (큐에서 1건 클레임 후 종료)
pnpm ai-inspector:worker

# 반복 실행 (기본 15분 주기)
pnpm ai-inspector:worker:loop
```
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
"dev:web": "pnpm --filter @solid-connect/web run dev",
"dev:admin": "pnpm --filter @solid-connect/admin run dev",
"dev:debug": "turbo dev --filter=@solid-connect/web --filter=@solid-connect/admin",
"ai-inspector:worker": "node --env-file-if-exists=apps/web/.env.local .github/scripts/ai-inspector-worker.mjs",
"ai-inspector:worker:loop": "node --env-file-if-exists=apps/web/.env.local scripts/ai-inspector-worker-loop.mjs",
"build": "turbo build",
"sync:bruno": "turbo run sync:bruno",
"lint": "turbo lint",
Expand All @@ -22,6 +24,7 @@
"@biomejs/biome": "^2.3.11",
"@commitlint/cli": "^20.2.0",
"@commitlint/config-conventional": "^20.2.0",
"firebase-admin": "^13.7.0",
"husky": "^9.1.7",
"turbo": "^2.3.0"
},
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

53 changes: 53 additions & 0 deletions scripts/ai-inspector-worker-loop.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { execFileSync } from "node:child_process";
import process from "node:process";

const ONE_SECOND = 1_000;
const defaultIntervalSeconds = 15 * 60;

const parseIntervalSeconds = () => {
const rawValue = process.env.AI_INSPECTOR_POLL_INTERVAL_SECONDS;
if (!rawValue) {
return defaultIntervalSeconds;
}

const value = Number(rawValue);
if (!Number.isFinite(value) || value <= 0) {
throw new Error("AI_INSPECTOR_POLL_INTERVAL_SECONDS must be a positive number.");
}

return value;
};

const runOnce = async () => {
execFileSync("node", [".github/scripts/ai-inspector-worker.mjs"], {
stdio: "inherit",
env: process.env,
});
};

const sleep = async (ms) =>
new Promise((resolve) => {
setTimeout(resolve, ms);
});

const loop = async () => {
const intervalSeconds = parseIntervalSeconds();
while (true) {
const startedAt = Date.now();
try {
await runOnce();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`[ai-inspector-worker-loop] run failed: ${message}`);
}

const elapsed = Date.now() - startedAt;
const waitMs = Math.max(ONE_SECOND, intervalSeconds * ONE_SECOND - elapsed);
await sleep(waitMs);
}
};

loop().catch((error) => {
console.error(error);
process.exit(1);
});
2 changes: 1 addition & 1 deletion turbo.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"@solid-connect/web#build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**"],
"env": ["NODE_ENV", "NEXT_PUBLIC_*"]
"env": ["NODE_ENV", "NEXT_PUBLIC_*", "SENTRY_*", "FIREBASE_*", "AI_INSPECTOR_*"]
},
"sync:bruno": {
"outputs": ["src/apis/**"],
Expand Down
Loading