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
|
target: z
|
||||||
.string()
|
.string()
|
||||||
|
.optional()
|
||||||
.describe(
|
.describe(
|
||||||
'What to watch, for example "PR #123 checks" or "GitHub Actions run 987654321".',
|
'What to watch, for example "PR #123 checks" or "GitHub Actions run 987654321".',
|
||||||
),
|
),
|
||||||
check_instructions: z
|
check_instructions: z
|
||||||
.string()
|
.string()
|
||||||
|
.optional()
|
||||||
.describe(
|
.describe(
|
||||||
'Exact steps or commands to check status and what details matter when it finishes.',
|
'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
|
poll_interval_seconds: z
|
||||||
.number()
|
.number()
|
||||||
.int()
|
.int()
|
||||||
.min(30)
|
.min(10)
|
||||||
.max(3600)
|
.max(3600)
|
||||||
.default(60)
|
.optional()
|
||||||
.describe('How often to poll in seconds. Default 60, minimum 30.'),
|
.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
|
context_mode: z
|
||||||
.enum(['group', 'isolated'])
|
.enum(['group', 'isolated'])
|
||||||
.default(DEFAULT_WATCH_CI_CONTEXT_MODE)
|
.default(DEFAULT_WATCH_CI_CONTEXT_MODE)
|
||||||
@@ -198,9 +230,59 @@ server.tool(
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
async (args) => {
|
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;
|
let pollSeconds: number;
|
||||||
try {
|
try {
|
||||||
pollSeconds = normalizeWatchCiIntervalSeconds(args.poll_interval_seconds);
|
pollSeconds = normalizeWatchCiIntervalSeconds(args.poll_interval_seconds, {
|
||||||
|
ciProvider: args.ci_provider,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
@@ -218,8 +300,8 @@ server.tool(
|
|||||||
const taskId = `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
const taskId = `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
const prompt = buildCiWatchPrompt({
|
const prompt = buildCiWatchPrompt({
|
||||||
taskId,
|
taskId,
|
||||||
target: args.target,
|
target,
|
||||||
checkInstructions: args.check_instructions,
|
checkInstructions,
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
@@ -229,6 +311,13 @@ server.tool(
|
|||||||
schedule_type: 'interval' as const,
|
schedule_type: 'interval' as const,
|
||||||
schedule_value: String(pollSeconds * 1000),
|
schedule_value: String(pollSeconds * 1000),
|
||||||
context_mode: args.context_mode || DEFAULT_WATCH_CI_CONTEXT_MODE,
|
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,
|
targetJid,
|
||||||
createdBy: groupFolder,
|
createdBy: groupFolder,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
|
|||||||
@@ -1,17 +1,34 @@
|
|||||||
export const DEFAULT_WATCH_CI_INTERVAL_SECONDS = 60;
|
export const DEFAULT_WATCH_CI_INTERVAL_SECONDS = 60;
|
||||||
export const MIN_WATCH_CI_INTERVAL_SECONDS = 30;
|
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 MAX_WATCH_CI_INTERVAL_SECONDS = 3600;
|
||||||
export const DEFAULT_WATCH_CI_CONTEXT_MODE = 'isolated';
|
export const DEFAULT_WATCH_CI_CONTEXT_MODE = 'isolated';
|
||||||
|
|
||||||
|
export interface NormalizeWatchCiIntervalOptions {
|
||||||
|
ciProvider?: 'github';
|
||||||
|
}
|
||||||
|
|
||||||
export interface BuildCiWatchPromptArgs {
|
export interface BuildCiWatchPromptArgs {
|
||||||
taskId: string;
|
taskId: string;
|
||||||
target: string;
|
target: string;
|
||||||
checkInstructions: 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) {
|
if (seconds === undefined) {
|
||||||
return DEFAULT_WATCH_CI_INTERVAL_SECONDS;
|
return defaultSeconds;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Number.isInteger(seconds)) {
|
if (!Number.isInteger(seconds)) {
|
||||||
@@ -19,11 +36,11 @@ export function normalizeWatchCiIntervalSeconds(seconds?: number): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
seconds < MIN_WATCH_CI_INTERVAL_SECONDS ||
|
seconds < minSeconds ||
|
||||||
seconds > MAX_WATCH_CI_INTERVAL_SECONDS
|
seconds > MAX_WATCH_CI_INTERVAL_SECONDS
|
||||||
) {
|
) {
|
||||||
throw new Error(
|
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 {
|
import {
|
||||||
buildCiWatchPrompt,
|
buildCiWatchPrompt,
|
||||||
DEFAULT_WATCH_CI_CONTEXT_MODE,
|
DEFAULT_WATCH_CI_CONTEXT_MODE,
|
||||||
|
DEFAULT_GITHUB_WATCH_CI_INTERVAL_SECONDS,
|
||||||
normalizeWatchCiIntervalSeconds,
|
normalizeWatchCiIntervalSeconds,
|
||||||
} from '../src/watch-ci.js';
|
} from '../src/watch-ci.js';
|
||||||
|
|
||||||
@@ -35,6 +36,12 @@ describe('watch-ci helpers', () => {
|
|||||||
expect(normalizeWatchCiIntervalSeconds()).toBe(60);
|
expect(normalizeWatchCiIntervalSeconds()).toBe(60);
|
||||||
expect(normalizeWatchCiIntervalSeconds(30)).toBe(30);
|
expect(normalizeWatchCiIntervalSeconds(30)).toBe(30);
|
||||||
expect(normalizeWatchCiIntervalSeconds(600)).toBe(600);
|
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', () => {
|
it('rejects invalid poll intervals', () => {
|
||||||
@@ -45,5 +52,8 @@ describe('watch-ci helpers', () => {
|
|||||||
/between 30 and 3600/i,
|
/between 30 and 3600/i,
|
||||||
);
|
);
|
||||||
expect(() => normalizeWatchCiIntervalSeconds(30.5)).toThrow(/integer/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');
|
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', () => {
|
it('deletes a task and its run logs', () => {
|
||||||
createTask({
|
createTask({
|
||||||
id: 'task-3',
|
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,
|
group_folder TEXT NOT NULL,
|
||||||
chat_jid TEXT NOT NULL,
|
chat_jid TEXT NOT NULL,
|
||||||
agent_type TEXT,
|
agent_type TEXT,
|
||||||
|
ci_provider TEXT,
|
||||||
|
ci_metadata TEXT,
|
||||||
max_duration_ms INTEGER,
|
max_duration_ms INTEGER,
|
||||||
status_message_id TEXT,
|
status_message_id TEXT,
|
||||||
status_started_at TEXT,
|
status_started_at TEXT,
|
||||||
@@ -205,6 +207,18 @@ function createSchema(database: Database.Database): void {
|
|||||||
/* column already exists */
|
/* 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 {
|
try {
|
||||||
database.exec(
|
database.exec(
|
||||||
`ALTER TABLE scheduled_tasks ADD COLUMN max_duration_ms INTEGER`,
|
`ALTER TABLE scheduled_tasks ADD COLUMN max_duration_ms INTEGER`,
|
||||||
@@ -877,11 +891,15 @@ export function createTask(
|
|||||||
| 'last_run'
|
| 'last_run'
|
||||||
| 'last_result'
|
| 'last_result'
|
||||||
| 'agent_type'
|
| 'agent_type'
|
||||||
|
| 'ci_provider'
|
||||||
|
| 'ci_metadata'
|
||||||
| 'max_duration_ms'
|
| 'max_duration_ms'
|
||||||
| 'status_message_id'
|
| 'status_message_id'
|
||||||
| 'status_started_at'
|
| 'status_started_at'
|
||||||
> & {
|
> & {
|
||||||
agent_type?: AgentType | null;
|
agent_type?: AgentType | null;
|
||||||
|
ci_provider?: ScheduledTask['ci_provider'];
|
||||||
|
ci_metadata?: string | null;
|
||||||
max_duration_ms?: number | null;
|
max_duration_ms?: number | null;
|
||||||
status_message_id?: string | null;
|
status_message_id?: string | null;
|
||||||
status_started_at?: string | null;
|
status_started_at?: string | null;
|
||||||
@@ -889,14 +907,16 @@ export function createTask(
|
|||||||
): void {
|
): void {
|
||||||
db.prepare(
|
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)
|
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`,
|
`,
|
||||||
).run(
|
).run(
|
||||||
task.id,
|
task.id,
|
||||||
task.group_folder,
|
task.group_folder,
|
||||||
task.chat_jid,
|
task.chat_jid,
|
||||||
task.agent_type || SERVICE_AGENT_TYPE,
|
task.agent_type || SERVICE_AGENT_TYPE,
|
||||||
|
task.ci_provider ?? null,
|
||||||
|
task.ci_metadata ?? null,
|
||||||
task.max_duration_ms ?? null,
|
task.max_duration_ms ?? null,
|
||||||
task.status_message_id || null,
|
task.status_message_id || null,
|
||||||
task.status_started_at || null,
|
task.status_started_at || null,
|
||||||
@@ -960,6 +980,7 @@ export function updateTask(
|
|||||||
| 'next_run'
|
| 'next_run'
|
||||||
| 'status'
|
| 'status'
|
||||||
| 'suspended_until'
|
| 'suspended_until'
|
||||||
|
| 'ci_metadata'
|
||||||
>
|
>
|
||||||
>,
|
>,
|
||||||
): void {
|
): void {
|
||||||
@@ -990,6 +1011,10 @@ export function updateTask(
|
|||||||
fields.push('suspended_until = ?');
|
fields.push('suspended_until = ?');
|
||||||
values.push(updates.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;
|
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);
|
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 () => {
|
it('does not assign a max duration to regular scheduled tasks', async () => {
|
||||||
await processTaskIpc(
|
await processTaskIpc(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -286,6 +286,8 @@ export async function processTaskIpc(
|
|||||||
schedule_type?: string;
|
schedule_type?: string;
|
||||||
schedule_value?: string;
|
schedule_value?: string;
|
||||||
context_mode?: string;
|
context_mode?: string;
|
||||||
|
ci_provider?: 'github';
|
||||||
|
ci_metadata?: string;
|
||||||
groupFolder?: string;
|
groupFolder?: string;
|
||||||
chatJid?: string;
|
chatJid?: string;
|
||||||
targetJid?: string;
|
targetJid?: string;
|
||||||
@@ -404,6 +406,8 @@ export async function processTaskIpc(
|
|||||||
group_folder: targetFolder,
|
group_folder: targetFolder,
|
||||||
chat_jid: resolvedTargetJid,
|
chat_jid: resolvedTargetJid,
|
||||||
agent_type: targetGroupEntry.agentType || SERVICE_AGENT_TYPE,
|
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 })
|
max_duration_ms: isWatchCiTask({ prompt: data.prompt })
|
||||||
? DEFAULT_WATCH_CI_MAX_DURATION_MS
|
? DEFAULT_WATCH_CI_MAX_DURATION_MS
|
||||||
: null,
|
: null,
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
const { runAgentProcessMock, writeTasksSnapshotMock, loggerDebugMock } =
|
const {
|
||||||
|
runAgentProcessMock,
|
||||||
|
writeTasksSnapshotMock,
|
||||||
|
loggerDebugMock,
|
||||||
|
checkGitHubActionsRunMock,
|
||||||
|
} =
|
||||||
vi.hoisted(() => ({
|
vi.hoisted(() => ({
|
||||||
runAgentProcessMock: vi.fn(async () => ({
|
runAgentProcessMock: vi.fn(async () => ({
|
||||||
status: 'success' as const,
|
status: 'success' as const,
|
||||||
@@ -8,6 +13,16 @@ const { runAgentProcessMock, writeTasksSnapshotMock, loggerDebugMock } =
|
|||||||
})),
|
})),
|
||||||
writeTasksSnapshotMock: vi.fn(),
|
writeTasksSnapshotMock: vi.fn(),
|
||||||
loggerDebugMock: 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', () => ({
|
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 { _initTestDatabase, createTask, getTaskById } from './db.js';
|
||||||
import * as providerFallback from './provider-fallback.js';
|
import * as providerFallback from './provider-fallback.js';
|
||||||
import * as codexTokenRotation from './codex-token-rotation.js';
|
import * as codexTokenRotation from './codex-token-rotation.js';
|
||||||
@@ -113,6 +132,11 @@ describe('task scheduler', () => {
|
|||||||
runAgentProcessMock.mockClear();
|
runAgentProcessMock.mockClear();
|
||||||
writeTasksSnapshotMock.mockClear();
|
writeTasksSnapshotMock.mockClear();
|
||||||
loggerDebugMock.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.markPrimaryCooldown).mockClear();
|
||||||
vi.mocked(providerFallback.getActiveProvider).mockResolvedValue('claude');
|
vi.mocked(providerFallback.getActiveProvider).mockResolvedValue('claude');
|
||||||
vi.mocked(providerFallback.isFallbackEnabled).mockReturnValue(true);
|
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 () => {
|
it('deletes active tasks that exceed max duration before they run', async () => {
|
||||||
const enqueueTask = vi.fn();
|
const enqueueTask = vi.fn();
|
||||||
createTask({
|
createTask({
|
||||||
|
|||||||
@@ -71,9 +71,11 @@ import {
|
|||||||
import {
|
import {
|
||||||
getTaskQueueJid,
|
getTaskQueueJid,
|
||||||
getTaskRuntimeTaskId,
|
getTaskRuntimeTaskId,
|
||||||
|
isGitHubCiTask,
|
||||||
shouldUseTaskScopedSession,
|
shouldUseTaskScopedSession,
|
||||||
} from './task-watch-status.js';
|
} from './task-watch-status.js';
|
||||||
import { AgentType, RegisteredGroup, ScheduledTask } from './types.js';
|
import { AgentType, RegisteredGroup, ScheduledTask } from './types.js';
|
||||||
|
import { checkGitHubActionsRun } from './github-ci.js';
|
||||||
export {
|
export {
|
||||||
extractWatchCiTarget,
|
extractWatchCiTarget,
|
||||||
getTaskQueueJid,
|
getTaskQueueJid,
|
||||||
@@ -779,6 +781,94 @@ async function runTask(
|
|||||||
updateTaskAfterRun(task.id, nextRun, resultSummary);
|
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 schedulerRunning = false;
|
||||||
let schedulerTimer: ReturnType<typeof setTimeout> | null = null;
|
let schedulerTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let schedulerLoopFn: (() => Promise<void>) | null = null;
|
let schedulerLoopFn: (() => Promise<void>) | null = null;
|
||||||
@@ -864,7 +954,10 @@ export function startSchedulerLoop(deps: SchedulerDependencies): void {
|
|||||||
deps.queue.enqueueTask(
|
deps.queue.enqueueTask(
|
||||||
getTaskQueueJid(currentTask),
|
getTaskQueueJid(currentTask),
|
||||||
currentTask.id,
|
currentTask.id,
|
||||||
() => runTask(currentTask, deps),
|
() =>
|
||||||
|
isGitHubCiTask(currentTask)
|
||||||
|
? runGithubCiTask(currentTask, deps)
|
||||||
|
: runTask(currentTask, deps),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ export function isWatchCiTask(task: Pick<ScheduledTask, 'prompt'>): boolean {
|
|||||||
return task.prompt.startsWith(WATCH_CI_PREFIX);
|
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 {
|
export function isTaskStatusControlMessage(content: string): boolean {
|
||||||
return content.startsWith(TASK_STATUS_MESSAGE_PREFIX);
|
return content.startsWith(TASK_STATUS_MESSAGE_PREFIX);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ export interface ScheduledTask {
|
|||||||
group_folder: string;
|
group_folder: string;
|
||||||
chat_jid: string;
|
chat_jid: string;
|
||||||
agent_type: AgentType | null;
|
agent_type: AgentType | null;
|
||||||
|
ci_provider?: 'github' | null;
|
||||||
|
ci_metadata?: string | null;
|
||||||
max_duration_ms?: number | null;
|
max_duration_ms?: number | null;
|
||||||
status_message_id: string | null;
|
status_message_id: string | null;
|
||||||
status_started_at: string | null;
|
status_started_at: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user