feat: add host-driven GitHub Actions CI watcher (Level 3)
Replace LLM-per-tick polling with direct `gh api` calls for GitHub Actions watchers. New `ci_provider` discriminator column routes tasks to either the host-driven path (zero LLM tokens) or the existing generic LLM path. GitHub watchers poll at 15s intervals (min 10s) via `checkGitHubActionsRun()` in the scheduler, with completion messages sent directly to chat on terminal state. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -169,21 +169,53 @@ server.tool(
|
||||
{
|
||||
target: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'What to watch, for example "PR #123 checks" or "GitHub Actions run 987654321".',
|
||||
),
|
||||
check_instructions: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Exact steps or commands to check status and what details matter when it finishes.',
|
||||
),
|
||||
ci_provider: z
|
||||
.enum(['github'])
|
||||
.optional()
|
||||
.describe(
|
||||
'Optional structured CI provider selector. When set to "github", the watcher can use a host-driven fast path.',
|
||||
),
|
||||
ci_repo: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Optional GitHub repository in "owner/repo" format for host-driven GitHub watchers.',
|
||||
),
|
||||
ci_run_id: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.describe(
|
||||
'Optional GitHub Actions run ID for host-driven GitHub watchers.',
|
||||
),
|
||||
ci_pr_number: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.describe(
|
||||
'Optional PR number reserved for future GitHub PR-check watchers.',
|
||||
),
|
||||
poll_interval_seconds: z
|
||||
.number()
|
||||
.int()
|
||||
.min(30)
|
||||
.min(10)
|
||||
.max(3600)
|
||||
.default(60)
|
||||
.describe('How often to poll in seconds. Default 60, minimum 30.'),
|
||||
.optional()
|
||||
.describe(
|
||||
'How often to poll in seconds. Defaults to 60 for generic watchers and 15 for GitHub host-driven watchers. Generic watchers require 30+, GitHub host-driven watchers allow 10+.',
|
||||
),
|
||||
context_mode: z
|
||||
.enum(['group', 'isolated'])
|
||||
.default(DEFAULT_WATCH_CI_CONTEXT_MODE)
|
||||
@@ -198,9 +230,59 @@ server.tool(
|
||||
),
|
||||
},
|
||||
async (args) => {
|
||||
const isGitHubWatcher = args.ci_provider === 'github';
|
||||
const target =
|
||||
args.target ||
|
||||
(isGitHubWatcher && args.ci_run_id
|
||||
? `GitHub Actions run ${args.ci_run_id}`
|
||||
: undefined);
|
||||
const checkInstructions =
|
||||
args.check_instructions ||
|
||||
(isGitHubWatcher
|
||||
? 'This watcher is handled by the host-driven GitHub Actions path. Do not rely on the prompt for execution.'
|
||||
: undefined);
|
||||
|
||||
if (!target) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: 'target is required unless structured GitHub watcher fields are provided.',
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (!checkInstructions) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: 'check_instructions is required for generic CI watchers.',
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (isGitHubWatcher && (!args.ci_repo || !args.ci_run_id)) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: 'GitHub host-driven watchers require both ci_repo and ci_run_id.',
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
let pollSeconds: number;
|
||||
try {
|
||||
pollSeconds = normalizeWatchCiIntervalSeconds(args.poll_interval_seconds);
|
||||
pollSeconds = normalizeWatchCiIntervalSeconds(args.poll_interval_seconds, {
|
||||
ciProvider: args.ci_provider,
|
||||
});
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
@@ -218,8 +300,8 @@ server.tool(
|
||||
const taskId = `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const prompt = buildCiWatchPrompt({
|
||||
taskId,
|
||||
target: args.target,
|
||||
checkInstructions: args.check_instructions,
|
||||
target,
|
||||
checkInstructions,
|
||||
});
|
||||
|
||||
const data = {
|
||||
@@ -229,6 +311,13 @@ server.tool(
|
||||
schedule_type: 'interval' as const,
|
||||
schedule_value: String(pollSeconds * 1000),
|
||||
context_mode: args.context_mode || DEFAULT_WATCH_CI_CONTEXT_MODE,
|
||||
ci_provider: args.ci_provider,
|
||||
ci_metadata: isGitHubWatcher
|
||||
? JSON.stringify({
|
||||
repo: args.ci_repo,
|
||||
run_id: args.ci_run_id,
|
||||
})
|
||||
: undefined,
|
||||
targetJid,
|
||||
createdBy: groupFolder,
|
||||
timestamp: new Date().toISOString(),
|
||||
|
||||
@@ -1,17 +1,34 @@
|
||||
export const DEFAULT_WATCH_CI_INTERVAL_SECONDS = 60;
|
||||
export const MIN_WATCH_CI_INTERVAL_SECONDS = 30;
|
||||
export const DEFAULT_GITHUB_WATCH_CI_INTERVAL_SECONDS = 15;
|
||||
export const MIN_GITHUB_WATCH_CI_INTERVAL_SECONDS = 10;
|
||||
export const MAX_WATCH_CI_INTERVAL_SECONDS = 3600;
|
||||
export const DEFAULT_WATCH_CI_CONTEXT_MODE = 'isolated';
|
||||
|
||||
export interface NormalizeWatchCiIntervalOptions {
|
||||
ciProvider?: 'github';
|
||||
}
|
||||
|
||||
export interface BuildCiWatchPromptArgs {
|
||||
taskId: string;
|
||||
target: string;
|
||||
checkInstructions: string;
|
||||
}
|
||||
|
||||
export function normalizeWatchCiIntervalSeconds(seconds?: number): number {
|
||||
export function normalizeWatchCiIntervalSeconds(
|
||||
seconds?: number,
|
||||
options?: NormalizeWatchCiIntervalOptions,
|
||||
): number {
|
||||
const isGitHub = options?.ciProvider === 'github';
|
||||
const defaultSeconds = isGitHub
|
||||
? DEFAULT_GITHUB_WATCH_CI_INTERVAL_SECONDS
|
||||
: DEFAULT_WATCH_CI_INTERVAL_SECONDS;
|
||||
const minSeconds = isGitHub
|
||||
? MIN_GITHUB_WATCH_CI_INTERVAL_SECONDS
|
||||
: MIN_WATCH_CI_INTERVAL_SECONDS;
|
||||
|
||||
if (seconds === undefined) {
|
||||
return DEFAULT_WATCH_CI_INTERVAL_SECONDS;
|
||||
return defaultSeconds;
|
||||
}
|
||||
|
||||
if (!Number.isInteger(seconds)) {
|
||||
@@ -19,11 +36,11 @@ export function normalizeWatchCiIntervalSeconds(seconds?: number): number {
|
||||
}
|
||||
|
||||
if (
|
||||
seconds < MIN_WATCH_CI_INTERVAL_SECONDS ||
|
||||
seconds < minSeconds ||
|
||||
seconds > MAX_WATCH_CI_INTERVAL_SECONDS
|
||||
) {
|
||||
throw new Error(
|
||||
`poll_interval_seconds must be between ${MIN_WATCH_CI_INTERVAL_SECONDS} and ${MAX_WATCH_CI_INTERVAL_SECONDS}.`,
|
||||
`poll_interval_seconds must be between ${minSeconds} and ${MAX_WATCH_CI_INTERVAL_SECONDS}.`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildCiWatchPrompt,
|
||||
DEFAULT_WATCH_CI_CONTEXT_MODE,
|
||||
DEFAULT_GITHUB_WATCH_CI_INTERVAL_SECONDS,
|
||||
normalizeWatchCiIntervalSeconds,
|
||||
} from '../src/watch-ci.js';
|
||||
|
||||
@@ -35,6 +36,12 @@ describe('watch-ci helpers', () => {
|
||||
expect(normalizeWatchCiIntervalSeconds()).toBe(60);
|
||||
expect(normalizeWatchCiIntervalSeconds(30)).toBe(30);
|
||||
expect(normalizeWatchCiIntervalSeconds(600)).toBe(600);
|
||||
expect(
|
||||
normalizeWatchCiIntervalSeconds(undefined, { ciProvider: 'github' }),
|
||||
).toBe(DEFAULT_GITHUB_WATCH_CI_INTERVAL_SECONDS);
|
||||
expect(normalizeWatchCiIntervalSeconds(10, { ciProvider: 'github' })).toBe(
|
||||
10,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects invalid poll intervals', () => {
|
||||
@@ -45,5 +52,8 @@ describe('watch-ci helpers', () => {
|
||||
/between 30 and 3600/i,
|
||||
);
|
||||
expect(() => normalizeWatchCiIntervalSeconds(30.5)).toThrow(/integer/i);
|
||||
expect(() =>
|
||||
normalizeWatchCiIntervalSeconds(9, { ciProvider: 'github' }),
|
||||
).toThrow(/between 10 and 3600/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -400,6 +400,36 @@ describe('task CRUD', () => {
|
||||
expect(getTaskById('task-2')!.status).toBe('paused');
|
||||
});
|
||||
|
||||
it('stores and updates GitHub CI task metadata', () => {
|
||||
createTask({
|
||||
id: 'task-github',
|
||||
group_folder: 'main',
|
||||
chat_jid: 'group@g.us',
|
||||
ci_provider: 'github',
|
||||
ci_metadata: JSON.stringify({ repo: 'owner/repo', run_id: 123456 }),
|
||||
prompt: 'github watcher',
|
||||
schedule_type: 'interval',
|
||||
schedule_value: '15000',
|
||||
context_mode: 'isolated',
|
||||
next_run: '2024-06-01T00:00:00.000Z',
|
||||
status: 'active',
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(getTaskById('task-github')?.ci_provider).toBe('github');
|
||||
expect(getTaskById('task-github')?.ci_metadata).toContain('owner/repo');
|
||||
|
||||
updateTask('task-github', {
|
||||
ci_metadata: JSON.stringify({
|
||||
repo: 'owner/repo',
|
||||
run_id: 123456,
|
||||
poll_count: 2,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(getTaskById('task-github')?.ci_metadata).toContain('"poll_count":2');
|
||||
});
|
||||
|
||||
it('deletes a task and its run logs', () => {
|
||||
createTask({
|
||||
id: 'task-3',
|
||||
|
||||
29
src/db.ts
29
src/db.ts
@@ -137,6 +137,8 @@ function createSchema(database: Database.Database): void {
|
||||
group_folder TEXT NOT NULL,
|
||||
chat_jid TEXT NOT NULL,
|
||||
agent_type TEXT,
|
||||
ci_provider TEXT,
|
||||
ci_metadata TEXT,
|
||||
max_duration_ms INTEGER,
|
||||
status_message_id TEXT,
|
||||
status_started_at TEXT,
|
||||
@@ -205,6 +207,18 @@ function createSchema(database: Database.Database): void {
|
||||
/* column already exists */
|
||||
}
|
||||
|
||||
try {
|
||||
database.exec(`ALTER TABLE scheduled_tasks ADD COLUMN ci_provider TEXT`);
|
||||
} catch {
|
||||
/* column already exists */
|
||||
}
|
||||
|
||||
try {
|
||||
database.exec(`ALTER TABLE scheduled_tasks ADD COLUMN ci_metadata TEXT`);
|
||||
} catch {
|
||||
/* column already exists */
|
||||
}
|
||||
|
||||
try {
|
||||
database.exec(
|
||||
`ALTER TABLE scheduled_tasks ADD COLUMN max_duration_ms INTEGER`,
|
||||
@@ -877,11 +891,15 @@ export function createTask(
|
||||
| 'last_run'
|
||||
| 'last_result'
|
||||
| 'agent_type'
|
||||
| 'ci_provider'
|
||||
| 'ci_metadata'
|
||||
| 'max_duration_ms'
|
||||
| 'status_message_id'
|
||||
| 'status_started_at'
|
||||
> & {
|
||||
agent_type?: AgentType | null;
|
||||
ci_provider?: ScheduledTask['ci_provider'];
|
||||
ci_metadata?: string | null;
|
||||
max_duration_ms?: number | null;
|
||||
status_message_id?: string | null;
|
||||
status_started_at?: string | null;
|
||||
@@ -889,14 +907,16 @@ export function createTask(
|
||||
): void {
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO scheduled_tasks (id, group_folder, chat_jid, agent_type, max_duration_ms, status_message_id, status_started_at, prompt, schedule_type, schedule_value, context_mode, next_run, status, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO scheduled_tasks (id, group_folder, chat_jid, agent_type, ci_provider, ci_metadata, max_duration_ms, status_message_id, status_started_at, prompt, schedule_type, schedule_value, context_mode, next_run, status, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
).run(
|
||||
task.id,
|
||||
task.group_folder,
|
||||
task.chat_jid,
|
||||
task.agent_type || SERVICE_AGENT_TYPE,
|
||||
task.ci_provider ?? null,
|
||||
task.ci_metadata ?? null,
|
||||
task.max_duration_ms ?? null,
|
||||
task.status_message_id || null,
|
||||
task.status_started_at || null,
|
||||
@@ -960,6 +980,7 @@ export function updateTask(
|
||||
| 'next_run'
|
||||
| 'status'
|
||||
| 'suspended_until'
|
||||
| 'ci_metadata'
|
||||
>
|
||||
>,
|
||||
): void {
|
||||
@@ -990,6 +1011,10 @@ export function updateTask(
|
||||
fields.push('suspended_until = ?');
|
||||
values.push(updates.suspended_until);
|
||||
}
|
||||
if (updates.ci_metadata !== undefined) {
|
||||
fields.push('ci_metadata = ?');
|
||||
values.push(updates.ci_metadata);
|
||||
}
|
||||
|
||||
if (fields.length === 0) return;
|
||||
|
||||
|
||||
149
src/github-ci.test.ts
Normal file
149
src/github-ci.test.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { execFileMock } = vi.hoisted(() => ({
|
||||
execFileMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('child_process', () => ({
|
||||
execFile: execFileMock,
|
||||
}));
|
||||
|
||||
import {
|
||||
checkGitHubActionsRun,
|
||||
parseGitHubCiMetadata,
|
||||
serializeGitHubCiMetadata,
|
||||
} from './github-ci.js';
|
||||
|
||||
describe('github-ci helpers', () => {
|
||||
afterEach(() => {
|
||||
execFileMock.mockReset();
|
||||
});
|
||||
|
||||
it('round-trips GitHub CI metadata', () => {
|
||||
const raw = serializeGitHubCiMetadata({
|
||||
repo: 'owner/repo',
|
||||
run_id: 123456,
|
||||
poll_count: 1,
|
||||
});
|
||||
|
||||
expect(parseGitHubCiMetadata(raw)).toEqual({
|
||||
repo: 'owner/repo',
|
||||
run_id: 123456,
|
||||
poll_count: 1,
|
||||
consecutive_errors: undefined,
|
||||
});
|
||||
expect(parseGitHubCiMetadata('{"repo":"","run_id":0}')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns non-terminal status for in-progress runs', async () => {
|
||||
execFileMock.mockImplementation(
|
||||
(
|
||||
_cmd: string,
|
||||
_args: string[],
|
||||
_opts: unknown,
|
||||
cb: (error: Error | null, stdout: string, stderr: string) => void,
|
||||
) => {
|
||||
cb(
|
||||
null,
|
||||
JSON.stringify({
|
||||
status: 'in_progress',
|
||||
conclusion: null,
|
||||
}),
|
||||
'',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await expect(
|
||||
checkGitHubActionsRun({
|
||||
prompt: `
|
||||
[BACKGROUND CI WATCH]
|
||||
|
||||
Watch target:
|
||||
GitHub Actions run 123456
|
||||
|
||||
Task ID:
|
||||
task-github
|
||||
|
||||
Check instructions:
|
||||
Managed by host-driven watcher.
|
||||
`.trim(),
|
||||
ci_metadata: JSON.stringify({
|
||||
repo: 'owner/repo',
|
||||
run_id: 123456,
|
||||
}),
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
terminal: false,
|
||||
resultSummary: 'GitHub Actions run 123456 is in_progress',
|
||||
});
|
||||
});
|
||||
|
||||
it('renders a concise completion message for terminal runs', async () => {
|
||||
execFileMock
|
||||
.mockImplementationOnce(
|
||||
(
|
||||
_cmd: string,
|
||||
_args: string[],
|
||||
_opts: unknown,
|
||||
cb: (error: Error | null, stdout: string, stderr: string) => void,
|
||||
) => {
|
||||
cb(
|
||||
null,
|
||||
JSON.stringify({
|
||||
status: 'completed',
|
||||
conclusion: 'failure',
|
||||
name: 'CI',
|
||||
html_url: 'https://github.com/owner/repo/actions/runs/654321',
|
||||
head_branch: 'main',
|
||||
}),
|
||||
'',
|
||||
);
|
||||
},
|
||||
)
|
||||
.mockImplementationOnce(
|
||||
(
|
||||
_cmd: string,
|
||||
_args: string[],
|
||||
_opts: unknown,
|
||||
cb: (error: Error | null, stdout: string, stderr: string) => void,
|
||||
) => {
|
||||
cb(
|
||||
null,
|
||||
JSON.stringify({
|
||||
jobs: [
|
||||
{ name: 'build', conclusion: 'success' },
|
||||
{ name: 'test', conclusion: 'failure' },
|
||||
],
|
||||
}),
|
||||
'',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const result = await checkGitHubActionsRun({
|
||||
prompt: `
|
||||
[BACKGROUND CI WATCH]
|
||||
|
||||
Watch target:
|
||||
GitHub Actions run 654321
|
||||
|
||||
Task ID:
|
||||
task-github
|
||||
|
||||
Check instructions:
|
||||
Managed by host-driven watcher.
|
||||
`.trim(),
|
||||
ci_metadata: JSON.stringify({
|
||||
repo: 'owner/repo',
|
||||
run_id: 654321,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result.terminal).toBe(true);
|
||||
expect(result.resultSummary).toContain('실패');
|
||||
expect(result.completionMessage).toContain('CI 완료: GitHub Actions run 654321');
|
||||
expect(result.completionMessage).toContain('판정: 실패');
|
||||
expect(result.completionMessage).toContain('- 실패 job: test');
|
||||
});
|
||||
});
|
||||
199
src/github-ci.ts
Normal file
199
src/github-ci.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { execFile } from 'child_process';
|
||||
|
||||
import { extractWatchCiTarget } from './task-watch-status.js';
|
||||
import type { ScheduledTask } from './types.js';
|
||||
|
||||
export interface GitHubCiMetadata {
|
||||
repo: string;
|
||||
run_id: number;
|
||||
poll_count?: number;
|
||||
consecutive_errors?: number;
|
||||
}
|
||||
|
||||
interface GitHubActionsRunResponse {
|
||||
status?: string | null;
|
||||
conclusion?: string | null;
|
||||
name?: string | null;
|
||||
display_title?: string | null;
|
||||
html_url?: string | null;
|
||||
head_branch?: string | null;
|
||||
head_sha?: string | null;
|
||||
event?: string | null;
|
||||
}
|
||||
|
||||
interface GitHubActionsJobsResponse {
|
||||
jobs?: Array<{
|
||||
name?: string | null;
|
||||
conclusion?: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface GitHubRunCheckResult {
|
||||
terminal: boolean;
|
||||
resultSummary: string;
|
||||
completionMessage?: string;
|
||||
}
|
||||
|
||||
function execGhApi(args: string[]): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(
|
||||
'gh',
|
||||
['api', ...args],
|
||||
{
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
env: process.env,
|
||||
},
|
||||
(error, stdout, stderr) => {
|
||||
if (error) {
|
||||
const details = stderr?.trim() || stdout?.trim() || error.message;
|
||||
reject(new Error(`gh api failed: ${details}`));
|
||||
return;
|
||||
}
|
||||
resolve(stdout);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function parseGitHubCiMetadata(
|
||||
raw: string | null | undefined,
|
||||
): GitHubCiMetadata | null {
|
||||
if (!raw) return null;
|
||||
|
||||
let parsed: Partial<GitHubCiMetadata>;
|
||||
try {
|
||||
parsed = JSON.parse(raw) as Partial<GitHubCiMetadata>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof parsed.repo !== 'string' || parsed.repo.trim() === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const runId = Number(parsed.run_id);
|
||||
if (!Number.isInteger(runId) || runId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
repo: parsed.repo,
|
||||
run_id: runId,
|
||||
poll_count:
|
||||
Number.isInteger(parsed.poll_count) && parsed.poll_count! >= 0
|
||||
? parsed.poll_count
|
||||
: undefined,
|
||||
consecutive_errors:
|
||||
Number.isInteger(parsed.consecutive_errors) &&
|
||||
parsed.consecutive_errors! >= 0
|
||||
? parsed.consecutive_errors
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeGitHubCiMetadata(
|
||||
metadata: GitHubCiMetadata,
|
||||
): string {
|
||||
return JSON.stringify(metadata);
|
||||
}
|
||||
|
||||
function formatConclusionLabel(conclusion: string | null | undefined): string {
|
||||
switch (conclusion) {
|
||||
case 'success':
|
||||
return '성공';
|
||||
case 'failure':
|
||||
return '실패';
|
||||
case 'cancelled':
|
||||
return '취소됨';
|
||||
case 'timed_out':
|
||||
return '시간 초과';
|
||||
case 'action_required':
|
||||
return '조치 필요';
|
||||
case 'neutral':
|
||||
return '중립';
|
||||
case 'skipped':
|
||||
return '건너뜀';
|
||||
case 'stale':
|
||||
return '오래됨';
|
||||
default:
|
||||
return conclusion || '완료';
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchFailedJobs(
|
||||
metadata: GitHubCiMetadata,
|
||||
): Promise<string[]> {
|
||||
const stdout = await execGhApi([
|
||||
`repos/${metadata.repo}/actions/runs/${metadata.run_id}/jobs?per_page=100`,
|
||||
]);
|
||||
const parsed = JSON.parse(stdout) as GitHubActionsJobsResponse;
|
||||
return (parsed.jobs || [])
|
||||
.filter(
|
||||
(job) =>
|
||||
job.conclusion &&
|
||||
['failure', 'cancelled', 'timed_out', 'startup_failure'].includes(
|
||||
job.conclusion,
|
||||
),
|
||||
)
|
||||
.map((job) => job.name?.trim())
|
||||
.filter((name): name is string => Boolean(name))
|
||||
.slice(0, 3);
|
||||
}
|
||||
|
||||
export async function checkGitHubActionsRun(
|
||||
task: Pick<ScheduledTask, 'prompt' | 'ci_metadata'>,
|
||||
): Promise<GitHubRunCheckResult> {
|
||||
const metadata = parseGitHubCiMetadata(task.ci_metadata);
|
||||
if (!metadata) {
|
||||
throw new Error('Task is missing valid GitHub CI metadata');
|
||||
}
|
||||
|
||||
const stdout = await execGhApi([
|
||||
`repos/${metadata.repo}/actions/runs/${metadata.run_id}`,
|
||||
]);
|
||||
const run = JSON.parse(stdout) as GitHubActionsRunResponse;
|
||||
const status = run.status || 'unknown';
|
||||
|
||||
if (status !== 'completed') {
|
||||
return {
|
||||
terminal: false,
|
||||
resultSummary: `GitHub Actions run ${metadata.run_id} is ${status}`,
|
||||
};
|
||||
}
|
||||
|
||||
let failedJobs: string[] = [];
|
||||
try {
|
||||
failedJobs = await fetchFailedJobs(metadata);
|
||||
} catch {
|
||||
failedJobs = [];
|
||||
}
|
||||
|
||||
const target =
|
||||
extractWatchCiTarget(task.prompt) ||
|
||||
`GitHub Actions run ${metadata.run_id}`;
|
||||
const conclusionLabel = formatConclusionLabel(run.conclusion);
|
||||
|
||||
const lines = [
|
||||
`CI 완료: ${target}`,
|
||||
`판정: ${conclusionLabel}`,
|
||||
`- 저장소: ${metadata.repo}`,
|
||||
];
|
||||
|
||||
if (run.name) {
|
||||
lines.push(`- 워크플로: ${run.name}`);
|
||||
}
|
||||
if (run.head_branch) {
|
||||
lines.push(`- 브랜치: ${run.head_branch}`);
|
||||
}
|
||||
if (failedJobs.length > 0) {
|
||||
lines.push(`- 실패 job: ${failedJobs.join(', ')}`);
|
||||
}
|
||||
if (run.html_url) {
|
||||
lines.push(`- 링크: ${run.html_url}`);
|
||||
}
|
||||
|
||||
return {
|
||||
terminal: true,
|
||||
resultSummary: `${conclusionLabel}: ${metadata.repo} run ${metadata.run_id}`,
|
||||
completionMessage: lines.join('\n'),
|
||||
};
|
||||
}
|
||||
@@ -588,6 +588,43 @@ Check the run.
|
||||
expect(deps.nudgeScheduler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('persists structured GitHub watch metadata for host-driven watchers', async () => {
|
||||
await processTaskIpc(
|
||||
{
|
||||
type: 'schedule_task',
|
||||
prompt: `
|
||||
[BACKGROUND CI WATCH]
|
||||
|
||||
Watch target:
|
||||
GitHub Actions run 654321
|
||||
|
||||
Task ID:
|
||||
task-watch-github
|
||||
|
||||
Check instructions:
|
||||
Managed by host-driven watcher.
|
||||
`.trim(),
|
||||
schedule_type: 'interval',
|
||||
schedule_value: '15000',
|
||||
ci_provider: 'github',
|
||||
ci_metadata: JSON.stringify({
|
||||
repo: 'owner/repo',
|
||||
run_id: 654321,
|
||||
}),
|
||||
targetJid: 'other@g.us',
|
||||
},
|
||||
'whatsapp_main',
|
||||
true,
|
||||
deps,
|
||||
);
|
||||
|
||||
const tasks = getAllTasks();
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0].ci_provider).toBe('github');
|
||||
expect(tasks[0].ci_metadata).toContain('owner/repo');
|
||||
expect(tasks[0].max_duration_ms).toBe(DEFAULT_WATCH_CI_MAX_DURATION_MS);
|
||||
});
|
||||
|
||||
it('does not assign a max duration to regular scheduled tasks', async () => {
|
||||
await processTaskIpc(
|
||||
{
|
||||
|
||||
@@ -286,6 +286,8 @@ export async function processTaskIpc(
|
||||
schedule_type?: string;
|
||||
schedule_value?: string;
|
||||
context_mode?: string;
|
||||
ci_provider?: 'github';
|
||||
ci_metadata?: string;
|
||||
groupFolder?: string;
|
||||
chatJid?: string;
|
||||
targetJid?: string;
|
||||
@@ -404,6 +406,8 @@ export async function processTaskIpc(
|
||||
group_folder: targetFolder,
|
||||
chat_jid: resolvedTargetJid,
|
||||
agent_type: targetGroupEntry.agentType || SERVICE_AGENT_TYPE,
|
||||
ci_provider: data.ci_provider ?? null,
|
||||
ci_metadata: data.ci_metadata ?? null,
|
||||
max_duration_ms: isWatchCiTask({ prompt: data.prompt })
|
||||
? DEFAULT_WATCH_CI_MAX_DURATION_MS
|
||||
: null,
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { runAgentProcessMock, writeTasksSnapshotMock, loggerDebugMock } =
|
||||
const {
|
||||
runAgentProcessMock,
|
||||
writeTasksSnapshotMock,
|
||||
loggerDebugMock,
|
||||
checkGitHubActionsRunMock,
|
||||
} =
|
||||
vi.hoisted(() => ({
|
||||
runAgentProcessMock: vi.fn(async () => ({
|
||||
status: 'success' as const,
|
||||
@@ -8,6 +13,16 @@ const { runAgentProcessMock, writeTasksSnapshotMock, loggerDebugMock } =
|
||||
})),
|
||||
writeTasksSnapshotMock: vi.fn(),
|
||||
loggerDebugMock: vi.fn(),
|
||||
checkGitHubActionsRunMock: vi.fn(
|
||||
async (): Promise<{
|
||||
terminal: boolean;
|
||||
resultSummary: string;
|
||||
completionMessage?: string;
|
||||
}> => ({
|
||||
terminal: false,
|
||||
resultSummary: 'GitHub Actions run 123 is in_progress',
|
||||
}),
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('./provider-fallback.js', () => ({
|
||||
@@ -90,6 +105,10 @@ vi.mock('./logger.js', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('./github-ci.js', () => ({
|
||||
checkGitHubActionsRun: checkGitHubActionsRunMock,
|
||||
}));
|
||||
|
||||
import { _initTestDatabase, createTask, getTaskById } from './db.js';
|
||||
import * as providerFallback from './provider-fallback.js';
|
||||
import * as codexTokenRotation from './codex-token-rotation.js';
|
||||
@@ -113,6 +132,11 @@ describe('task scheduler', () => {
|
||||
runAgentProcessMock.mockClear();
|
||||
writeTasksSnapshotMock.mockClear();
|
||||
loggerDebugMock.mockClear();
|
||||
checkGitHubActionsRunMock.mockClear();
|
||||
checkGitHubActionsRunMock.mockResolvedValue({
|
||||
terminal: false,
|
||||
resultSummary: 'GitHub Actions run 123 is in_progress',
|
||||
});
|
||||
vi.mocked(providerFallback.markPrimaryCooldown).mockClear();
|
||||
vi.mocked(providerFallback.getActiveProvider).mockResolvedValue('claude');
|
||||
vi.mocked(providerFallback.isFallbackEnabled).mockReturnValue(true);
|
||||
@@ -838,6 +862,148 @@ Check the run.
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the host-driven GitHub watcher path without spawning an agent', async () => {
|
||||
const dueAt = new Date(Date.now() - 60_000).toISOString();
|
||||
createTask({
|
||||
id: 'task-github-running',
|
||||
group_folder: 'shared-group',
|
||||
chat_jid: 'shared@g.us',
|
||||
agent_type: 'codex',
|
||||
ci_provider: 'github',
|
||||
ci_metadata: JSON.stringify({
|
||||
repo: 'owner/repo',
|
||||
run_id: 123456,
|
||||
}),
|
||||
prompt: `
|
||||
[BACKGROUND CI WATCH]
|
||||
|
||||
Watch target:
|
||||
GitHub Actions run 123456
|
||||
|
||||
Task ID:
|
||||
task-github-running
|
||||
|
||||
Check instructions:
|
||||
Managed by host-driven watcher.
|
||||
`.trim(),
|
||||
schedule_type: 'interval',
|
||||
schedule_value: '15000',
|
||||
context_mode: 'isolated',
|
||||
next_run: dueAt,
|
||||
status: 'active',
|
||||
created_at: '2026-02-22T00:00:00.000Z',
|
||||
});
|
||||
|
||||
const enqueueTask = vi.fn(
|
||||
async (_groupJid: string, _taskId: string, fn: () => Promise<void>) => {
|
||||
await fn();
|
||||
},
|
||||
);
|
||||
|
||||
startSchedulerLoop({
|
||||
serviceAgentType: 'codex',
|
||||
registeredGroups: () => ({
|
||||
'shared@g.us': {
|
||||
name: 'Shared',
|
||||
folder: 'shared-group',
|
||||
trigger: '@Codex',
|
||||
added_at: '2026-02-22T00:00:00.000Z',
|
||||
agentType: 'codex',
|
||||
},
|
||||
}),
|
||||
getSessions: () => ({}),
|
||||
queue: { enqueueTask } as any,
|
||||
onProcess: () => {},
|
||||
sendMessage: async () => {},
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
expect(checkGitHubActionsRunMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'task-github-running',
|
||||
ci_provider: 'github',
|
||||
}),
|
||||
);
|
||||
expect(runAgentProcessMock).not.toHaveBeenCalled();
|
||||
|
||||
const task = getTaskById('task-github-running');
|
||||
expect(task).toBeDefined();
|
||||
expect(task?.next_run).not.toBe(dueAt);
|
||||
});
|
||||
|
||||
it('sends a final message and deletes terminal GitHub watcher tasks', async () => {
|
||||
checkGitHubActionsRunMock.mockResolvedValueOnce({
|
||||
terminal: true,
|
||||
resultSummary: '성공: owner/repo run 654321',
|
||||
completionMessage: 'CI 완료: GitHub Actions run 654321\n판정: 성공',
|
||||
});
|
||||
|
||||
const dueAt = new Date(Date.now() - 60_000).toISOString();
|
||||
createTask({
|
||||
id: 'task-github-complete',
|
||||
group_folder: 'shared-group',
|
||||
chat_jid: 'shared@g.us',
|
||||
agent_type: 'codex',
|
||||
ci_provider: 'github',
|
||||
ci_metadata: JSON.stringify({
|
||||
repo: 'owner/repo',
|
||||
run_id: 654321,
|
||||
}),
|
||||
prompt: `
|
||||
[BACKGROUND CI WATCH]
|
||||
|
||||
Watch target:
|
||||
GitHub Actions run 654321
|
||||
|
||||
Task ID:
|
||||
task-github-complete
|
||||
|
||||
Check instructions:
|
||||
Managed by host-driven watcher.
|
||||
`.trim(),
|
||||
schedule_type: 'interval',
|
||||
schedule_value: '15000',
|
||||
context_mode: 'isolated',
|
||||
next_run: dueAt,
|
||||
status: 'active',
|
||||
created_at: '2026-02-22T00:00:00.000Z',
|
||||
});
|
||||
|
||||
const sendMessage = vi.fn(async () => {});
|
||||
const enqueueTask = vi.fn(
|
||||
async (_groupJid: string, _taskId: string, fn: () => Promise<void>) => {
|
||||
await fn();
|
||||
},
|
||||
);
|
||||
|
||||
startSchedulerLoop({
|
||||
serviceAgentType: 'codex',
|
||||
registeredGroups: () => ({
|
||||
'shared@g.us': {
|
||||
name: 'Shared',
|
||||
folder: 'shared-group',
|
||||
trigger: '@Codex',
|
||||
added_at: '2026-02-22T00:00:00.000Z',
|
||||
agentType: 'codex',
|
||||
},
|
||||
}),
|
||||
getSessions: () => ({}),
|
||||
queue: { enqueueTask } as any,
|
||||
onProcess: () => {},
|
||||
sendMessage,
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith(
|
||||
'shared@g.us',
|
||||
'CI 완료: GitHub Actions run 654321\n판정: 성공',
|
||||
);
|
||||
expect(runAgentProcessMock).not.toHaveBeenCalled();
|
||||
expect(getTaskById('task-github-complete')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('deletes active tasks that exceed max duration before they run', async () => {
|
||||
const enqueueTask = vi.fn();
|
||||
createTask({
|
||||
|
||||
@@ -71,9 +71,11 @@ import {
|
||||
import {
|
||||
getTaskQueueJid,
|
||||
getTaskRuntimeTaskId,
|
||||
isGitHubCiTask,
|
||||
shouldUseTaskScopedSession,
|
||||
} from './task-watch-status.js';
|
||||
import { AgentType, RegisteredGroup, ScheduledTask } from './types.js';
|
||||
import { checkGitHubActionsRun } from './github-ci.js';
|
||||
export {
|
||||
extractWatchCiTarget,
|
||||
getTaskQueueJid,
|
||||
@@ -779,6 +781,94 @@ async function runTask(
|
||||
updateTaskAfterRun(task.id, nextRun, resultSummary);
|
||||
}
|
||||
|
||||
async function runGithubCiTask(
|
||||
task: ScheduledTask,
|
||||
deps: SchedulerDependencies,
|
||||
): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
let result: string | null = null;
|
||||
let error: string | null = null;
|
||||
let completedAndDeleted = false;
|
||||
const statusTracker = createTaskStatusTracker(task, {
|
||||
sendTrackedMessage: deps.sendTrackedMessage,
|
||||
editTrackedMessage: deps.editTrackedMessage,
|
||||
});
|
||||
|
||||
try {
|
||||
await statusTracker.update('checking');
|
||||
|
||||
const check = await checkGitHubActionsRun(task);
|
||||
result = check.resultSummary;
|
||||
|
||||
if (check.terminal) {
|
||||
await statusTracker.update('completed');
|
||||
if (check.completionMessage) {
|
||||
await deps.sendMessage(task.chat_jid, check.completionMessage);
|
||||
}
|
||||
deleteTask(task.id);
|
||||
completedAndDeleted = true;
|
||||
logger.info(
|
||||
{
|
||||
taskId: task.id,
|
||||
groupFolder: task.group_folder,
|
||||
durationMs: Date.now() - startTime,
|
||||
},
|
||||
'GitHub CI watcher completed and deleted',
|
||||
);
|
||||
} else {
|
||||
logger.info(
|
||||
{
|
||||
taskId: task.id,
|
||||
groupFolder: task.group_folder,
|
||||
result,
|
||||
},
|
||||
'GitHub CI watcher checked non-terminal run',
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
error = getErrorMessage(err);
|
||||
logger.error({ taskId: task.id, error }, 'GitHub CI watcher failed');
|
||||
}
|
||||
|
||||
const durationMs = Date.now() - startTime;
|
||||
const currentTask = getTaskById(task.id);
|
||||
const nextRun = currentTask ? computeNextRun(task) : null;
|
||||
|
||||
if (!currentTask) {
|
||||
if (!completedAndDeleted) {
|
||||
await statusTracker.update('completed');
|
||||
}
|
||||
logger.debug(
|
||||
{ taskId: task.id },
|
||||
'GitHub CI watcher deleted during execution, skipping persistence',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
await statusTracker.update('retrying', nextRun);
|
||||
} else if (nextRun) {
|
||||
await statusTracker.update('waiting', nextRun);
|
||||
} else {
|
||||
await statusTracker.update('completed');
|
||||
}
|
||||
|
||||
logTaskRun({
|
||||
task_id: task.id,
|
||||
run_at: new Date().toISOString(),
|
||||
duration_ms: durationMs,
|
||||
status: error ? 'error' : 'success',
|
||||
result,
|
||||
error,
|
||||
});
|
||||
|
||||
updateTaskAfterRun(
|
||||
task.id,
|
||||
nextRun,
|
||||
error ? `Error: ${error}` : result || 'Completed',
|
||||
);
|
||||
}
|
||||
|
||||
let schedulerRunning = false;
|
||||
let schedulerTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let schedulerLoopFn: (() => Promise<void>) | null = null;
|
||||
@@ -864,7 +954,10 @@ export function startSchedulerLoop(deps: SchedulerDependencies): void {
|
||||
deps.queue.enqueueTask(
|
||||
getTaskQueueJid(currentTask),
|
||||
currentTask.id,
|
||||
() => runTask(currentTask, deps),
|
||||
() =>
|
||||
isGitHubCiTask(currentTask)
|
||||
? runGithubCiTask(currentTask, deps)
|
||||
: runTask(currentTask, deps),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -16,6 +16,12 @@ export function isWatchCiTask(task: Pick<ScheduledTask, 'prompt'>): boolean {
|
||||
return task.prompt.startsWith(WATCH_CI_PREFIX);
|
||||
}
|
||||
|
||||
export function isGitHubCiTask(
|
||||
task: Pick<ScheduledTask, 'ci_provider'>,
|
||||
): boolean {
|
||||
return task.ci_provider === 'github';
|
||||
}
|
||||
|
||||
export function isTaskStatusControlMessage(content: string): boolean {
|
||||
return content.startsWith(TASK_STATUS_MESSAGE_PREFIX);
|
||||
}
|
||||
|
||||
@@ -72,6 +72,8 @@ export interface ScheduledTask {
|
||||
group_folder: string;
|
||||
chat_jid: string;
|
||||
agent_type: AgentType | null;
|
||||
ci_provider?: 'github' | null;
|
||||
ci_metadata?: string | null;
|
||||
max_duration_ms?: number | null;
|
||||
status_message_id: string | null;
|
||||
status_started_at: string | null;
|
||||
|
||||
Reference in New Issue
Block a user