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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user