Skip to content

Commit f70ed0b

Browse files
committed
feat: enforce strict typing in workflows and modularize slash commands
1 parent 6fd5043 commit f70ed0b

50 files changed

Lines changed: 6339 additions & 375 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Rule: Webhook Refactor Standards
2+
3+
- Every automation MUST extend `BaseAutomation` and live in its own isolated `.ts` file.
4+
- **Strict Conditional Routing**: The webhook router must NEVER contain conditional `if/else` logic regarding payload traits or repos. It must act strictly as a dumb dispatcher reading from D1, and it MUST call `await instance.shouldExecute()` to let the class decide if it applies to the payload.
5+
- All frontend components must use Shadcn strictly, ensuring the global dark theme is maintained without custom raw CSS.
6+
- The workflow execution logs must always be sorted by `createdAt DESC`.
7+
- **Global Automations**: Use the AutomationArchitect agent via the Workflows Dashboard to rapidly scaffold new automation files and ensure they are added to `AutomationRegistry.ts`.
Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,26 @@
1-
# .agent/workflows/implement-feature.md
1+
---
2+
description: How to implement a new Global Automation Workflow for GitHub events
3+
---
24

3-
# Implement BaseAgent-Powered Jules Overseer & Tiered Triage
5+
# Workflow: Implementing a New Automation Workflow
46

5-
## Context
7+
To add a new GitHub webhook automation to the repository, follow these standards:
68

7-
We are refactoring the `JulesOverseer` to inherit from the newly established `BaseAgent` class. This provides the Overseer with immediate, native access to the `@cloudflare/mcp-server-cloudflare` without writing custom HTTP fetch loops. We are also formalizing the Tier 1 / Tier 2 delegation strategy in the SRE prompts.
9+
1. **Domain Class Creation**:
10+
- Create a new TypeScript file in `backend/src/automations/[domain]/[WorkflowName]Automation.ts`.
11+
- Ensure the new class extends `BaseAutomation` from `@/core/BaseAutomation`.
812

9-
## Execution Steps
13+
2. **Lifecycle Hooks**:
14+
- Implement the mandatory `shouldExecute()` function containing all conditional logic (checking the payload, identifying the target repo, checking if specific files exist contextually, etc.). The router will bypass execution if this returns `false`.
15+
- Implement the `execute()` function containing the main action. Include detailed structured logging. Dual-authentication will automatically provide `this.octokit` (App Installation by default, fallback to PAT if configured in D1).
1016

11-
1. **Update Prompts**
12-
- Inject the updated `HealthDiagnostician` prompt into the agent's definition, establishing the hard logic branch between SMALL fixes (execute internally via Octokit) and COMPLEX fixes (delegate to `JulesService`).
17+
3. **Automation Registry**:
18+
- Add your class name into `backend/src/core/AutomationRegistry.ts` under the registry object mapping the class name to its exported module.
19+
- If the automation must run for every user invariably without opt-in (e.g. system telemetry), add it to the `SystemAutomations` array within the registry.
1320

14-
2. **Refactor `JulesOverseer.ts`**
15-
- Replace the file contents with the provided code.
16-
- Note the simplified `evaluateStuckJules` method. It now calls `await this.runTextWithModel({...})`, letting `BaseAgent` handle the model routing, the ReAct loop, and the Cloudflare MCP queries.
17-
- The method writes to the `alerts` table when `status === 'ready_for_pr'`.
21+
4. **Frontend Activation**:
22+
- The UI Dashboard (`/workflows`) dynamically discovers missing configured classes via the registry and will populate the new automation in gray.
23+
- An admin must visit the Workflows tab and toggle it "Active" with the correct Identity pattern (App / PAT) to provision it globally.
1824

19-
3. **Verify Execution**
20-
- Ensure the `delegate_to_jules` tool in `HealthDiagnostician` sets `autoPr: false` so the job lands in the Overseer's queue.
21-
- When the chron triggers `/schedule/check`, verify via `wrangler tail` that the Overseer successfully pulls the stuck context, invokes `runTextWithModel`, and sends the generated strategy back to Jules via `julesService.sendMessage`.
25+
5. **Agentic Workflows**:
26+
- Users can now use the **Automation Architect** agent in the `/workflows` sidebar to generate these class files automagically based on natural language prompts.

backend/drizzle.config.webhooks.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import { defineConfig } from "drizzle-kit";
33
export default defineConfig({
44
schema: [
55
"./backend/src/db/schemas/github/webhooks.ts",
6-
"./backend/src/db/schemas/logs/audit.ts"
6+
"./backend/src/db/schemas/logs/audit.ts",
7+
"./backend/src/db/schemas/webhooks/automations.ts"
78
],
89
out: "./migrations/webhooks",
910
dialect: "sqlite",

backend/eslint.config.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ export default tseslint.config(
1212
languageOptions: {
1313
ecmaVersion: 'latest',
1414
sourceType: 'module',
15+
parserOptions: {
16+
project: '../tsconfig.json',
17+
tsconfigRootDir: import.meta.dirname,
18+
},
1519
},
1620
rules: {
1721
// Downgrades the 'any' error to a warning to unblock rapid iteration

backend/src/ai/agents/Gemini.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,15 +37,15 @@ export class GeminiAgent extends BaseAgent<Env, GeminiState> {
3737
* @returns The agent's response and updated history.
3838
*/
3939
@callable()
40-
async chat(prompt: string, history?: any[]) {
40+
async chat(prompt: string, history?: any[], customInstructions?: string) {
4141
try {
4242
await this.setState({ ...this.state, status: "running" });
4343

4444
const fullResponse = await this.runTextWithModel({
4545
provider: "gemini",
4646
model: "google-ai-studio/gemini-2.5-flash",
4747
name: "cf_gateway_agent",
48-
instructions: "You are an elite autonomous agent powered by Cloudflare AI Gateway. Provide structured, highly accurate responses.",
48+
instructions: customInstructions || "You are an elite autonomous agent powered by Cloudflare AI Gateway. Provide structured, highly accurate responses.",
4949
prompt: prompt,
5050
});
5151

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { BaseAutomation } from '@/core/BaseAutomation';
2+
import { runBugHunterWorkflow, shouldRunBugHunter } from "@/routes/api/webhooks/workflows/bug-hunter";
3+
4+
export class BugHunter extends BaseAutomation {
5+
private deliveryId: string;
6+
7+
constructor(env: Env, payload: unknown, installationId: number | undefined, usePat: boolean, deliveryId: string) {
8+
super(env, payload, installationId, usePat);
9+
this.deliveryId = deliveryId;
10+
}
11+
12+
async shouldExecute(): Promise<boolean> {
13+
return shouldRunBugHunter(this.payload);
14+
}
15+
16+
async execute(): Promise<void> {
17+
try {
18+
await runBugHunterWorkflow({
19+
env: this.env,
20+
payload: this.payload,
21+
deliveryId: this.deliveryId,
22+
});
23+
await this.logExecution('success', 'BugHunter workflow dispatched');
24+
} catch (error: unknown) {
25+
console.error('[BugHunter] Workflow failed:', error);
26+
await this.logExecution('failure', `BugHunter failed: ${error.message}`);
27+
}
28+
}
29+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { BaseAutomation } from '@/core/BaseAutomation';
2+
import { JulesService } from "@/services/jules/jules";
3+
import { GitHubConditionals } from '@/utils/github/conditionals';
4+
5+
interface JulesPayload {
6+
action?: string;
7+
issue?: { number?: number };
8+
comment?: { body?: string; user?: { login?: string; type?: string; }};
9+
repository?: { name?: string; owner?: { login?: string; } };
10+
}
11+
12+
export class JulesAutoFix extends BaseAutomation {
13+
async shouldExecute(): Promise<boolean> {
14+
const payload = this.payload as JulesPayload;
15+
if (!payload.comment) return false;
16+
17+
const isGemini = GitHubConditionals.isBotOrAgentUser(payload.comment?.user);
18+
19+
return !!isGemini && payload.action === 'created' && !!payload.issue?.number;
20+
}
21+
22+
async execute(): Promise<void> {
23+
const payload = this.payload as JulesPayload;
24+
const feedback = payload.comment?.body;
25+
const prNumber = payload.issue?.number;
26+
27+
if (feedback && (feedback.includes('Review') || feedback.includes('suggestion'))) {
28+
try {
29+
const julesService = JulesService.getInstance(this.env);
30+
const prompt = `Gemini Code Assist provided a review on PR #${prNumber}.\n\nFeedback:\n${feedback}\n\nPlease apply the fixes suggested in the feedback.`;
31+
await julesService.startSession({
32+
prompt: prompt,
33+
repo: {
34+
owner: payload.repository?.owner?.login || '',
35+
repo: payload.repository?.name || '',
36+
},
37+
autoPr: true
38+
});
39+
await this.logExecution('success', 'Jules auto-fix session started', prNumber);
40+
} catch (err: unknown) {
41+
console.error(`[Jules] Failed to trigger auto-fix:`, err);
42+
await this.logExecution('failure', `Jules startSession failed: ${err instanceof Error ? err.message : String(err)}`, prNumber);
43+
}
44+
} else {
45+
await this.logExecution('skipped', 'No actionable feedback keywords found', prNumber);
46+
}
47+
}
48+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { BaseAutomation } from '@/core/BaseAutomation';
2+
import { SlashCommandRouter } from "@/routes/api/webhooks/workflows/gardener/router";
3+
import { withCompatOctokit } from "@/services/octokit/compat";
4+
5+
export class SlashCommand extends BaseAutomation {
6+
private c: unknown;
7+
8+
constructor(env: Env, payload: unknown, installationId: number | undefined, usePat: boolean, c: unknown) {
9+
super(env, payload, installationId, usePat);
10+
this.c = c;
11+
}
12+
13+
async shouldExecute(): Promise<boolean> {
14+
const isIssue = !!this.payload.issue && !this.payload.comment;
15+
const isIssueComment = !!this.payload.comment;
16+
17+
if (isIssue) {
18+
if (this.payload.action === 'opened' || this.payload.action === 'edited') {
19+
return this.payload.issue?.body?.includes('/colby') || false;
20+
}
21+
} else if (isIssueComment) {
22+
if (this.payload.action === 'created') {
23+
return this.payload.comment?.body?.includes('/colby') || false;
24+
}
25+
}
26+
return false;
27+
}
28+
29+
async execute(): Promise<void> {
30+
try {
31+
const octokit = withCompatOctokit(await this.getGitHubClient());
32+
const body = this.payload.comment ? this.payload.comment.body : this.payload.issue?.body;
33+
34+
await SlashCommandRouter.handleAndReply(
35+
body,
36+
{
37+
env: this.env,
38+
executionCtx: { ...(this.c as { executionCtx: unknown }).executionCtx, exports: {} as Record<string, unknown> },
39+
repo: {
40+
owner: this.payload.repository?.owner?.login,
41+
name: this.payload.repository?.name,
42+
defaultBranch: this.payload.repository?.default_branch
43+
},
44+
octokit
45+
},
46+
{ issueNumber: this.payload.issue?.number, issueBody: this.payload.issue?.body }
47+
);
48+
await this.logExecution('success', 'Slash command processed');
49+
} catch (err: unknown) {
50+
console.error('[SlashCommand] Failed:', err);
51+
await this.logExecution('failure', `Slash command failed: ${err.message}`);
52+
}
53+
}
54+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { BaseAutomation } from '@/core/BaseAutomation';
2+
import { getDb } from '@db';
3+
import { tasks, repos } from '@db/schema';
4+
import { eq, and } from 'drizzle-orm';
5+
import { generateUuid } from "@/utils/common";
6+
7+
export class TaskSync extends BaseAutomation {
8+
async shouldExecute(): Promise<boolean> {
9+
return !!this.payload.issue && !this.payload.comment;
10+
}
11+
12+
async execute(): Promise<void> {
13+
const issuesPayload = this.payload;
14+
const dbCore = getDb(this.env.DB);
15+
const { TaskStatus, KanbanColumn } = await import('@/types/project-management/enums');
16+
const { StatusMapper } = await import('@services/statusMapper');
17+
18+
try {
19+
const repoRecord = await dbCore.select()
20+
.from(repos)
21+
.where(and(eq(repos.owner, issuesPayload.repository.owner.login), eq(repos.name, issuesPayload.repository.name)))
22+
.limit(1);
23+
24+
if (repoRecord.length) {
25+
const internalRepoId = repoRecord[0].id;
26+
let assignee = issuesPayload.issue.assignee ? issuesPayload.issue.assignee.login : null;
27+
if (issuesPayload.issue.body && issuesPayload.issue.body.includes('/colby')) {
28+
assignee = 'system';
29+
}
30+
31+
let status = TaskStatus.BACKLOG;
32+
let kanbanColumn = KanbanColumn.BACKLOG;
33+
if (issuesPayload.issue.state === 'closed') {
34+
status = TaskStatus.DONE;
35+
kanbanColumn = KanbanColumn.DONE;
36+
} else {
37+
if (assignee) {
38+
status = TaskStatus.TODO;
39+
kanbanColumn = StatusMapper.mapStatusToColumn(status);
40+
}
41+
const actionName = (issuesPayload as Record<string, unknown>).action;
42+
if (actionName === 'assigned' || actionName === 'unassigned') {
43+
kanbanColumn = assignee ? KanbanColumn.PLANNED : KanbanColumn.BACKLOG;
44+
status = StatusMapper.mapColumnToStatus(kanbanColumn);
45+
} else if (issuesPayload.action === 'edited' && kanbanColumn !== KanbanColumn.DONE) {
46+
if (kanbanColumn !== KanbanColumn.BACKLOG) {
47+
status = TaskStatus.IN_PROGRESS;
48+
kanbanColumn = KanbanColumn.IN_PROGRESS;
49+
}
50+
}
51+
}
52+
53+
let endAt: string | undefined;
54+
if (status === TaskStatus.DONE || kanbanColumn === KanbanColumn.DONE) {
55+
endAt = new Date().toISOString();
56+
}
57+
58+
if (issuesPayload.action === 'opened') {
59+
await dbCore.insert(tasks).values({
60+
id: generateUuid(),
61+
repoId: internalRepoId,
62+
title: issuesPayload.issue.title,
63+
description: issuesPayload.issue.body,
64+
status: status,
65+
kanbanColumn: kanbanColumn,
66+
assignee: assignee,
67+
githubIssueId: issuesPayload.issue.number,
68+
githubHtmlUrl: issuesPayload.issue.html_url,
69+
createdAt: issuesPayload.issue.created_at,
70+
updatedAt: issuesPayload.issue.updated_at,
71+
endAt: endAt
72+
});
73+
} else if (['edited', 'closed', 'reopened'].includes(issuesPayload.action!)) {
74+
const updatePayload: Record<string, unknown> = {
75+
title: issuesPayload.issue.title,
76+
description: issuesPayload.issue.body,
77+
status: status,
78+
kanbanColumn: kanbanColumn,
79+
assignee: assignee,
80+
updatedAt: new Date().toISOString(),
81+
endAt: endAt
82+
};
83+
if (status !== TaskStatus.DONE && kanbanColumn !== KanbanColumn.DONE) {
84+
updatePayload.endAt = null;
85+
}
86+
await dbCore.update(tasks)
87+
.set(updatePayload)
88+
.where(and(eq(tasks.repoId, internalRepoId), eq(tasks.githubIssueId, issuesPayload.issue.number)));
89+
}
90+
await this.logExecution('success', 'Task synced to DB', issuesPayload.issue.number);
91+
} else {
92+
await this.logExecution('skipped', 'Repo not tracked internally', issuesPayload.issue.number);
93+
}
94+
} catch (err: unknown) {
95+
console.error('[TaskSync] failed', err);
96+
await this.logExecution('failure', `Update failed: ${err.message}`, issuesPayload.issue?.number);
97+
}
98+
}
99+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { BaseAutomation } from '@/core/BaseAutomation';
2+
import { withCompatOctokit } from "@/services/octokit/compat";
3+
import { appendSignature } from "@/utils/github/signature";
4+
import {
5+
isCodeReviewBot,
6+
formatAgentFixComment,
7+
detectPRAuthorAgent,
8+
type ExtractedReviewComment,
9+
} from "@/routes/api/webhooks/workflows/pr-agent-tagger";
10+
11+
export class AgentTagger extends BaseAutomation {
12+
async shouldExecute(): Promise<boolean> {
13+
if (this.payload.action !== 'submitted' || !this.payload.review?.user?.login) return false;
14+
return isCodeReviewBot(this.payload.review.user.login);
15+
}
16+
17+
async execute(): Promise<void> {
18+
try {
19+
const prData = this.payload.pull_request;
20+
const reviewerLogin = this.payload.review.user.login;
21+
if (!prData || !this.payload.repository) return;
22+
23+
const octokit = withCompatOctokit(await this.getGitHubClient());
24+
25+
const issueCommentsRes = await octokit.rest.issues.listComments({
26+
owner: this.payload.repository.owner?.login,
27+
repo: this.payload.repository.name,
28+
issue_number: prData.number,
29+
per_page: 100,
30+
});
31+
32+
const agentInfo = detectPRAuthorAgent({
33+
headRef: prData.head?.ref,
34+
body: prData.body,
35+
authorLogin: prData.user?.login,
36+
authorHtmlUrl: prData.user?.html_url,
37+
issueComments: issueCommentsRes.data.map((c: Record<string, unknown>) => ({ body: (c.body as string) || "" })),
38+
});
39+
40+
if (!agentInfo) return;
41+
42+
const reviewCommentsRes = await octokit.rest.pulls.listReviewComments({
43+
owner: this.payload.repository.owner?.login,
44+
repo: this.payload.repository.name,
45+
pull_number: prData.number,
46+
per_page: 100,
47+
});
48+
49+
const botComments: ExtractedReviewComment[] = reviewCommentsRes.data
50+
.filter((c: Record<string, unknown>) => (c.user as { login?: string })?.login === reviewerLogin)
51+
.map((c: Record<string, unknown>) => ({
52+
path: c.path || '',
53+
line: c.line || c.original_line || null,
54+
body: c.body || '',
55+
diff_hunk: c.diff_hunk,
56+
suggestion: c.body?.match(/```suggestion\n([\s\S]*?)\n```/)?.[1] || undefined,
57+
}));
58+
59+
if (botComments.length === 0) return;
60+
61+
const commentBody = appendSignature(formatAgentFixComment(agentInfo.tag, prData.number, botComments));
62+
await octokit.rest.issues.createComment({
63+
owner: this.payload.repository.owner?.login,
64+
repo: this.payload.repository.name,
65+
issue_number: prData.number,
66+
body: commentBody,
67+
});
68+
await this.logExecution('success', 'Agent fix comment tagged', prData.number);
69+
} catch (e: unknown) {
70+
console.error('[AgentTagger] failed:', e);
71+
await this.logExecution('failure', `AgentTagger failed: ${e.message}`, this.payload.pull_request?.number);
72+
}
73+
}
74+
}

0 commit comments

Comments
 (0)