fireqa-agent 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2 @@
1
+ import { ConfigStore } from "../config/store.js";
2
+ export declare function loginWithApiKey(store: ConfigStore): Promise<void>;
@@ -0,0 +1,39 @@
1
+ import readline from "readline";
2
+ import os from "os";
3
+ export async function loginWithApiKey(store) {
4
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
5
+ const token = await new Promise((resolve) => {
6
+ rl.question("API Key를 입력하세요 (fqa_...): ", (answer) => {
7
+ rl.close();
8
+ resolve(answer.trim());
9
+ });
10
+ });
11
+ if (!token.startsWith("fqa_")) {
12
+ console.error("올바른 API Key 형식이 아닙니다. fqa_로 시작해야 합니다.");
13
+ process.exit(1);
14
+ }
15
+ const config = store.load();
16
+ const res = await fetch(`${config.server}/api/agent/connections`, {
17
+ method: "POST",
18
+ headers: {
19
+ "Content-Type": "application/json",
20
+ Authorization: `Bearer ${token}`,
21
+ },
22
+ body: JSON.stringify({
23
+ name: `${process.env.USER ?? "agent"}@${os.hostname()}`,
24
+ metadata: {
25
+ cli: config.cli,
26
+ os: process.platform,
27
+ nodeVersion: process.version,
28
+ },
29
+ }),
30
+ });
31
+ if (!res.ok) {
32
+ const err = await res.json().catch(() => ({ error: "알 수 없는 오류" }));
33
+ console.error(`인증 실패: ${err.error}`);
34
+ process.exit(1);
35
+ }
36
+ store.setToken(token);
37
+ const data = await res.json();
38
+ console.log(`인증 성공! 에이전트 "${data.name}" 등록됨.`);
39
+ }
@@ -0,0 +1,2 @@
1
+ import { ConfigStore } from "../config/store.js";
2
+ export declare function loginWithOAuth(store: ConfigStore): Promise<void>;
@@ -0,0 +1,63 @@
1
+ import { exec } from "child_process";
2
+ const POLL_INTERVAL = 3000;
3
+ const MAX_WAIT_MS = 5 * 60 * 1000;
4
+ export async function loginWithOAuth(store) {
5
+ const config = store.load();
6
+ // Step 1: Create device auth
7
+ const createRes = await fetch(`${config.server}/api/auth/device`, {
8
+ method: "POST",
9
+ headers: { "Content-Type": "application/json" },
10
+ body: JSON.stringify({ action: "create" }),
11
+ });
12
+ if (!createRes.ok) {
13
+ console.error("인증 요청 생성에 실패했습니다.");
14
+ process.exit(1);
15
+ }
16
+ const { deviceCode } = (await createRes.json());
17
+ const verificationUrl = `${config.server}/auth/device?code=${deviceCode}&source=agent`;
18
+ console.log("\nFireQA 인증 페이지를 브라우저에서 열고 있습니다...");
19
+ openBrowser(verificationUrl);
20
+ console.log(`\n자동으로 열리지 않으면 아래 URL을 직접 여세요:\n ${verificationUrl}\n`);
21
+ console.log("FireQA 계정으로 로그인 후 에이전트 연결을 승인하세요.");
22
+ console.log("\n승인을 기다리는 중... (최대 5분)\n");
23
+ const deadline = Date.now() + MAX_WAIT_MS;
24
+ while (Date.now() < deadline) {
25
+ await sleep(POLL_INTERVAL);
26
+ try {
27
+ const pollRes = await fetch(`${config.server}/api/auth/device?code=${deviceCode}`);
28
+ if (pollRes.status === 202) {
29
+ // Still pending
30
+ process.stdout.write(".");
31
+ continue;
32
+ }
33
+ if (pollRes.ok) {
34
+ const data = (await pollRes.json());
35
+ if (data.status === "approved" && data.token) {
36
+ store.setToken(data.token);
37
+ console.log("\n\n인증 성공!");
38
+ if (data.email) {
39
+ console.log(`계정: ${data.email}`);
40
+ }
41
+ return;
42
+ }
43
+ }
44
+ // Error or expired
45
+ console.error("\n인증이 만료되었거나 거부되었습니다. 다시 시도해주세요.");
46
+ process.exit(1);
47
+ }
48
+ catch {
49
+ // Network error, retry
50
+ }
51
+ }
52
+ console.error("\n인증 시간이 초과되었습니다. 다시 시도해주세요.");
53
+ process.exit(1);
54
+ }
55
+ function openBrowser(url) {
56
+ const cmd = process.platform === "darwin" ? `open "${url}"` :
57
+ process.platform === "win32" ? `start "" "${url}"` :
58
+ `xdg-open "${url}"`;
59
+ exec(cmd, () => { }); // 실패해도 무시 (수동 URL 안내가 폴백)
60
+ }
61
+ function sleep(ms) {
62
+ return new Promise((resolve) => setTimeout(resolve, ms));
63
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { ConfigStore } from "./config/store.js";
4
+ import { loginWithApiKey } from "./auth/api-key.js";
5
+ import { CLI_ADAPTERS } from "./runner/adapters.js";
6
+ const program = new Command();
7
+ const store = new ConfigStore();
8
+ program
9
+ .name("fireqa-agent")
10
+ .description("FireQA Agent — connect your AI CLI to FireQA")
11
+ .version("0.1.0");
12
+ program
13
+ .command("login")
14
+ .description("FireQA에 인증 (기본: OAuth, --api-key: API Key 사용)")
15
+ .option("--api-key", "API Key로 직접 인증")
16
+ .action(async (options) => {
17
+ if (options.apiKey) {
18
+ await loginWithApiKey(store);
19
+ }
20
+ else {
21
+ const { loginWithOAuth } = await import("./auth/oauth.js");
22
+ await loginWithOAuth(store);
23
+ }
24
+ });
25
+ program
26
+ .command("config")
27
+ .description("현재 설정 표시")
28
+ .action(() => {
29
+ const config = store.load();
30
+ const display = {
31
+ ...config,
32
+ auth: config.auth
33
+ ? { token: config.auth.token.slice(0, 12) + "..." }
34
+ : undefined,
35
+ };
36
+ console.log(JSON.stringify(display, null, 2));
37
+ });
38
+ program
39
+ .command("config:set <key> <value>")
40
+ .description("설정값 변경 (cli, server, pollingIntervalMs)")
41
+ .action((key, value) => {
42
+ const numericKeys = ["pollingIntervalMs", "maxConcurrentTasks"];
43
+ const parsed = numericKeys.includes(key) ? parseInt(value, 10) : value;
44
+ store.save({ [key]: parsed });
45
+ console.log(`${key} = ${parsed}`);
46
+ });
47
+ program
48
+ .command("start")
49
+ .description("에이전트 시작 — FireQA 작업 큐를 폴링하고 CLI를 실행")
50
+ .option("--cli-type <type>", "사용할 LLM CLI 타입 (claude | codex | gemini)", "claude")
51
+ .action(async (options) => {
52
+ const cliType = Object.keys(CLI_ADAPTERS).find(v => v === options.cliType) ?? "claude";
53
+ store.save({ cliType, cli: CLI_ADAPTERS[cliType].defaultCommand });
54
+ const { startAgent } = await import("./runner/task-poller.js");
55
+ await startAgent(store);
56
+ });
57
+ program.parse();
@@ -0,0 +1,20 @@
1
+ import type { CliType } from "../runner/adapters.js";
2
+ export type AgentMode = "self_hosted" | "hosted";
3
+ export type AgentConfig = {
4
+ server: string;
5
+ auth?: {
6
+ token: string;
7
+ };
8
+ cliType: CliType;
9
+ cli: string;
10
+ pollingIntervalMs: number;
11
+ maxConcurrentTasks: number;
12
+ mode: AgentMode;
13
+ };
14
+ export declare class ConfigStore {
15
+ private configPath;
16
+ constructor(configDir?: string);
17
+ load(): AgentConfig;
18
+ save(partial: Partial<AgentConfig>): void;
19
+ setToken(token: string): void;
20
+ }
@@ -0,0 +1,53 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import os from "os";
4
+ const DEFAULTS = {
5
+ server: "https://fireqa.vercel.app",
6
+ cliType: "claude",
7
+ cli: "claude",
8
+ pollingIntervalMs: 3000,
9
+ maxConcurrentTasks: 1,
10
+ mode: "self_hosted",
11
+ };
12
+ export class ConfigStore {
13
+ configPath;
14
+ constructor(configDir) {
15
+ const dir = configDir ?? path.join(os.homedir(), ".fireqa");
16
+ this.configPath = path.join(dir, "config.json");
17
+ }
18
+ load() {
19
+ // 환경변수 우선 (hosted 모드에서 Docker ENV로 주입)
20
+ const envOverrides = {};
21
+ if (process.env.FIREQA_SERVER)
22
+ envOverrides.server = process.env.FIREQA_SERVER;
23
+ if (process.env.FIREQA_TOKEN)
24
+ envOverrides.auth = { token: process.env.FIREQA_TOKEN };
25
+ if (process.env.FIREQA_CLI)
26
+ envOverrides.cli = process.env.FIREQA_CLI;
27
+ if (process.env.FIREQA_MODE)
28
+ envOverrides.mode = process.env.FIREQA_MODE;
29
+ if (process.env.FIREQA_POLLING_INTERVAL)
30
+ envOverrides.pollingIntervalMs = parseInt(process.env.FIREQA_POLLING_INTERVAL, 10);
31
+ let fileConfig = {};
32
+ try {
33
+ const raw = fs.readFileSync(this.configPath, "utf-8");
34
+ fileConfig = JSON.parse(raw);
35
+ }
36
+ catch {
37
+ // 파일 없으면 무시
38
+ }
39
+ return { ...DEFAULTS, ...fileConfig, ...envOverrides };
40
+ }
41
+ save(partial) {
42
+ const current = this.load();
43
+ const merged = { ...current, ...partial };
44
+ const dir = path.dirname(this.configPath);
45
+ if (!fs.existsSync(dir)) {
46
+ fs.mkdirSync(dir, { recursive: true });
47
+ }
48
+ fs.writeFileSync(this.configPath, JSON.stringify(merged, null, 2));
49
+ }
50
+ setToken(token) {
51
+ this.save({ auth: { token } });
52
+ }
53
+ }
@@ -0,0 +1,23 @@
1
+ import type { AgentConfig } from "../config/store.js";
2
+ import type { ParsedChunk } from "../runner/output-parser.js";
3
+ export declare class ApiClient {
4
+ private baseUrl;
5
+ private token;
6
+ constructor(config: AgentConfig);
7
+ private headers;
8
+ registerConnection(name: string, metadata: Record<string, unknown>): Promise<{
9
+ id: string;
10
+ }>;
11
+ heartbeat(connectionId: string): Promise<{
12
+ cancelledTaskIds: string[];
13
+ }>;
14
+ disconnect(connectionId: string): Promise<void>;
15
+ getNextTask(connectionId: string): Promise<unknown | null>;
16
+ updateTaskStatus(taskId: string, status: string, extra?: {
17
+ errorMessage?: string;
18
+ sessionId?: string;
19
+ }): Promise<void>;
20
+ sendOutput(taskId: string, chunks: ParsedChunk[]): Promise<void>;
21
+ flushPendingOutputs(): Promise<void>;
22
+ sendResult(taskId: string, result: unknown, sessionId?: string): Promise<void>;
23
+ }
@@ -0,0 +1,147 @@
1
+ import fs from "fs";
2
+ import fsp from "fs/promises";
3
+ import path from "path";
4
+ import os from "os";
5
+ // 전송 실패 시 임시 저장할 디렉토리
6
+ const PENDING_DIR = path.join(os.homedir(), ".fireqa", "pending");
7
+ // 지수 백오프 재시도 후 실패 시 디스크에 저장하고 에러를 throw
8
+ async function retrySendOutput(url, headers, body, taskId, chunks) {
9
+ const delays = [1000, 2000, 4000];
10
+ let lastError = null;
11
+ for (let attempt = 0; attempt <= delays.length; attempt++) {
12
+ if (attempt > 0) {
13
+ await sleep(delays[attempt - 1]);
14
+ }
15
+ try {
16
+ const res = await fetch(url, { method: "POST", headers, body });
17
+ if (res.ok)
18
+ return;
19
+ lastError = new Error(`HTTP ${res.status}`);
20
+ }
21
+ catch (err) {
22
+ lastError = err instanceof Error ? err : new Error(String(err));
23
+ }
24
+ }
25
+ // 모든 재시도 실패 — 디스크에 저장
26
+ try {
27
+ fs.mkdirSync(PENDING_DIR, { recursive: true });
28
+ const filename = `${taskId}-${Date.now()}.json`;
29
+ fs.writeFileSync(path.join(PENDING_DIR, filename), JSON.stringify({ taskId, chunks, timestamp: Date.now() }));
30
+ }
31
+ catch {
32
+ // 디스크 쓰기 실패는 무시
33
+ }
34
+ throw lastError ?? new Error("sendOutput failed");
35
+ }
36
+ function sleep(ms) {
37
+ return new Promise((resolve) => setTimeout(resolve, ms));
38
+ }
39
+ export class ApiClient {
40
+ baseUrl;
41
+ token;
42
+ constructor(config) {
43
+ this.baseUrl = config.server;
44
+ this.token = config.auth?.token ?? "";
45
+ }
46
+ headers() {
47
+ return {
48
+ "Content-Type": "application/json",
49
+ Authorization: `Bearer ${this.token}`,
50
+ };
51
+ }
52
+ async registerConnection(name, metadata) {
53
+ const res = await fetch(`${this.baseUrl}/api/agent/connections`, {
54
+ method: "POST",
55
+ headers: this.headers(),
56
+ body: JSON.stringify({ name, metadata }),
57
+ });
58
+ if (!res.ok)
59
+ throw new Error(`등록 실패: ${res.status}`);
60
+ return res.json();
61
+ }
62
+ async heartbeat(connectionId) {
63
+ const res = await fetch(`${this.baseUrl}/api/agent/connections/${connectionId}`, {
64
+ method: "PUT",
65
+ headers: this.headers(),
66
+ body: JSON.stringify({}),
67
+ });
68
+ if (!res.ok)
69
+ throw new Error(`heartbeat 실패: ${res.status}`);
70
+ return res.json();
71
+ }
72
+ async disconnect(connectionId) {
73
+ await fetch(`${this.baseUrl}/api/agent/connections/${connectionId}`, {
74
+ method: "DELETE",
75
+ headers: this.headers(),
76
+ });
77
+ }
78
+ async getNextTask(connectionId) {
79
+ const res = await fetch(`${this.baseUrl}/api/agent/tasks/next?connectionId=${connectionId}`, { headers: this.headers() });
80
+ if (!res.ok)
81
+ throw new Error(`작업 수령 실패: ${res.status}`);
82
+ const data = await res.json();
83
+ return data.task;
84
+ }
85
+ async updateTaskStatus(taskId, status, extra) {
86
+ const res = await fetch(`${this.baseUrl}/api/agent/tasks/${taskId}/status`, {
87
+ method: "PUT",
88
+ headers: this.headers(),
89
+ body: JSON.stringify({ status, ...extra }),
90
+ });
91
+ if (!res.ok)
92
+ throw new Error(`상태 변경 실패: ${res.status}`);
93
+ }
94
+ async sendOutput(taskId, chunks) {
95
+ const timestamped = chunks.map((c) => ({
96
+ ...c,
97
+ timestamp: new Date().toISOString(),
98
+ }));
99
+ const url = `${this.baseUrl}/api/agent/tasks/${taskId}/output`;
100
+ const body = JSON.stringify({ chunks: timestamped });
101
+ // 실패 시 1s/2s/4s 재시도, 모두 실패하면 ~/.fireqa/pending/ 에 저장
102
+ await retrySendOutput(url, this.headers(), body, taskId, timestamped);
103
+ }
104
+ // 에이전트 시작 시 미전송 파일을 재전송
105
+ async flushPendingOutputs() {
106
+ let entries;
107
+ try {
108
+ entries = (await fsp.readdir(PENDING_DIR)).filter((f) => f.endsWith(".json"));
109
+ }
110
+ catch {
111
+ return; // 디렉토리 없으면 종료
112
+ }
113
+ const EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
114
+ const now = Date.now();
115
+ for (const file of entries) {
116
+ const filePath = path.join(PENDING_DIR, file);
117
+ try {
118
+ const raw = await fsp.readFile(filePath, "utf-8");
119
+ const data = JSON.parse(raw);
120
+ if (data.timestamp && now - data.timestamp > EXPIRY_MS) {
121
+ await fsp.unlink(filePath);
122
+ continue;
123
+ }
124
+ const res = await fetch(`${this.baseUrl}/api/agent/tasks/${data.taskId}/output`, {
125
+ method: "POST",
126
+ headers: this.headers(),
127
+ body: JSON.stringify({ chunks: data.chunks }),
128
+ });
129
+ if (res.ok || res.status === 404) {
130
+ await fsp.unlink(filePath);
131
+ }
132
+ }
133
+ catch {
134
+ // 실패 시 다음 시작 때 재시도
135
+ }
136
+ }
137
+ }
138
+ async sendResult(taskId, result, sessionId) {
139
+ const res = await fetch(`${this.baseUrl}/api/agent/tasks/${taskId}/result`, {
140
+ method: "POST",
141
+ headers: this.headers(),
142
+ body: JSON.stringify({ result, sessionId }),
143
+ });
144
+ if (!res.ok)
145
+ throw new Error(`결과 전송 실패: ${res.status}`);
146
+ }
147
+ }
@@ -0,0 +1,20 @@
1
+ import { type ParsedChunk } from "./output-parser.js";
2
+ export type CliType = "claude" | "codex" | "gemini";
3
+ export declare const CLI_ADAPTERS: Record<CliType, {
4
+ label: string;
5
+ defaultCommand: string;
6
+ installUrl: string;
7
+ loginHint: string;
8
+ }>;
9
+ export type SpawnResult = {
10
+ exitCode: number;
11
+ chunks: ParsedChunk[];
12
+ fullOutput: string;
13
+ };
14
+ export declare function spawnCli(cliType: CliType, command: string, prompt: string, options?: {
15
+ sessionId?: string;
16
+ mcpTools?: string[];
17
+ onChunk?: (chunk: ParsedChunk) => void;
18
+ signal?: AbortSignal;
19
+ env?: Record<string, string>;
20
+ }): Promise<SpawnResult>;
@@ -0,0 +1,119 @@
1
+ import { spawn } from "child_process";
2
+ import { parseStreamJsonLine } from "./output-parser.js";
3
+ export const CLI_ADAPTERS = {
4
+ claude: {
5
+ label: "Claude Code",
6
+ defaultCommand: "claude",
7
+ installUrl: "https://docs.anthropic.com/claude-code",
8
+ loginHint: "claude auth login",
9
+ },
10
+ codex: {
11
+ label: "Codex CLI",
12
+ defaultCommand: "codex",
13
+ installUrl: "https://github.com/openai/codex",
14
+ loginHint: "codex login",
15
+ },
16
+ gemini: {
17
+ label: "Gemini CLI",
18
+ defaultCommand: "gemini",
19
+ installUrl: "https://github.com/google-gemini/gemini-cli",
20
+ loginHint: "gemini auth",
21
+ },
22
+ };
23
+ function buildArgs(cliType, prompt, sessionId) {
24
+ switch (cliType) {
25
+ case "claude":
26
+ return {
27
+ args: [
28
+ "--print", prompt,
29
+ "--output-format", "stream-json",
30
+ ...(sessionId ? ["--resume", sessionId] : []),
31
+ ],
32
+ stdinPrompt: null,
33
+ };
34
+ case "codex": {
35
+ const args = ["exec", "--json"];
36
+ if (sessionId)
37
+ args.push("resume", sessionId, "-");
38
+ else
39
+ args.push("-");
40
+ return { args, stdinPrompt: prompt };
41
+ }
42
+ case "gemini":
43
+ return {
44
+ args: [
45
+ "--output-format", "stream-json",
46
+ "--approval-mode", "yolo",
47
+ "--sandbox=none",
48
+ "--prompt", prompt,
49
+ ...(sessionId ? ["--resume", sessionId] : []),
50
+ ],
51
+ stdinPrompt: null,
52
+ };
53
+ }
54
+ }
55
+ export async function spawnCli(cliType, command, prompt, options) {
56
+ const { args, stdinPrompt } = buildArgs(cliType, prompt, options?.sessionId);
57
+ return new Promise((resolve, reject) => {
58
+ const child = spawn(command, args, {
59
+ stdio: ["pipe", "pipe", "pipe"],
60
+ env: options?.env ? { ...process.env, ...options.env } : process.env,
61
+ });
62
+ if (stdinPrompt) {
63
+ child.stdin?.write(stdinPrompt);
64
+ child.stdin?.end();
65
+ }
66
+ const chunks = [];
67
+ let fullOutput = "";
68
+ let buffer = "";
69
+ child.stdout?.on("data", (data) => {
70
+ const text = data.toString();
71
+ fullOutput += text;
72
+ buffer += text;
73
+ const lines = buffer.split("\n");
74
+ buffer = lines.pop() ?? "";
75
+ for (const line of lines) {
76
+ const trimmed = line.trim();
77
+ if (!trimmed)
78
+ continue;
79
+ const parsed = parseStreamJsonLine(trimmed);
80
+ if (parsed) {
81
+ chunks.push(parsed);
82
+ options?.onChunk?.(parsed);
83
+ }
84
+ }
85
+ });
86
+ child.stderr?.on("data", (data) => {
87
+ const errText = data.toString().trim();
88
+ if (errText) {
89
+ const errChunk = { type: "error", content: errText };
90
+ chunks.push(errChunk);
91
+ options?.onChunk?.(errChunk);
92
+ }
93
+ });
94
+ let killTimer;
95
+ const abortHandler = () => {
96
+ child.kill("SIGTERM");
97
+ killTimer = setTimeout(() => {
98
+ if (!child.killed)
99
+ child.kill("SIGKILL");
100
+ }, 5000);
101
+ };
102
+ options?.signal?.addEventListener("abort", abortHandler);
103
+ child.on("close", (code) => {
104
+ clearTimeout(killTimer);
105
+ options?.signal?.removeEventListener("abort", abortHandler);
106
+ if (buffer.trim()) {
107
+ const parsed = parseStreamJsonLine(buffer.trim());
108
+ if (parsed) {
109
+ chunks.push(parsed);
110
+ options?.onChunk?.(parsed);
111
+ }
112
+ }
113
+ resolve({ exitCode: code ?? 1, chunks, fullOutput });
114
+ });
115
+ child.on("error", (err) => {
116
+ reject(new Error(`CLI 실행 실패: ${err.message}`));
117
+ });
118
+ });
119
+ }
@@ -0,0 +1,7 @@
1
+ export type ParsedChunk = {
2
+ type: "text" | "tool_use" | "tool_result" | "error";
3
+ content: string;
4
+ tool?: string;
5
+ sessionId?: string;
6
+ };
7
+ export declare function parseStreamJsonLine(line: string): ParsedChunk | null;
@@ -0,0 +1,27 @@
1
+ export function parseStreamJsonLine(line) {
2
+ try {
3
+ const data = JSON.parse(line);
4
+ if (data.type === "assistant") {
5
+ if (data.subtype === "text" && data.text) {
6
+ return { type: "text", content: data.text };
7
+ }
8
+ if (data.subtype === "tool_use" && data.tool_name) {
9
+ return { type: "tool_use", content: data.tool_name, tool: data.tool_name };
10
+ }
11
+ }
12
+ if (data.type === "tool_result") {
13
+ return { type: "tool_result", content: String(data.content ?? "").slice(0, 500) };
14
+ }
15
+ if (data.type === "result") {
16
+ return {
17
+ type: "text",
18
+ content: String(data.result ?? ""),
19
+ sessionId: data.session_id ?? undefined, // 다음 작업의 --resume에 사용
20
+ };
21
+ }
22
+ return null;
23
+ }
24
+ catch {
25
+ return null;
26
+ }
27
+ }
@@ -0,0 +1,2 @@
1
+ import { ConfigStore } from "../config/store.js";
2
+ export declare function startAgent(store: ConfigStore): Promise<void>;
@@ -0,0 +1,294 @@
1
+ import { ApiClient } from "../reporter/api-client.js";
2
+ import { spawnCli, CLI_ADAPTERS } from "./adapters.js";
3
+ import { execSync } from "child_process";
4
+ import { readFileSync } from "fs";
5
+ import os from "os";
6
+ // 실행 중인 작업의 AbortController를 추적하여 heartbeat에서 취소 신호를 전달
7
+ const runningTasks = new Map();
8
+ // 네트워크 재연결을 위한 exponential backoff 계산
9
+ const MAX_BACKOFF_MS = 60_000;
10
+ const BASE_DELAY_MS = 1_000;
11
+ function getBackoffDelay(consecutiveFailures) {
12
+ if (consecutiveFailures === 0)
13
+ return 0;
14
+ const delay = Math.min(BASE_DELAY_MS * Math.pow(2, consecutiveFailures - 1), MAX_BACKOFF_MS);
15
+ return delay + Math.random() * 1000; // jitter
16
+ }
17
+ let _agentVersion = "";
18
+ function getAgentVersion() {
19
+ if (_agentVersion)
20
+ return _agentVersion;
21
+ try {
22
+ const pkgPath = new URL("../../package.json", import.meta.url);
23
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
24
+ _agentVersion = pkg.version ?? "0.1.0";
25
+ }
26
+ catch {
27
+ _agentVersion = "0.1.0";
28
+ }
29
+ return _agentVersion;
30
+ }
31
+ function checkCliInstalled(cli) {
32
+ try {
33
+ execSync(`which ${cli}`, { stdio: "ignore" });
34
+ return true;
35
+ }
36
+ catch {
37
+ return false;
38
+ }
39
+ }
40
+ export async function startAgent(store) {
41
+ const config = store.load();
42
+ if (!config.auth?.token) {
43
+ console.error("먼저 로그인하세요: fireqa-agent login");
44
+ process.exit(1);
45
+ }
46
+ const adapter = CLI_ADAPTERS[config.cliType];
47
+ if (!checkCliInstalled(config.cli)) {
48
+ console.error(`${adapter.label} (${config.cli})가 설치되어 있지 않습니다.`);
49
+ console.error(`설치: ${adapter.installUrl}`);
50
+ console.error(`로그인: ${adapter.loginHint}`);
51
+ process.exit(1);
52
+ }
53
+ // hosted 모드: 서버 컨테이너에서 실행, 짧은 polling/heartbeat 간격
54
+ if (config.mode === "hosted") {
55
+ await startHostedWorker(config);
56
+ return;
57
+ }
58
+ const api = new ApiClient(config);
59
+ const agentName = `${process.env.USER ?? "agent"}@${os.hostname()}`;
60
+ let connection;
61
+ try {
62
+ connection = await api.registerConnection(agentName, {
63
+ cli: config.cli,
64
+ os: process.platform,
65
+ nodeVersion: process.version,
66
+ version: getAgentVersion(),
67
+ });
68
+ }
69
+ catch (err) {
70
+ console.error(`에이전트 등록 실패: ${err instanceof Error ? err.message : err}`);
71
+ process.exit(1);
72
+ }
73
+ console.log(`FireQA에 연결됨. 작업 대기 중... (${config.server})`);
74
+ // 이전 실행에서 미전송된 출력 데이터 재전송 시도
75
+ await api.flushPendingOutputs().catch(() => { });
76
+ console.log("미전송 데이터 확인 완료");
77
+ const cleanup = async () => {
78
+ console.log("\n에이전트 종료 중...");
79
+ await api.disconnect(connection.id).catch(() => { });
80
+ process.exit(0);
81
+ };
82
+ process.on("SIGINT", cleanup);
83
+ process.on("SIGTERM", cleanup);
84
+ // 10초마다 heartbeat — cancelledTaskIds를 받아 실행 중인 작업 중단
85
+ let heartbeatFailures = 0;
86
+ const heartbeatTimer = setInterval(async () => {
87
+ try {
88
+ const res = await api.heartbeat(connection.id);
89
+ heartbeatFailures = 0;
90
+ if (res.cancelledTaskIds && res.cancelledTaskIds.length > 0) {
91
+ for (const taskId of res.cancelledTaskIds) {
92
+ const controller = runningTasks.get(taskId);
93
+ if (controller) {
94
+ controller.abort("cancelled");
95
+ console.log(`작업 취소 신호 전송: ${taskId}`);
96
+ }
97
+ }
98
+ }
99
+ }
100
+ catch {
101
+ heartbeatFailures++;
102
+ if (heartbeatFailures > 3) {
103
+ console.warn(`heartbeat 연속 실패 (${heartbeatFailures}회)`);
104
+ }
105
+ }
106
+ }, 10_000);
107
+ let pollingFailures = 0;
108
+ while (true) {
109
+ try {
110
+ const task = await api.getNextTask(connection.id);
111
+ pollingFailures = 0;
112
+ if (task) {
113
+ console.log(`작업 수령: [${task.type}] ${task.prompt.slice(0, 50)}...`);
114
+ await executeTask(config, api, task);
115
+ }
116
+ }
117
+ catch (err) {
118
+ if (err instanceof Error && err.message.includes("401")) {
119
+ console.error("인증 실패. API Key를 확인하세요.");
120
+ clearInterval(heartbeatTimer);
121
+ process.exit(1);
122
+ }
123
+ pollingFailures++;
124
+ const backoff = getBackoffDelay(pollingFailures);
125
+ if (pollingFailures === 1) {
126
+ console.warn("서버 연결 실패. 재연결 시도 중...");
127
+ }
128
+ if (backoff > 0) {
129
+ await sleep(backoff);
130
+ continue; // backoff 후 바로 재시도 (아래 pollingIntervalMs 대기 건너뜀)
131
+ }
132
+ }
133
+ await sleep(config.pollingIntervalMs);
134
+ }
135
+ }
136
+ // Figma MCP 가용성을 캐싱 (에이전트 수명 동안 유효)
137
+ let figmaMcpAvailable = null;
138
+ function checkFigmaMcp(cli) {
139
+ if (figmaMcpAvailable !== null)
140
+ return figmaMcpAvailable;
141
+ try {
142
+ const output = execSync(`${cli} mcp list 2>/dev/null`, { encoding: "utf-8", timeout: 5000 });
143
+ figmaMcpAvailable = output.toLowerCase().includes("figma");
144
+ }
145
+ catch {
146
+ figmaMcpAvailable = true; // 명령어 실패 시 검증 건너뜀 (soft fail)
147
+ }
148
+ return figmaMcpAvailable;
149
+ }
150
+ async function executeTask(config, api, task) {
151
+ // Figma MCP 미설정 감지: figma 도구가 필요한 작업인데 MCP가 없으면 사전 차단
152
+ const needsFigma = task.mcpTools?.some((t) => t.includes("figma")) &&
153
+ task.context?.figmaFileKey;
154
+ if (needsFigma && !checkFigmaMcp(config.cli)) {
155
+ await api.updateTaskStatus(task.id, "failed", {
156
+ errorMessage: "Figma MCP가 설정되어 있지 않습니다. 'claude mcp add figma' 명령으로 설정해주세요.",
157
+ });
158
+ console.error(`작업 실패: ${task.id} — Figma MCP 미설정`);
159
+ return;
160
+ }
161
+ await api.updateTaskStatus(task.id, "running");
162
+ const controller = new AbortController();
163
+ runningTasks.set(task.id, controller);
164
+ const timeout = setTimeout(() => controller.abort("timeout"), task.timeoutMs);
165
+ let chunkBuffer = [];
166
+ const flushInterval = setInterval(async () => {
167
+ if (chunkBuffer.length > 0) {
168
+ const toSend = [...chunkBuffer];
169
+ chunkBuffer = [];
170
+ await api.sendOutput(task.id, toSend).catch(() => { });
171
+ }
172
+ }, 500);
173
+ try {
174
+ const result = await spawnCli(config.cliType, config.cli, task.prompt, {
175
+ sessionId: task.sessionId ?? undefined,
176
+ mcpTools: task.mcpTools,
177
+ onChunk: (chunk) => { chunkBuffer.push(chunk); },
178
+ signal: controller.signal,
179
+ });
180
+ clearInterval(flushInterval);
181
+ if (chunkBuffer.length > 0) {
182
+ await api.sendOutput(task.id, chunkBuffer).catch(() => { });
183
+ }
184
+ if (result.exitCode === 0) {
185
+ // result 청크에서 sessionId 추출 (세션 연속성: 다음 작업에서 --resume에 사용)
186
+ const sessionId = result.chunks
187
+ .filter((c) => c.sessionId)
188
+ .at(-1)?.sessionId;
189
+ await api.sendResult(task.id, { output: result.fullOutput }, sessionId);
190
+ console.log(`작업 완료: ${task.id}`);
191
+ }
192
+ else {
193
+ await api.updateTaskStatus(task.id, "failed", {
194
+ errorMessage: `CLI exited with code ${result.exitCode}`,
195
+ });
196
+ console.error(`작업 실패: ${task.id} (exit code: ${result.exitCode})`);
197
+ }
198
+ }
199
+ catch (err) {
200
+ clearInterval(flushInterval);
201
+ const message = err instanceof Error ? err.message : "알 수 없는 오류";
202
+ if (controller.signal.aborted) {
203
+ const reason = controller.signal.reason;
204
+ if (reason === "cancelled") {
205
+ // 서버에서 이미 cancelled로 변경했으므로 상태 업데이트 불필요
206
+ console.log(`작업 취소됨: ${task.id}`);
207
+ }
208
+ else {
209
+ await api.updateTaskStatus(task.id, "timed_out", { errorMessage: "작업 시간 초과" });
210
+ console.error(`작업 시간 초과: ${task.id}`);
211
+ }
212
+ }
213
+ else {
214
+ await api.updateTaskStatus(task.id, "failed", { errorMessage: message });
215
+ console.error(`작업 실패: ${task.id} — ${message}`);
216
+ }
217
+ }
218
+ finally {
219
+ clearTimeout(timeout);
220
+ clearInterval(flushInterval);
221
+ runningTasks.delete(task.id);
222
+ }
223
+ }
224
+ async function startHostedWorker(config) {
225
+ const api = new ApiClient(config);
226
+ await api.flushPendingOutputs().catch(() => { });
227
+ let connection;
228
+ try {
229
+ connection = await api.registerConnection("hosted-worker", {
230
+ cli: config.cli,
231
+ os: process.platform,
232
+ nodeVersion: process.version,
233
+ version: getAgentVersion(),
234
+ mode: "hosted",
235
+ });
236
+ }
237
+ catch (err) {
238
+ console.error(`호스티드 워커 등록 실패: ${err instanceof Error ? err.message : err}`);
239
+ process.exit(1);
240
+ }
241
+ console.log(`호스티드 워커 연결됨 (${config.server})`);
242
+ const cleanup = async () => {
243
+ await api.disconnect(connection.id).catch(() => { });
244
+ process.exit(0);
245
+ };
246
+ process.on("SIGINT", cleanup);
247
+ process.on("SIGTERM", cleanup);
248
+ // hosted 모드: 5초 heartbeat
249
+ let heartbeatFailures = 0;
250
+ const heartbeatTimer = setInterval(async () => {
251
+ try {
252
+ const res = await api.heartbeat(connection.id);
253
+ heartbeatFailures = 0;
254
+ if (res.cancelledTaskIds?.length > 0) {
255
+ for (const taskId of res.cancelledTaskIds) {
256
+ const controller = runningTasks.get(taskId);
257
+ if (controller) {
258
+ controller.abort("cancelled");
259
+ console.log(`작업 취소 신호: ${taskId}`);
260
+ }
261
+ }
262
+ }
263
+ }
264
+ catch {
265
+ heartbeatFailures++;
266
+ if (heartbeatFailures > 5) {
267
+ console.error("heartbeat 연속 실패. 워커 종료.");
268
+ clearInterval(heartbeatTimer);
269
+ process.exit(1);
270
+ }
271
+ }
272
+ }, 5_000);
273
+ // hosted 모드: 1초 간격 polling, 연속 실행
274
+ while (true) {
275
+ try {
276
+ const task = await api.getNextTask(connection.id);
277
+ if (task) {
278
+ console.log(`작업 수령: [${task.type}] ${task.prompt.slice(0, 50)}...`);
279
+ await executeTask(config, api, task);
280
+ }
281
+ }
282
+ catch (err) {
283
+ if (err instanceof Error && err.message.includes("401")) {
284
+ console.error("인증 실패. 워커 종료.");
285
+ clearInterval(heartbeatTimer);
286
+ process.exit(1);
287
+ }
288
+ }
289
+ await sleep(1000);
290
+ }
291
+ }
292
+ function sleep(ms) {
293
+ return new Promise((resolve) => setTimeout(resolve, ms));
294
+ }
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "fireqa-agent",
3
+ "version": "0.1.0",
4
+ "description": "FireQA Agent CLI — connect your AI CLI (Claude Code, Codex, Gemini) to FireQA",
5
+ "type": "module",
6
+ "bin": {
7
+ "fireqa-agent": "./dist/cli.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "README.md"
12
+ ],
13
+ "scripts": {
14
+ "build": "tsc",
15
+ "dev": "tsx src/cli.ts",
16
+ "prepublishOnly": "npm run build",
17
+ "test": "vitest run"
18
+ },
19
+ "engines": {
20
+ "node": ">=18"
21
+ },
22
+ "keywords": [
23
+ "fireqa",
24
+ "agent",
25
+ "cli",
26
+ "claude-code",
27
+ "codex",
28
+ "gemini",
29
+ "qa",
30
+ "test-automation"
31
+ ],
32
+ "license": "MIT",
33
+ "dependencies": {
34
+ "commander": "^13.0.0"
35
+ },
36
+ "devDependencies": {
37
+ "@types/node": "^22.0.0",
38
+ "tsx": "^4.0.0",
39
+ "typescript": "^5.8.0",
40
+ "vitest": "^3.0.0"
41
+ }
42
+ }