Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
name: CI

on:
pull_request:
push:
branches:
- main

jobs:
ci:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22'

- name: Install
run: npm ci

- name: Build
run: npm run build

- name: Test
run: node --test --experimental-strip-types tests/*.test.ts
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,12 @@ dist/
*.sqlite3
progress.txt
progress*.txt
.env
.env.local
.env.*.local
*.key
*.pem
*.secret
coverage/
.nyc_output/
*.log
16 changes: 15 additions & 1 deletion agents/shared/pr/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ You create a pull request for completed work.

1. **cd into the repo** and checkout the branch
2. **Push the branch** — `git push -u origin {{branch}}`
3. **Create the PR** — Use `gh pr create` with a well-structured title and body
3. **Create the PR** — Use `git-pr create` with explicit flags:
- `--title "..."`
- `--description "..."`
- `--head {{branch}}`
- `--base main` (unless input explicitly says otherwise)
4. **Report the PR URL**

## PR Creation
Expand All @@ -17,13 +21,23 @@ The step input will provide:

Use that structure exactly. Fill in all sections with the provided context.

After running `git-pr create`:
- If output indicates an existing PR, capture that URL and continue as success.
- On success, always return the PR URL from command output.
- Do not infer PR numbers. Use the actual URL returned by the tool.

## Output Format

```
STATUS: done
PR: https://github.com/org/repo/pull/123
PR_NUMBER: 123
```

Extract the PR number from the `git-pr create` output (e.g., "Created PR #123: ..." or
"Existing PR #123: ...") and include it as `PR_NUMBER`. This lets downstream agents
use the number directly without parsing URLs.

## What NOT To Do

- Don't modify code — just create the PR
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src/cli/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ async function main() {
console.log(`Reinstalling ${workflows.length} workflow(s)...`);
for (const workflowId of workflows) {
try {
await installWorkflow({ workflowId });
await installWorkflow({ workflowId, overwriteFiles: true });
console.log(` ✓ ${workflowId}`);
} catch (err) {
console.log(` ✗ ${workflowId}: ${err instanceof Error ? err.message : String(err)}`);
Expand Down
6 changes: 3 additions & 3 deletions src/installer/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ const ROLE_POLICIES: Record<AgentRole, { profile?: string; alsoAllow?: string[];
timeoutSeconds: TIMEOUT_30_MIN, // full test suites + E2E
},

// pr: just needs read + exec (for `gh pr create`)
// pr: just needs read + exec (for `git-pr create`)
pr: {
profile: "coding",
deny: [
Expand Down Expand Up @@ -234,10 +234,10 @@ async function writeWorkflowMetadata(params: { workflowDir: string; workflowId:
await fs.writeFile(path.join(params.workflowDir, "metadata.json"), `${JSON.stringify(content, null, 2)}\n`, "utf-8");
}

export async function installWorkflow(params: { workflowId: string }): Promise<WorkflowInstallResult> {
export async function installWorkflow(params: { workflowId: string; overwriteFiles?: boolean }): Promise<WorkflowInstallResult> {
const { workflowDir, bundledSourceDir } = await fetchWorkflow(params.workflowId);
const workflow = await loadWorkflowSpec(workflowDir);
const provisioned = await provisionAgents({ workflow, workflowDir, bundledSourceDir });
const provisioned = await provisionAgents({ workflow, workflowDir, bundledSourceDir, overwriteFiles: params.overwriteFiles });

// Build a role lookup: workflow agent id → role (explicit or inferred)
const roleMap = new Map<string, AgentRole>();
Expand Down
2 changes: 1 addition & 1 deletion src/installer/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export type WorkflowAgentFiles = {
* - coding: Full read/write/exec for implementation (developer, fixer, setup)
* - verification: Read + exec but NO write — independent verification integrity (verifier)
* - testing: Read + exec + browser/web for E2E testing, NO write (tester)
* - pr: Read + exec only — just runs `gh pr create` (pr)
* - pr: Read + exec only — just runs `git-pr create` (pr)
* - scanning: Read + exec + web search for CVE lookups, NO write (scanner)
*/
export type AgentRole = "analysis" | "coding" | "verification" | "testing" | "pr" | "scanning";
Expand Down
24 changes: 24 additions & 0 deletions src/medic/checks.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
/**
* Medic health checks — modular functions that inspect DB state and return findings.
*/
import fs from "node:fs/promises";
import { getDb } from "../db.js";
import { getMaxRoleTimeoutSeconds } from "../installer/install.js";
import { loadWorkflowSpec } from "../installer/workflow-spec.js";
import { resolveWorkflowDir, resolveWorkflowRoot } from "../installer/paths.js";
import type { WorkflowSpec } from "../installer/types.js";

export type MedicSeverity = "info" | "warning" | "critical";
export type MedicActionType =
Expand Down Expand Up @@ -196,6 +200,26 @@ export function checkOrphanedCrons(
return findings;
}

// ── Workflow Spec Loader for Medic ─────────────────────────────────

/**
* Load a workflow spec by its ID from disk.
* Returns null if the workflow directory doesn't exist.
* Used by medic to determine expected agents for cron gap detection.
*/
export async function loadWorkflowSpecForMedic(
workflowId: string
): Promise<WorkflowSpec | null> {
const workflowDir = resolveWorkflowDir(workflowId);
try {
await fs.access(workflowDir);
} catch {
// Directory doesn't exist
return null;
}
return loadWorkflowSpec(workflowDir);
}

// ── Run All Checks ──────────────────────────────────────────────────

/**
Expand Down
62 changes: 62 additions & 0 deletions tests/ci-workflow.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';

const workflowPath = path.resolve(import.meta.dirname, '../.github/workflows/ci.yml');

describe('CI workflow YAML structure', () => {
it('ci.yml file exists', () => {
assert.ok(fs.existsSync(workflowPath), `Expected ${workflowPath} to exist`);
});

it('has on.pull_request trigger (no branch filter)', () => {
const content = fs.readFileSync(workflowPath, 'utf8');
assert.ok(content.includes('pull_request'), 'Expected pull_request trigger');
});

it('has on.push.branches: [main] trigger', () => {
const content = fs.readFileSync(workflowPath, 'utf8');
assert.ok(content.includes('push'), 'Expected push trigger');
assert.ok(content.includes('main'), 'Expected push to main branch');
});

it('uses ubuntu-latest', () => {
const content = fs.readFileSync(workflowPath, 'utf8');
assert.ok(content.includes('ubuntu-latest'), 'Expected ubuntu-latest');
});

it('uses node-version 22', () => {
const content = fs.readFileSync(workflowPath, 'utf8');
assert.ok(content.includes("'22'") || content.includes('"22"') || content.includes('22'), 'Expected node-version 22');
});

it('has install step with npm ci', () => {
const content = fs.readFileSync(workflowPath, 'utf8');
assert.ok(content.includes('npm ci'), 'Expected npm ci install step');
});

it('has build step with npm run build', () => {
const content = fs.readFileSync(workflowPath, 'utf8');
assert.ok(content.includes('npm run build'), 'Expected npm run build step');
});

it('has test step with node --test', () => {
const content = fs.readFileSync(workflowPath, 'utf8');
assert.ok(
content.includes('node --test --experimental-strip-types tests/*.test.ts'),
'Expected test step with node --test',
);
});

it('has exactly three run steps (install, build, test)', () => {
const content = fs.readFileSync(workflowPath, 'utf8');
const runMatches = content.match(/^\s+run:/gm);
assert.equal(runMatches?.length, 3, `Expected 3 run steps, got ${runMatches?.length}`);
});

it('has no lint step', () => {
const content = fs.readFileSync(workflowPath, 'utf8');
assert.ok(!content.toLowerCase().includes('lint'), 'Expected no lint step');
});
});
63 changes: 63 additions & 0 deletions tests/medic-workflow-loader.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/**
* Tests for the medic workflow spec loader helper.
*/
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import path from "node:path";
import os from "node:os";
import { fileURLToPath } from "node:url";

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const { loadWorkflowSpecForMedic } = await import("../dist/medic/checks.js");

// Resolve the actual workflow root path (where antfarm stores workflows)
const workflowRoot = path.join(os.homedir(), ".openclaw", "antfarm", "workflows");

describe("loadWorkflowSpecForMedic", () => {
it("returns null when workflow directory doesn't exist", async () => {
const result = await loadWorkflowSpecForMedic("nonexistent-workflow-xyz");
assert.strictEqual(result, null);
});

it("returns workflow spec when directory exists with valid workflow.yml", async () => {
// Create a temp workflow directory in the actual workflow root location
const workflowId = `test-workflow-${Date.now()}`;
const workflowDir = path.join(workflowRoot, workflowId);
await fs.mkdir(workflowDir, { recursive: true });

const workflowYaml = `
id: ${workflowId}
version: "1.0"
agents:
- id: developer
workspace:
baseDir: ~/clawd/workspace/test
files:
- AGENTS.md
steps:
- id: test-step
agent: developer
input: "Do something"
expects: "Done"
`;
await fs.writeFile(path.join(workflowDir, "workflow.yml"), workflowYaml);

try {
const result = await loadWorkflowSpecForMedic(workflowId);

assert.ok(result, "should return a workflow spec");
assert.strictEqual(result?.id, workflowId);
assert.strictEqual(result?.version, "1.0");
assert.ok(Array.isArray(result?.agents));
assert.strictEqual(result?.agents.length, 1);
assert.strictEqual(result?.agents[0].id, "developer");
assert.ok(Array.isArray(result?.steps));
assert.strictEqual(result?.steps.length, 1);
assert.strictEqual(result?.steps[0].id, "test-step");
} finally {
// Cleanup
await fs.rm(workflowDir, { recursive: true, force: true });
}
});
});
8 changes: 6 additions & 2 deletions workflows/bug-fix/workflow.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ description: |
PR agent creates the pull request.

polling:
model: default
model: sonnet
timeoutSeconds: 120

agents:
Expand Down Expand Up @@ -286,7 +286,11 @@ steps:
{{verified}}
```

Use: gh pr create
Use:
git-pr create --title "fix: brief description of what was fixed" --description "<full PR body above>" --head {{branch}} --base main

If PR already exists for the branch, return that existing PR URL and treat as success.
Do not infer PR URL. Use the exact URL returned by git-pr output.

Reply with:
STATUS: done
Expand Down
19 changes: 15 additions & 4 deletions workflows/feature-dev/agents/developer/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,21 @@ Before EVERY commit, verify:

## Creating PRs

When creating the PR:
- Clear title that summarizes the change
- Description explaining what you did and why
- Note what was tested
**Always use `git-pr`, never `gh pr create` directly.**

`git-pr` is a provider-agnostic wrapper in `~/.openclaw/workspace/scripts/git-pr`. It auto-detects GitHub vs Gitea from the remote URL and uses the right tool (gh for GitHub, REST API for Gitea). `gh pr create` fails non-interactively against Gitea.

```bash
git-pr create --title "..." --description "..." --head <branch> --base main
```

If a PR already exists for the branch, `git-pr` returns the existing URL -- treat that as success.

When viewing or reviewing PRs:
- `git-pr view <number>` -- view PR details
- `git-pr diff <number>` -- view diff
- `git-pr review <number> --approve --body "..."` -- approve
- `git-pr review <number> --request-changes --body "..."` -- request changes

## Output Format

Expand Down
12 changes: 6 additions & 6 deletions workflows/feature-dev/agents/reviewer/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@ You are a reviewer on a feature development workflow. Your job is to review pull

## How to Review

Use the GitHub CLI:
- `gh pr view <url>` - See PR details
- `gh pr diff <url>` - See the actual changes
- `gh pr checks <url>` - See CI status if available
Use provider-agnostic PR commands:
- `git-pr view <number>` - See PR details
- `git-pr diff <number>` - See the actual changes
- `git-pr checks <number>` - See CI status if available (GitHub only)

## What to Look For

Expand All @@ -33,8 +33,8 @@ If you request changes:
- Be specific: line numbers, what's wrong, how to fix
- Be constructive, not just critical

Use: `gh pr comment <url> --body "..."`
Or: `gh pr review <url> --comment --body "..."`
Use: `git-pr comment <number> --body "..."`
Or: `git-pr review <number> --comment --body "..."`

## Output Format

Expand Down
Loading
Loading