Merged
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
37 changes: 36 additions & 1 deletion AGENTS.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -132,4 +132,39 @@ src/
}
```

Returns 503 if database is unreachable.
Returns 503 if database is unreachable.

## OpenClaw Agent Workflow Contract

This section is the source of truth for how an OpenClaw agent should interact with Mission Control. Agents should follow this contract instead of grooming a GitHub Project board.

### Heartbeat lifecycle

At the **start** of each heartbeat:

1. **Best-effort `POST /api/sync`** to refresh Mission Control's issue cache. Treat any non-2xx, timeout, or network error as a freshness warning — log it and continue. **Do not fail the heartbeat on a sync failure.**

At the **end** of each heartbeat:

2. **`POST /api/agent-runs`** (bearer-auth with `MISSION_CONTROL_AGENT_TOKEN`) with run metadata: `agentName`, `runType`, `status`, `startedAt`, `finishedAt`, `summary`, `touchedIssueUrls`.

### Reading work

3. **Read issues from `GET /api/issues`.** Do not query the Postgres cache directly — the API is the contract.
4. **Prefer issues assigned via `agent/<agent-id>` label** if present. If no `agent/*` label exists, fall back to general backlog.
5. **Treat "no status label" or `status/backlog` as backlog work.** Both are valid entry states.

### Source of truth

6. **GitHub Issues and PRs remain the source of truth.** Mission Control's Postgres is a cache; do not write back to it as if it were authoritative.
7. **Do not rely on GitHub Projects.** The Projects board is deprecated for this workflow — group by repository instead.
8. **Do not auto-close issues without explicit evidence of completion.** Mission Control's audit log is not a license to close — a green pipeline, merged PR, or human approval is.

### Failure modes

9. **Mission Control failures must not fail the heartbeat.** Sync, agent-run POST, and issue read are all best-effort from the heartbeat's perspective. Log a warning, continue.
10. **Tokens are secrets.** `MISSION_CONTROL_AGENT_TOKEN` and `GITHUB_TOKEN` must never be logged, echoed, or persisted to disk.

### Auditability

11. **Every state-changing move on Mission Control must produce an AuditLog row.** Operators trace agent activity through `/api/audit`. Drag-and-drop moves on the Kanban board already write audit entries via `POST /api/issues/move`; agents using the same endpoint inherit this behavior.
21 changes: 21 additions & 0 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -324,6 +324,27 @@ For control actions (rerun, dispatch):
- UI shows stale warnings when `lastSyncedAt` > 1 hour ago
- Sync runs are recorded in `AutomationSyncRun` table with stats

## Pre-migration Smoke Checklist

Run this checklist before pointing an OpenClaw agent at Mission Control as its task-visibility layer (instead of a GitHub Project board). Every step should pass; stop and investigate on the first failure.

Set `BASE` to your Mission Control URL (e.g. `BASE=https://mission-control.internal`) before running.

| # | Check | Expected |
|---|-------|----------|
| 1 | `curl -fsS "$BASE/api/health"` | `{"ok":true,"database":"ok",...}` |
| 2 | `curl -fsS -X POST "$BASE/api/automation/sync"` | 2xx, no error body |
| 3 | `curl -fsS "$BASE/api/automation/repos"` | JSON array, non-empty if repos are configured |
| 4 | `curl -fsS -X POST "$BASE/api/sync"` | `syncedCount > 0` |
| 5 | `curl -fsS "$BASE/api/issues"` | JSON array, length > 0 |
| 6 | Open `/board` in a browser | Issues render — no "no issues synced yet" empty state |
| 7 | Open `/projects` | Repo groups render |
| 8 | Open `/agents` | Recent OpenClaw agent heartbeat visible with agent name |
| 9 | Move a low-risk test issue between columns | GitHub label changes; AuditLog row appears in `GET /api/audit` |
| 10 | `kubectl logs -n <ns> <pod>` (or equivalent) | No Prisma / BigInt / FK errors |

Only flip the agent's workflow over once all ten steps pass.

## Scheduled Issue Sync Strategy

Mission Control keeps GitHub as the source of truth and stores issues only as a local cache. Cache freshness is owned by **openclaw agent heartbeat sync** rather than by a Mission Control background worker or new cluster CronJob.
Expand Down
19 changes: 19 additions & 0 deletions src/app/api/issues/move/route.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -204,4 +204,23 @@ describe("POST /api/issues/move — validation", () => {
);
expect(res.status).toBe(400);
});

it("writes an AuditLog entry with success=false when the GitHub mutation fails", async () => {
mocks.removeIssueLabel.mockRejectedValueOnce(new Error("github 500"));

const res = await postRequest(makePayload());
expect(res.status).toBe(500);
const body = await res.json();
expect(body.error).toBe("github 500");

expect(mocks.createAuditLog).toHaveBeenCalledWith({
data: expect.objectContaining({
action: "move_issue",
repoFullName: "org/repo",
issueNumber: 42,
success: false,
errorMessage: "github 500",
}),
});
});
});
78 changes: 78 additions & 0 deletions src/app/board/page.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
import { describe, expect, it, vi, beforeEach } from "vitest";

const { mocks } = vi.hoisted(() => ({
mocks: {
findManyIssues: vi.fn().mockResolvedValue([]),
findManyRepos: vi.fn().mockResolvedValue([]),
aggregateIssues: vi.fn().mockResolvedValue({ _count: { _all: 0 }, _max: { lastSyncedAt: null } }),
getTrackedRepos: vi.fn().mockResolvedValue([]),
},
}));

vi.mock("@/lib/prisma", () => ({
prisma: {
issue: { findMany: mocks.findManyIssues, aggregate: mocks.aggregateIssues },
repository: { findMany: mocks.findManyRepos },
},
}));

vi.mock("@/lib/config", () => ({
getTrackedRepos: mocks.getTrackedRepos,
}));

// Components render to React elements but are not deep-rendered here.
vi.mock("@/components/kanban-board", () => ({ KanbanBoard: () => null }));
vi.mock("@/components/filter-bar", () => ({ FilterBar: () => null }));
vi.mock("@/components/sync-issues-button", () => ({ SyncIssuesButton: () => null }));
vi.mock("@/components/ui/card", () => ({
Card: ({ children }: { children: unknown }) => children,
CardContent: ({ children }: { children: unknown }) => children,
}));

import BoardPage from "./page";

describe("BoardPage searchParams handling (Next 16 async)", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.findManyIssues.mockResolvedValue([]);
mocks.findManyRepos.mockResolvedValue([]);
mocks.aggregateIssues.mockResolvedValue({ _count: { _all: 0 }, _max: { lastSyncedAt: null } });
mocks.getTrackedRepos.mockResolvedValue([]);
});

it("awaits searchParams and applies repo filter to the issue query", async () => {
await BoardPage({ searchParams: Promise.resolve({ repo: "myorg/repo1" }) });

// The first findMany call is the filtered issue query (the second is filter-options).
const filteredCall = mocks.findManyIssues.mock.calls[0][0];
expect(filteredCall.where.repository).toEqual({ enabled: true, fullName: "myorg/repo1" });
});

it("awaits searchParams and applies agent label filter", async () => {
await BoardPage({ searchParams: Promise.resolve({ agent: "agent/alpha" }) });

const filteredCall = mocks.findManyIssues.mock.calls[0][0];
expect(filteredCall.where.labels).toEqual({ has: "agent/alpha" });
});

it("awaits searchParams and applies owner label filter", async () => {
await BoardPage({ searchParams: Promise.resolve({ owner: "owner/alice" }) });

const filteredCall = mocks.findManyIssues.mock.calls[0][0];
expect(filteredCall.where.labels).toEqual({ has: "owner/alice" });
});

it("awaits searchParams and applies priority filter", async () => {
await BoardPage({ searchParams: Promise.resolve({ priority: "priority/p1" }) });

const filteredCall = mocks.findManyIssues.mock.calls[0][0];
expect(filteredCall.where.labels).toEqual({ has: "priority/p1" });
});

it("applies no extra filters when searchParams resolves empty", async () => {
await BoardPage({ searchParams: Promise.resolve({}) });

const filteredCall = mocks.findManyIssues.mock.calls[0][0];
expect(filteredCall.where).toEqual({ repository: { enabled: true } });
});
});
17 changes: 8 additions & 9 deletions src/app/board/page.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,13 +7,12 @@ import { getTrackedRepos } from "@/lib/config";

export const dynamic = "force-dynamic";

async function getIssues(repo?: string, agent?: string, owner?: string, project?: string, priority?: string) {
async function getIssues(repo?: string, agent?: string, owner?: string, priority?: string) {
const where: Record<string, unknown> = { repository: { enabled: true } };

if (repo) where.repository = { ...(where.repository as object), fullName: repo };
if (agent) where.labels = { has: agent };
if (owner) where.labels = { has: owner };
if (project) where.labels = { has: `project/${project}` };
if (priority) where.labels = { has: priority };

return prisma.issue.findMany({
Expand DownExpand Up@@ -70,12 +69,13 @@ async function getIssueSyncStatus() {
}

interface PageProps {
searchParams: { repo?: string; agent?: string; owner?: string; project?: string; priority?: string };
searchParams: Promise<{ repo?: string; agent?: string; owner?: string; priority?: string }>;
}

export default async function BoardPage({ searchParams }: PageProps) {
const params = await searchParams;
const [issues, repos, filterOptions, syncStatus] = await Promise.all([
getIssues(searchParams.repo, searchParams.agent, searchParams.owner, searchParams.project, searchParams.priority),
getIssues(params.repo, params.agent, params.owner, params.priority),
getRepos(),
getFilterOptions(),
getIssueSyncStatus(),
Expand All@@ -100,11 +100,10 @@ export default async function BoardPage({ searchParams }: PageProps) {
agents={filterOptions.agents}
owners={filterOptions.owners}
activeFilters={{
repo: searchParams.repo || "",
agent: searchParams.agent || "",
owner: searchParams.owner || "",
project: searchParams.project || "",
priority: searchParams.priority || "",
repo: params.repo || "",
agent: params.agent || "",
owner: params.owner || "",
priority: params.priority || "",
}}
/>

Expand Down
17 changes: 6 additions & 11 deletions src/components/filter-bar.tsx
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
"use client";

import { useRouter, useSearchParams } from "next/navigation";
import { Input } from "@/components/ui/input";
import { PRIORITY_LABELS, AGENT_PREFIX, OWNER_PREFIX } from "@/types";

interface FilterBarProps {
Expand All@@ -12,7 +11,6 @@ interface FilterBarProps {
repo: string;
agent: string;
owner: string;
project: string;
priority: string;
};
}
Expand DownExpand Up@@ -47,11 +45,13 @@ export function FilterBar({ repos, agents, owners, activeFilters }: FilterBarPro
</select>

<select
aria-label="Filter by agent label"
title="Filters by agent/* label only"
value={activeFilters.agent}
onChange={(e) => updateFilter("agent", e.target.value)}
className="h-9 px-3 rounded-md border border-input bg-background text-sm"
>
<option value="">All Agents</option>
<option value="">All agent labels</option>
{agents.map((a) => (
<option key={a} value={a}>
{a.replace(AGENT_PREFIX, "")}
Expand All@@ -60,25 +60,20 @@ export function FilterBar({ repos, agents, owners, activeFilters }: FilterBarPro
</select>

<select
aria-label="Filter by owner label"
title="Filters by owner/* label only"
value={activeFilters.owner}
onChange={(e) => updateFilter("owner", e.target.value)}
className="h-9 px-3 rounded-md border border-input bg-background text-sm"
>
<option value="">All Owners</option>
<option value="">All owner labels</option>
{owners.map((o) => (
<option key={o} value={o}>
{o.replace(OWNER_PREFIX, "")}
</option>
))}
</select>

<Input
placeholder="Project name"
value={activeFilters.project}
onChange={(e) => updateFilter("project", e.target.value)}
className="h-9 w-40"
/>

<select
value={activeFilters.priority}
onChange={(e) => updateFilter("priority", e.target.value)}
Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
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
37 changes: 36 additions & 1 deletion AGENTS.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -132,4 +132,39 @@ src/
}
```

Returns 503 if database is unreachable.
Returns 503 if database is unreachable.

## OpenClaw Agent Workflow Contract

This section is the source of truth for how an OpenClaw agent should interact with Mission Control. Agents should follow this contract instead of grooming a GitHub Project board.

### Heartbeat lifecycle

At the **start** of each heartbeat:

1. **Best-effort `POST /api/sync`** to refresh Mission Control's issue cache. Treat any non-2xx, timeout, or network error as a freshness warning — log it and continue. **Do not fail the heartbeat on a sync failure.**

At the **end** of each heartbeat:

2. **`POST /api/agent-runs`** (bearer-auth with `MISSION_CONTROL_AGENT_TOKEN`) with run metadata: `agentName`, `runType`, `status`, `startedAt`, `finishedAt`, `summary`, `touchedIssueUrls`.

### Reading work

3. **Read issues from `GET /api/issues`.** Do not query the Postgres cache directly — the API is the contract.
4. **Prefer issues assigned via `agent/<agent-id>` label** if present. If no `agent/*` label exists, fall back to general backlog.
5. **Treat "no status label" or `status/backlog` as backlog work.** Both are valid entry states.

### Source of truth

6. **GitHub Issues and PRs remain the source of truth.** Mission Control's Postgres is a cache; do not write back to it as if it were authoritative.
7. **Do not rely on GitHub Projects.** The Projects board is deprecated for this workflow — group by repository instead.
8. **Do not auto-close issues without explicit evidence of completion.** Mission Control's audit log is not a license to close — a green pipeline, merged PR, or human approval is.

### Failure modes

9. **Mission Control failures must not fail the heartbeat.** Sync, agent-run POST, and issue read are all best-effort from the heartbeat's perspective. Log a warning, continue.
10. **Tokens are secrets.** `MISSION_CONTROL_AGENT_TOKEN` and `GITHUB_TOKEN` must never be logged, echoed, or persisted to disk.

### Auditability

11. **Every state-changing move on Mission Control must produce an AuditLog row.** Operators trace agent activity through `/api/audit`. Drag-and-drop moves on the Kanban board already write audit entries via `POST /api/issues/move`; agents using the same endpoint inherit this behavior.
21 changes: 21 additions & 0 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -324,6 +324,27 @@ For control actions (rerun, dispatch):
- UI shows stale warnings when `lastSyncedAt` > 1 hour ago
- Sync runs are recorded in `AutomationSyncRun` table with stats

## Pre-migration Smoke Checklist

Run this checklist before pointing an OpenClaw agent at Mission Control as its task-visibility layer (instead of a GitHub Project board). Every step should pass; stop and investigate on the first failure.

Set `BASE` to your Mission Control URL (e.g. `BASE=https://mission-control.internal`) before running.

| # | Check | Expected |
|---|-------|----------|
| 1 | `curl -fsS "$BASE/api/health"` | `{"ok":true,"database":"ok",...}` |
| 2 | `curl -fsS -X POST "$BASE/api/automation/sync"` | 2xx, no error body |
| 3 | `curl -fsS "$BASE/api/automation/repos"` | JSON array, non-empty if repos are configured |
| 4 | `curl -fsS -X POST "$BASE/api/sync"` | `syncedCount > 0` |
| 5 | `curl -fsS "$BASE/api/issues"` | JSON array, length > 0 |
| 6 | Open `/board` in a browser | Issues render — no "no issues synced yet" empty state |
| 7 | Open `/projects` | Repo groups render |
| 8 | Open `/agents` | Recent OpenClaw agent heartbeat visible with agent name |
| 9 | Move a low-risk test issue between columns | GitHub label changes; AuditLog row appears in `GET /api/audit` |
| 10 | `kubectl logs -n <ns> <pod>` (or equivalent) | No Prisma / BigInt / FK errors |

Only flip the agent's workflow over once all ten steps pass.

## Scheduled Issue Sync Strategy

Mission Control keeps GitHub as the source of truth and stores issues only as a local cache. Cache freshness is owned by **openclaw agent heartbeat sync** rather than by a Mission Control background worker or new cluster CronJob.
Expand Down
19 changes: 19 additions & 0 deletions src/app/api/issues/move/route.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -204,4 +204,23 @@ describe("POST /api/issues/move — validation", () => {
);
expect(res.status).toBe(400);
});

it("writes an AuditLog entry with success=false when the GitHub mutation fails", async () => {
mocks.removeIssueLabel.mockRejectedValueOnce(new Error("github 500"));

const res = await postRequest(makePayload());
expect(res.status).toBe(500);
const body = await res.json();
expect(body.error).toBe("github 500");

expect(mocks.createAuditLog).toHaveBeenCalledWith({
data: expect.objectContaining({
action: "move_issue",
repoFullName: "org/repo",
issueNumber: 42,
success: false,
errorMessage: "github 500",
}),
});
});
});
78 changes: 78 additions & 0 deletions src/app/board/page.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
import { describe, expect, it, vi, beforeEach } from "vitest";

const { mocks } = vi.hoisted(() => ({
mocks: {
findManyIssues: vi.fn().mockResolvedValue([]),
findManyRepos: vi.fn().mockResolvedValue([]),
aggregateIssues: vi.fn().mockResolvedValue({ _count: { _all: 0 }, _max: { lastSyncedAt: null } }),
getTrackedRepos: vi.fn().mockResolvedValue([]),
},
}));

vi.mock("@/lib/prisma", () => ({
prisma: {
issue: { findMany: mocks.findManyIssues, aggregate: mocks.aggregateIssues },
repository: { findMany: mocks.findManyRepos },
},
}));

vi.mock("@/lib/config", () => ({
getTrackedRepos: mocks.getTrackedRepos,
}));

// Components render to React elements but are not deep-rendered here.
vi.mock("@/components/kanban-board", () => ({ KanbanBoard: () => null }));
vi.mock("@/components/filter-bar", () => ({ FilterBar: () => null }));
vi.mock("@/components/sync-issues-button", () => ({ SyncIssuesButton: () => null }));
vi.mock("@/components/ui/card", () => ({
Card: ({ children }: { children: unknown }) => children,
CardContent: ({ children }: { children: unknown }) => children,
}));

import BoardPage from "./page";

describe("BoardPage searchParams handling (Next 16 async)", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.findManyIssues.mockResolvedValue([]);
mocks.findManyRepos.mockResolvedValue([]);
mocks.aggregateIssues.mockResolvedValue({ _count: { _all: 0 }, _max: { lastSyncedAt: null } });
mocks.getTrackedRepos.mockResolvedValue([]);
});

it("awaits searchParams and applies repo filter to the issue query", async () => {
await BoardPage({ searchParams: Promise.resolve({ repo: "myorg/repo1" }) });

// The first findMany call is the filtered issue query (the second is filter-options).
const filteredCall = mocks.findManyIssues.mock.calls[0][0];
expect(filteredCall.where.repository).toEqual({ enabled: true, fullName: "myorg/repo1" });
});

it("awaits searchParams and applies agent label filter", async () => {
await BoardPage({ searchParams: Promise.resolve({ agent: "agent/alpha" }) });

const filteredCall = mocks.findManyIssues.mock.calls[0][0];
expect(filteredCall.where.labels).toEqual({ has: "agent/alpha" });
});

it("awaits searchParams and applies owner label filter", async () => {
await BoardPage({ searchParams: Promise.resolve({ owner: "owner/alice" }) });

const filteredCall = mocks.findManyIssues.mock.calls[0][0];
expect(filteredCall.where.labels).toEqual({ has: "owner/alice" });
});

it("awaits searchParams and applies priority filter", async () => {
await BoardPage({ searchParams: Promise.resolve({ priority: "priority/p1" }) });

const filteredCall = mocks.findManyIssues.mock.calls[0][0];
expect(filteredCall.where.labels).toEqual({ has: "priority/p1" });
});

it("applies no extra filters when searchParams resolves empty", async () => {
await BoardPage({ searchParams: Promise.resolve({}) });

const filteredCall = mocks.findManyIssues.mock.calls[0][0];
expect(filteredCall.where).toEqual({ repository: { enabled: true } });
});
});
17 changes: 8 additions & 9 deletions src/app/board/page.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,13 +7,12 @@ import { getTrackedRepos } from "@/lib/config";

export const dynamic = "force-dynamic";

async function getIssues(repo?: string, agent?: string, owner?: string, project?: string, priority?: string) {
async function getIssues(repo?: string, agent?: string, owner?: string, priority?: string) {
const where: Record<string, unknown> = { repository: { enabled: true } };

if (repo) where.repository = { ...(where.repository as object), fullName: repo };
if (agent) where.labels = { has: agent };
if (owner) where.labels = { has: owner };
if (project) where.labels = { has: `project/${project}` };
if (priority) where.labels = { has: priority };

return prisma.issue.findMany({
Expand DownExpand Up@@ -70,12 +69,13 @@ async function getIssueSyncStatus() {
}

interface PageProps {
searchParams: { repo?: string; agent?: string; owner?: string; project?: string; priority?: string };
searchParams: Promise<{ repo?: string; agent?: string; owner?: string; priority?: string }>;
}

export default async function BoardPage({ searchParams }: PageProps) {
const params = await searchParams;
const [issues, repos, filterOptions, syncStatus] = await Promise.all([
getIssues(searchParams.repo, searchParams.agent, searchParams.owner, searchParams.project, searchParams.priority),
getIssues(params.repo, params.agent, params.owner, params.priority),
getRepos(),
getFilterOptions(),
getIssueSyncStatus(),
Expand All@@ -100,11 +100,10 @@ export default async function BoardPage({ searchParams }: PageProps) {
agents={filterOptions.agents}
owners={filterOptions.owners}
activeFilters={{
repo: searchParams.repo || "",
agent: searchParams.agent || "",
owner: searchParams.owner || "",
project: searchParams.project || "",
priority: searchParams.priority || "",
repo: params.repo || "",
agent: params.agent || "",
owner: params.owner || "",
priority: params.priority || "",
}}
/>

Expand Down
17 changes: 6 additions & 11 deletions src/components/filter-bar.tsx
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
"use client";

import { useRouter, useSearchParams } from "next/navigation";
import { Input } from "@/components/ui/input";
import { PRIORITY_LABELS, AGENT_PREFIX, OWNER_PREFIX } from "@/types";

interface FilterBarProps {
Expand All@@ -12,7 +11,6 @@ interface FilterBarProps {
repo: string;
agent: string;
owner: string;
project: string;
priority: string;
};
}
Expand DownExpand Up@@ -47,11 +45,13 @@ export function FilterBar({ repos, agents, owners, activeFilters }: FilterBarPro
</select>

<select
aria-label="Filter by agent label"
title="Filters by agent/* label only"
value={activeFilters.agent}
onChange={(e) => updateFilter("agent", e.target.value)}
className="h-9 px-3 rounded-md border border-input bg-background text-sm"
>
<option value="">All Agents</option>
<option value="">All agent labels</option>
{agents.map((a) => (
<option key={a} value={a}>
{a.replace(AGENT_PREFIX, "")}
Expand All@@ -60,25 +60,20 @@ export function FilterBar({ repos, agents, owners, activeFilters }: FilterBarPro
</select>

<select
aria-label="Filter by owner label"
title="Filters by owner/* label only"
value={activeFilters.owner}
onChange={(e) => updateFilter("owner", e.target.value)}
className="h-9 px-3 rounded-md border border-input bg-background text-sm"
>
<option value="">All Owners</option>
<option value="">All owner labels</option>
{owners.map((o) => (
<option key={o} value={o}>
{o.replace(OWNER_PREFIX, "")}
</option>
))}
</select>

<Input
placeholder="Project name"
value={activeFilters.project}
onChange={(e) => updateFilter("project", e.target.value)}
className="h-9 w-40"
/>

<select
value={activeFilters.priority}
onChange={(e) => updateFilter("priority", e.target.value)}
Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
37 changes: 36 additions & 1 deletion AGENTS.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -132,4 +132,39 @@ src/
}
```

Returns 503 if database is unreachable.
Returns 503 if database is unreachable.

## OpenClaw Agent Workflow Contract

This section is the source of truth for how an OpenClaw agent should interact with Mission Control. Agents should follow this contract instead of grooming a GitHub Project board.

### Heartbeat lifecycle

At the **start** of each heartbeat:

1. **Best-effort `POST /api/sync`** to refresh Mission Control's issue cache. Treat any non-2xx, timeout, or network error as a freshness warning — log it and continue. **Do not fail the heartbeat on a sync failure.**

At the **end** of each heartbeat:

2. **`POST /api/agent-runs`** (bearer-auth with `MISSION_CONTROL_AGENT_TOKEN`) with run metadata: `agentName`, `runType`, `status`, `startedAt`, `finishedAt`, `summary`, `touchedIssueUrls`.

### Reading work

3. **Read issues from `GET /api/issues`.** Do not query the Postgres cache directly — the API is the contract.
4. **Prefer issues assigned via `agent/<agent-id>` label** if present. If no `agent/*` label exists, fall back to general backlog.
5. **Treat "no status label" or `status/backlog` as backlog work.** Both are valid entry states.

### Source of truth

6. **GitHub Issues and PRs remain the source of truth.** Mission Control's Postgres is a cache; do not write back to it as if it were authoritative.
7. **Do not rely on GitHub Projects.** The Projects board is deprecated for this workflow — group by repository instead.
8. **Do not auto-close issues without explicit evidence of completion.** Mission Control's audit log is not a license to close — a green pipeline, merged PR, or human approval is.

### Failure modes

9. **Mission Control failures must not fail the heartbeat.** Sync, agent-run POST, and issue read are all best-effort from the heartbeat's perspective. Log a warning, continue.
10. **Tokens are secrets.** `MISSION_CONTROL_AGENT_TOKEN` and `GITHUB_TOKEN` must never be logged, echoed, or persisted to disk.

### Auditability

11. **Every state-changing move on Mission Control must produce an AuditLog row.** Operators trace agent activity through `/api/audit`. Drag-and-drop moves on the Kanban board already write audit entries via `POST /api/issues/move`; agents using the same endpoint inherit this behavior.
21 changes: 21 additions & 0 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -324,6 +324,27 @@ For control actions (rerun, dispatch):
- UI shows stale warnings when `lastSyncedAt` > 1 hour ago
- Sync runs are recorded in `AutomationSyncRun` table with stats

## Pre-migration Smoke Checklist

Run this checklist before pointing an OpenClaw agent at Mission Control as its task-visibility layer (instead of a GitHub Project board). Every step should pass; stop and investigate on the first failure.

Set `BASE` to your Mission Control URL (e.g. `BASE=https://mission-control.internal`) before running.

| # | Check | Expected |
|---|-------|----------|
| 1 | `curl -fsS "$BASE/api/health"` | `{"ok":true,"database":"ok",...}` |
| 2 | `curl -fsS -X POST "$BASE/api/automation/sync"` | 2xx, no error body |
| 3 | `curl -fsS "$BASE/api/automation/repos"` | JSON array, non-empty if repos are configured |
| 4 | `curl -fsS -X POST "$BASE/api/sync"` | `syncedCount > 0` |
| 5 | `curl -fsS "$BASE/api/issues"` | JSON array, length > 0 |
| 6 | Open `/board` in a browser | Issues render — no "no issues synced yet" empty state |
| 7 | Open `/projects` | Repo groups render |
| 8 | Open `/agents` | Recent OpenClaw agent heartbeat visible with agent name |
| 9 | Move a low-risk test issue between columns | GitHub label changes; AuditLog row appears in `GET /api/audit` |
| 10 | `kubectl logs -n <ns> <pod>` (or equivalent) | No Prisma / BigInt / FK errors |

Only flip the agent's workflow over once all ten steps pass.

## Scheduled Issue Sync Strategy

Mission Control keeps GitHub as the source of truth and stores issues only as a local cache. Cache freshness is owned by **openclaw agent heartbeat sync** rather than by a Mission Control background worker or new cluster CronJob.
Expand Down
19 changes: 19 additions & 0 deletions src/app/api/issues/move/route.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -204,4 +204,23 @@ describe("POST /api/issues/move — validation", () => {
);
expect(res.status).toBe(400);
});

it("writes an AuditLog entry with success=false when the GitHub mutation fails", async () => {
mocks.removeIssueLabel.mockRejectedValueOnce(new Error("github 500"));

const res = await postRequest(makePayload());
expect(res.status).toBe(500);
const body = await res.json();
expect(body.error).toBe("github 500");

expect(mocks.createAuditLog).toHaveBeenCalledWith({
data: expect.objectContaining({
action: "move_issue",
repoFullName: "org/repo",
issueNumber: 42,
success: false,
errorMessage: "github 500",
}),
});
});
});
78 changes: 78 additions & 0 deletions src/app/board/page.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
import { describe, expect, it, vi, beforeEach } from "vitest";

const { mocks } = vi.hoisted(() => ({
mocks: {
findManyIssues: vi.fn().mockResolvedValue([]),
findManyRepos: vi.fn().mockResolvedValue([]),
aggregateIssues: vi.fn().mockResolvedValue({ _count: { _all: 0 }, _max: { lastSyncedAt: null } }),
getTrackedRepos: vi.fn().mockResolvedValue([]),
},
}));

vi.mock("@/lib/prisma", () => ({
prisma: {
issue: { findMany: mocks.findManyIssues, aggregate: mocks.aggregateIssues },
repository: { findMany: mocks.findManyRepos },
},
}));

vi.mock("@/lib/config", () => ({
getTrackedRepos: mocks.getTrackedRepos,
}));

// Components render to React elements but are not deep-rendered here.
vi.mock("@/components/kanban-board", () => ({ KanbanBoard: () => null }));
vi.mock("@/components/filter-bar", () => ({ FilterBar: () => null }));
vi.mock("@/components/sync-issues-button", () => ({ SyncIssuesButton: () => null }));
vi.mock("@/components/ui/card", () => ({
Card: ({ children }: { children: unknown }) => children,
CardContent: ({ children }: { children: unknown }) => children,
}));

import BoardPage from "./page";

describe("BoardPage searchParams handling (Next 16 async)", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.findManyIssues.mockResolvedValue([]);
mocks.findManyRepos.mockResolvedValue([]);
mocks.aggregateIssues.mockResolvedValue({ _count: { _all: 0 }, _max: { lastSyncedAt: null } });
mocks.getTrackedRepos.mockResolvedValue([]);
});

it("awaits searchParams and applies repo filter to the issue query", async () => {
await BoardPage({ searchParams: Promise.resolve({ repo: "myorg/repo1" }) });

// The first findMany call is the filtered issue query (the second is filter-options).
const filteredCall = mocks.findManyIssues.mock.calls[0][0];
expect(filteredCall.where.repository).toEqual({ enabled: true, fullName: "myorg/repo1" });
});

it("awaits searchParams and applies agent label filter", async () => {
await BoardPage({ searchParams: Promise.resolve({ agent: "agent/alpha" }) });

const filteredCall = mocks.findManyIssues.mock.calls[0][0];
expect(filteredCall.where.labels).toEqual({ has: "agent/alpha" });
});

it("awaits searchParams and applies owner label filter", async () => {
await BoardPage({ searchParams: Promise.resolve({ owner: "owner/alice" }) });

const filteredCall = mocks.findManyIssues.mock.calls[0][0];
expect(filteredCall.where.labels).toEqual({ has: "owner/alice" });
});

it("awaits searchParams and applies priority filter", async () => {
await BoardPage({ searchParams: Promise.resolve({ priority: "priority/p1" }) });

const filteredCall = mocks.findManyIssues.mock.calls[0][0];
expect(filteredCall.where.labels).toEqual({ has: "priority/p1" });
});

it("applies no extra filters when searchParams resolves empty", async () => {
await BoardPage({ searchParams: Promise.resolve({}) });

const filteredCall = mocks.findManyIssues.mock.calls[0][0];
expect(filteredCall.where).toEqual({ repository: { enabled: true } });
});
});
17 changes: 8 additions & 9 deletions src/app/board/page.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,13 +7,12 @@ import { getTrackedRepos } from "@/lib/config";

export const dynamic = "force-dynamic";

async function getIssues(repo?: string, agent?: string, owner?: string, project?: string, priority?: string) {
async function getIssues(repo?: string, agent?: string, owner?: string, priority?: string) {
const where: Record<string, unknown> = { repository: { enabled: true } };

if (repo) where.repository = { ...(where.repository as object), fullName: repo };
if (agent) where.labels = { has: agent };
if (owner) where.labels = { has: owner };
if (project) where.labels = { has: `project/${project}` };
if (priority) where.labels = { has: priority };

return prisma.issue.findMany({
Expand DownExpand Up@@ -70,12 +69,13 @@ async function getIssueSyncStatus() {
}

interface PageProps {
searchParams: { repo?: string; agent?: string; owner?: string; project?: string; priority?: string };
searchParams: Promise<{ repo?: string; agent?: string; owner?: string; priority?: string }>;
}

export default async function BoardPage({ searchParams }: PageProps) {
const params = await searchParams;
const [issues, repos, filterOptions, syncStatus] = await Promise.all([
getIssues(searchParams.repo, searchParams.agent, searchParams.owner, searchParams.project, searchParams.priority),
getIssues(params.repo, params.agent, params.owner, params.priority),
getRepos(),
getFilterOptions(),
getIssueSyncStatus(),
Expand All@@ -100,11 +100,10 @@ export default async function BoardPage({ searchParams }: PageProps) {
agents={filterOptions.agents}
owners={filterOptions.owners}
activeFilters={{
repo: searchParams.repo || "",
agent: searchParams.agent || "",
owner: searchParams.owner || "",
project: searchParams.project || "",
priority: searchParams.priority || "",
repo: params.repo || "",
agent: params.agent || "",
owner: params.owner || "",
priority: params.priority || "",
}}
/>

Expand Down
17 changes: 6 additions & 11 deletions src/components/filter-bar.tsx
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
"use client";

import { useRouter, useSearchParams } from "next/navigation";
import { Input } from "@/components/ui/input";
import { PRIORITY_LABELS, AGENT_PREFIX, OWNER_PREFIX } from "@/types";

interface FilterBarProps {
Expand All@@ -12,7 +11,6 @@ interface FilterBarProps {
repo: string;
agent: string;
owner: string;
project: string;
priority: string;
};
}
Expand DownExpand Up@@ -47,11 +45,13 @@ export function FilterBar({ repos, agents, owners, activeFilters }: FilterBarPro
</select>

<select
aria-label="Filter by agent label"
title="Filters by agent/* label only"
value={activeFilters.agent}
onChange={(e) => updateFilter("agent", e.target.value)}
className="h-9 px-3 rounded-md border border-input bg-background text-sm"
>
<option value="">All Agents</option>
<option value="">All agent labels</option>
{agents.map((a) => (
<option key={a} value={a}>
{a.replace(AGENT_PREFIX, "")}
Expand All@@ -60,25 +60,20 @@ export function FilterBar({ repos, agents, owners, activeFilters }: FilterBarPro
</select>

<select
aria-label="Filter by owner label"
title="Filters by owner/* label only"
value={activeFilters.owner}
onChange={(e) => updateFilter("owner", e.target.value)}
className="h-9 px-3 rounded-md border border-input bg-background text-sm"
>
<option value="">All Owners</option>
<option value="">All owner labels</option>
{owners.map((o) => (
<option key={o} value={o}>
{o.replace(OWNER_PREFIX, "")}
</option>
))}
</select>

<Input
placeholder="Project name"
value={activeFilters.project}
onChange={(e) => updateFilter("project", e.target.value)}
className="h-9 w-40"
/>

<select
value={activeFilters.priority}
onChange={(e) => updateFilter("priority", e.target.value)}
Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
37 changes: 36 additions & 1 deletion AGENTS.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -132,4 +132,39 @@ src/
}
```

Returns 503 if database is unreachable.
Returns 503 if database is unreachable.

## OpenClaw Agent Workflow Contract

This section is the source of truth for how an OpenClaw agent should interact with Mission Control. Agents should follow this contract instead of grooming a GitHub Project board.

### Heartbeat lifecycle

At the **start** of each heartbeat:

1. **Best-effort `POST /api/sync`** to refresh Mission Control's issue cache. Treat any non-2xx, timeout, or network error as a freshness warning — log it and continue. **Do not fail the heartbeat on a sync failure.**

At the **end** of each heartbeat:

2. **`POST /api/agent-runs`** (bearer-auth with `MISSION_CONTROL_AGENT_TOKEN`) with run metadata: `agentName`, `runType`, `status`, `startedAt`, `finishedAt`, `summary`, `touchedIssueUrls`.

### Reading work

3. **Read issues from `GET /api/issues`.** Do not query the Postgres cache directly — the API is the contract.
4. **Prefer issues assigned via `agent/<agent-id>` label** if present. If no `agent/*` label exists, fall back to general backlog.
5. **Treat "no status label" or `status/backlog` as backlog work.** Both are valid entry states.

### Source of truth

6. **GitHub Issues and PRs remain the source of truth.** Mission Control's Postgres is a cache; do not write back to it as if it were authoritative.
7. **Do not rely on GitHub Projects.** The Projects board is deprecated for this workflow — group by repository instead.
8. **Do not auto-close issues without explicit evidence of completion.** Mission Control's audit log is not a license to close — a green pipeline, merged PR, or human approval is.

### Failure modes

9. **Mission Control failures must not fail the heartbeat.** Sync, agent-run POST, and issue read are all best-effort from the heartbeat's perspective. Log a warning, continue.
10. **Tokens are secrets.** `MISSION_CONTROL_AGENT_TOKEN` and `GITHUB_TOKEN` must never be logged, echoed, or persisted to disk.

### Auditability

11. **Every state-changing move on Mission Control must produce an AuditLog row.** Operators trace agent activity through `/api/audit`. Drag-and-drop moves on the Kanban board already write audit entries via `POST /api/issues/move`; agents using the same endpoint inherit this behavior.
21 changes: 21 additions & 0 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -324,6 +324,27 @@ For control actions (rerun, dispatch):
- UI shows stale warnings when `lastSyncedAt` > 1 hour ago
- Sync runs are recorded in `AutomationSyncRun` table with stats

## Pre-migration Smoke Checklist

Run this checklist before pointing an OpenClaw agent at Mission Control as its task-visibility layer (instead of a GitHub Project board). Every step should pass; stop and investigate on the first failure.

Set `BASE` to your Mission Control URL (e.g. `BASE=https://mission-control.internal`) before running.

| # | Check | Expected |
|---|-------|----------|
| 1 | `curl -fsS "$BASE/api/health"` | `{"ok":true,"database":"ok",...}` |
| 2 | `curl -fsS -X POST "$BASE/api/automation/sync"` | 2xx, no error body |
| 3 | `curl -fsS "$BASE/api/automation/repos"` | JSON array, non-empty if repos are configured |
| 4 | `curl -fsS -X POST "$BASE/api/sync"` | `syncedCount > 0` |
| 5 | `curl -fsS "$BASE/api/issues"` | JSON array, length > 0 |
| 6 | Open `/board` in a browser | Issues render — no "no issues synced yet" empty state |
| 7 | Open `/projects` | Repo groups render |
| 8 | Open `/agents` | Recent OpenClaw agent heartbeat visible with agent name |
| 9 | Move a low-risk test issue between columns | GitHub label changes; AuditLog row appears in `GET /api/audit` |
| 10 | `kubectl logs -n <ns> <pod>` (or equivalent) | No Prisma / BigInt / FK errors |

Only flip the agent's workflow over once all ten steps pass.

## Scheduled Issue Sync Strategy

Mission Control keeps GitHub as the source of truth and stores issues only as a local cache. Cache freshness is owned by **openclaw agent heartbeat sync** rather than by a Mission Control background worker or new cluster CronJob.
Expand Down
19 changes: 19 additions & 0 deletions src/app/api/issues/move/route.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -204,4 +204,23 @@ describe("POST /api/issues/move — validation", () => {
);
expect(res.status).toBe(400);
});

it("writes an AuditLog entry with success=false when the GitHub mutation fails", async () => {
mocks.removeIssueLabel.mockRejectedValueOnce(new Error("github 500"));

const res = await postRequest(makePayload());
expect(res.status).toBe(500);
const body = await res.json();
expect(body.error).toBe("github 500");

expect(mocks.createAuditLog).toHaveBeenCalledWith({
data: expect.objectContaining({
action: "move_issue",
repoFullName: "org/repo",
issueNumber: 42,
success: false,
errorMessage: "github 500",
}),
});
});
});
78 changes: 78 additions & 0 deletions src/app/board/page.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
import { describe, expect, it, vi, beforeEach } from "vitest";

const { mocks } = vi.hoisted(() => ({
mocks: {
findManyIssues: vi.fn().mockResolvedValue([]),
findManyRepos: vi.fn().mockResolvedValue([]),
aggregateIssues: vi.fn().mockResolvedValue({ _count: { _all: 0 }, _max: { lastSyncedAt: null } }),
getTrackedRepos: vi.fn().mockResolvedValue([]),
},
}));

vi.mock("@/lib/prisma", () => ({
prisma: {
issue: { findMany: mocks.findManyIssues, aggregate: mocks.aggregateIssues },
repository: { findMany: mocks.findManyRepos },
},
}));

vi.mock("@/lib/config", () => ({
getTrackedRepos: mocks.getTrackedRepos,
}));

// Components render to React elements but are not deep-rendered here.
vi.mock("@/components/kanban-board", () => ({ KanbanBoard: () => null }));
vi.mock("@/components/filter-bar", () => ({ FilterBar: () => null }));
vi.mock("@/components/sync-issues-button", () => ({ SyncIssuesButton: () => null }));
vi.mock("@/components/ui/card", () => ({
Card: ({ children }: { children: unknown }) => children,
CardContent: ({ children }: { children: unknown }) => children,
}));

import BoardPage from "./page";

describe("BoardPage searchParams handling (Next 16 async)", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.findManyIssues.mockResolvedValue([]);
mocks.findManyRepos.mockResolvedValue([]);
mocks.aggregateIssues.mockResolvedValue({ _count: { _all: 0 }, _max: { lastSyncedAt: null } });
mocks.getTrackedRepos.mockResolvedValue([]);
});

it("awaits searchParams and applies repo filter to the issue query", async () => {
await BoardPage({ searchParams: Promise.resolve({ repo: "myorg/repo1" }) });

// The first findMany call is the filtered issue query (the second is filter-options).
const filteredCall = mocks.findManyIssues.mock.calls[0][0];
expect(filteredCall.where.repository).toEqual({ enabled: true, fullName: "myorg/repo1" });
});

it("awaits searchParams and applies agent label filter", async () => {
await BoardPage({ searchParams: Promise.resolve({ agent: "agent/alpha" }) });

const filteredCall = mocks.findManyIssues.mock.calls[0][0];
expect(filteredCall.where.labels).toEqual({ has: "agent/alpha" });
});

it("awaits searchParams and applies owner label filter", async () => {
await BoardPage({ searchParams: Promise.resolve({ owner: "owner/alice" }) });

const filteredCall = mocks.findManyIssues.mock.calls[0][0];
expect(filteredCall.where.labels).toEqual({ has: "owner/alice" });
});

it("awaits searchParams and applies priority filter", async () => {
await BoardPage({ searchParams: Promise.resolve({ priority: "priority/p1" }) });

const filteredCall = mocks.findManyIssues.mock.calls[0][0];
expect(filteredCall.where.labels).toEqual({ has: "priority/p1" });
});

it("applies no extra filters when searchParams resolves empty", async () => {
await BoardPage({ searchParams: Promise.resolve({}) });

const filteredCall = mocks.findManyIssues.mock.calls[0][0];
expect(filteredCall.where).toEqual({ repository: { enabled: true } });
});
});
17 changes: 8 additions & 9 deletions src/app/board/page.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,13 +7,12 @@ import { getTrackedRepos } from "@/lib/config";

export const dynamic = "force-dynamic";

async function getIssues(repo?: string, agent?: string, owner?: string, project?: string, priority?: string) {
async function getIssues(repo?: string, agent?: string, owner?: string, priority?: string) {
const where: Record<string, unknown> = { repository: { enabled: true } };

if (repo) where.repository = { ...(where.repository as object), fullName: repo };
if (agent) where.labels = { has: agent };
if (owner) where.labels = { has: owner };
if (project) where.labels = { has: `project/${project}` };
if (priority) where.labels = { has: priority };

return prisma.issue.findMany({
Expand DownExpand Up@@ -70,12 +69,13 @@ async function getIssueSyncStatus() {
}

interface PageProps {
searchParams: { repo?: string; agent?: string; owner?: string; project?: string; priority?: string };
searchParams: Promise<{ repo?: string; agent?: string; owner?: string; priority?: string }>;
}

export default async function BoardPage({ searchParams }: PageProps) {
const params = await searchParams;
const [issues, repos, filterOptions, syncStatus] = await Promise.all([
getIssues(searchParams.repo, searchParams.agent, searchParams.owner, searchParams.project, searchParams.priority),
getIssues(params.repo, params.agent, params.owner, params.priority),
getRepos(),
getFilterOptions(),
getIssueSyncStatus(),
Expand All@@ -100,11 +100,10 @@ export default async function BoardPage({ searchParams }: PageProps) {
agents={filterOptions.agents}
owners={filterOptions.owners}
activeFilters={{
repo: searchParams.repo || "",
agent: searchParams.agent || "",
owner: searchParams.owner || "",
project: searchParams.project || "",
priority: searchParams.priority || "",
repo: params.repo || "",
agent: params.agent || "",
owner: params.owner || "",
priority: params.priority || "",
}}
/>

Expand Down
17 changes: 6 additions & 11 deletions src/components/filter-bar.tsx
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
"use client";

import { useRouter, useSearchParams } from "next/navigation";
import { Input } from "@/components/ui/input";
import { PRIORITY_LABELS, AGENT_PREFIX, OWNER_PREFIX } from "@/types";

interface FilterBarProps {
Expand All@@ -12,7 +11,6 @@ interface FilterBarProps {
repo: string;
agent: string;
owner: string;
project: string;
priority: string;
};
}
Expand DownExpand Up@@ -47,11 +45,13 @@ export function FilterBar({ repos, agents, owners, activeFilters }: FilterBarPro
</select>

<select
aria-label="Filter by agent label"
title="Filters by agent/* label only"
value={activeFilters.agent}
onChange={(e) => updateFilter("agent", e.target.value)}
className="h-9 px-3 rounded-md border border-input bg-background text-sm"
>
<option value="">All Agents</option>
<option value="">All agent labels</option>
{agents.map((a) => (
<option key={a} value={a}>
{a.replace(AGENT_PREFIX, "")}
Expand All@@ -60,25 +60,20 @@ export function FilterBar({ repos, agents, owners, activeFilters }: FilterBarPro
</select>

<select
aria-label="Filter by owner label"
title="Filters by owner/* label only"
value={activeFilters.owner}
onChange={(e) => updateFilter("owner", e.target.value)}
className="h-9 px-3 rounded-md border border-input bg-background text-sm"
>
<option value="">All Owners</option>
<option value="">All owner labels</option>
{owners.map((o) => (
<option key={o} value={o}>
{o.replace(OWNER_PREFIX, "")}
</option>
))}
</select>

<Input
placeholder="Project name"
value={activeFilters.project}
onChange={(e) => updateFilter("project", e.target.value)}
className="h-9 w-40"
/>

<select
value={activeFilters.priority}
onChange={(e) => updateFilter("priority", e.target.value)}
Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
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
37 changes: 36 additions & 1 deletion AGENTS.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -132,4 +132,39 @@ src/
}
```

Returns 503 if database is unreachable.
Returns 503 if database is unreachable.

## OpenClaw Agent Workflow Contract

This section is the source of truth for how an OpenClaw agent should interact with Mission Control. Agents should follow this contract instead of grooming a GitHub Project board.

### Heartbeat lifecycle

At the **start** of each heartbeat:

1. **Best-effort `POST /api/sync`** to refresh Mission Control's issue cache. Treat any non-2xx, timeout, or network error as a freshness warning — log it and continue. **Do not fail the heartbeat on a sync failure.**

At the **end** of each heartbeat:

2. **`POST /api/agent-runs`** (bearer-auth with `MISSION_CONTROL_AGENT_TOKEN`) with run metadata: `agentName`, `runType`, `status`, `startedAt`, `finishedAt`, `summary`, `touchedIssueUrls`.

### Reading work

3. **Read issues from `GET /api/issues`.** Do not query the Postgres cache directly — the API is the contract.
4. **Prefer issues assigned via `agent/<agent-id>` label** if present. If no `agent/*` label exists, fall back to general backlog.
5. **Treat "no status label" or `status/backlog` as backlog work.** Both are valid entry states.

### Source of truth

6. **GitHub Issues and PRs remain the source of truth.** Mission Control's Postgres is a cache; do not write back to it as if it were authoritative.
7. **Do not rely on GitHub Projects.** The Projects board is deprecated for this workflow — group by repository instead.
8. **Do not auto-close issues without explicit evidence of completion.** Mission Control's audit log is not a license to close — a green pipeline, merged PR, or human approval is.

### Failure modes

9. **Mission Control failures must not fail the heartbeat.** Sync, agent-run POST, and issue read are all best-effort from the heartbeat's perspective. Log a warning, continue.
10. **Tokens are secrets.** `MISSION_CONTROL_AGENT_TOKEN` and `GITHUB_TOKEN` must never be logged, echoed, or persisted to disk.

### Auditability

11. **Every state-changing move on Mission Control must produce an AuditLog row.** Operators trace agent activity through `/api/audit`. Drag-and-drop moves on the Kanban board already write audit entries via `POST /api/issues/move`; agents using the same endpoint inherit this behavior.
21 changes: 21 additions & 0 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -324,6 +324,27 @@ For control actions (rerun, dispatch):
- UI shows stale warnings when `lastSyncedAt` > 1 hour ago
- Sync runs are recorded in `AutomationSyncRun` table with stats

## Pre-migration Smoke Checklist

Run this checklist before pointing an OpenClaw agent at Mission Control as its task-visibility layer (instead of a GitHub Project board). Every step should pass; stop and investigate on the first failure.

Set `BASE` to your Mission Control URL (e.g. `BASE=https://mission-control.internal`) before running.

| # | Check | Expected |
|---|-------|----------|
| 1 | `curl -fsS "$BASE/api/health"` | `{"ok":true,"database":"ok",...}` |
| 2 | `curl -fsS -X POST "$BASE/api/automation/sync"` | 2xx, no error body |
| 3 | `curl -fsS "$BASE/api/automation/repos"` | JSON array, non-empty if repos are configured |
| 4 | `curl -fsS -X POST "$BASE/api/sync"` | `syncedCount > 0` |
| 5 | `curl -fsS "$BASE/api/issues"` | JSON array, length > 0 |
| 6 | Open `/board` in a browser | Issues render — no "no issues synced yet" empty state |
| 7 | Open `/projects` | Repo groups render |
| 8 | Open `/agents` | Recent OpenClaw agent heartbeat visible with agent name |
| 9 | Move a low-risk test issue between columns | GitHub label changes; AuditLog row appears in `GET /api/audit` |
| 10 | `kubectl logs -n <ns> <pod>` (or equivalent) | No Prisma / BigInt / FK errors |

Only flip the agent's workflow over once all ten steps pass.

## Scheduled Issue Sync Strategy

Mission Control keeps GitHub as the source of truth and stores issues only as a local cache. Cache freshness is owned by **openclaw agent heartbeat sync** rather than by a Mission Control background worker or new cluster CronJob.
Expand Down
19 changes: 19 additions & 0 deletions src/app/api/issues/move/route.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -204,4 +204,23 @@ describe("POST /api/issues/move — validation", () => {
);
expect(res.status).toBe(400);
});

it("writes an AuditLog entry with success=false when the GitHub mutation fails", async () => {
mocks.removeIssueLabel.mockRejectedValueOnce(new Error("github 500"));

const res = await postRequest(makePayload());
expect(res.status).toBe(500);
const body = await res.json();
expect(body.error).toBe("github 500");

expect(mocks.createAuditLog).toHaveBeenCalledWith({
data: expect.objectContaining({
action: "move_issue",
repoFullName: "org/repo",
issueNumber: 42,
success: false,
errorMessage: "github 500",
}),
});
});
});
78 changes: 78 additions & 0 deletions src/app/board/page.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
import { describe, expect, it, vi, beforeEach } from "vitest";

const { mocks } = vi.hoisted(() => ({
mocks: {
findManyIssues: vi.fn().mockResolvedValue([]),
findManyRepos: vi.fn().mockResolvedValue([]),
aggregateIssues: vi.fn().mockResolvedValue({ _count: { _all: 0 }, _max: { lastSyncedAt: null } }),
getTrackedRepos: vi.fn().mockResolvedValue([]),
},
}));

vi.mock("@/lib/prisma", () => ({
prisma: {
issue: { findMany: mocks.findManyIssues, aggregate: mocks.aggregateIssues },
repository: { findMany: mocks.findManyRepos },
},
}));

vi.mock("@/lib/config", () => ({
getTrackedRepos: mocks.getTrackedRepos,
}));

// Components render to React elements but are not deep-rendered here.
vi.mock("@/components/kanban-board", () => ({ KanbanBoard: () => null }));
vi.mock("@/components/filter-bar", () => ({ FilterBar: () => null }));
vi.mock("@/components/sync-issues-button", () => ({ SyncIssuesButton: () => null }));
vi.mock("@/components/ui/card", () => ({
Card: ({ children }: { children: unknown }) => children,
CardContent: ({ children }: { children: unknown }) => children,
}));

import BoardPage from "./page";

describe("BoardPage searchParams handling (Next 16 async)", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.findManyIssues.mockResolvedValue([]);
mocks.findManyRepos.mockResolvedValue([]);
mocks.aggregateIssues.mockResolvedValue({ _count: { _all: 0 }, _max: { lastSyncedAt: null } });
mocks.getTrackedRepos.mockResolvedValue([]);
});

it("awaits searchParams and applies repo filter to the issue query", async () => {
await BoardPage({ searchParams: Promise.resolve({ repo: "myorg/repo1" }) });

// The first findMany call is the filtered issue query (the second is filter-options).
const filteredCall = mocks.findManyIssues.mock.calls[0][0];
expect(filteredCall.where.repository).toEqual({ enabled: true, fullName: "myorg/repo1" });
});

it("awaits searchParams and applies agent label filter", async () => {
await BoardPage({ searchParams: Promise.resolve({ agent: "agent/alpha" }) });

const filteredCall = mocks.findManyIssues.mock.calls[0][0];
expect(filteredCall.where.labels).toEqual({ has: "agent/alpha" });
});

it("awaits searchParams and applies owner label filter", async () => {
await BoardPage({ searchParams: Promise.resolve({ owner: "owner/alice" }) });

const filteredCall = mocks.findManyIssues.mock.calls[0][0];
expect(filteredCall.where.labels).toEqual({ has: "owner/alice" });
});

it("awaits searchParams and applies priority filter", async () => {
await BoardPage({ searchParams: Promise.resolve({ priority: "priority/p1" }) });

const filteredCall = mocks.findManyIssues.mock.calls[0][0];
expect(filteredCall.where.labels).toEqual({ has: "priority/p1" });
});

it("applies no extra filters when searchParams resolves empty", async () => {
await BoardPage({ searchParams: Promise.resolve({}) });

const filteredCall = mocks.findManyIssues.mock.calls[0][0];
expect(filteredCall.where).toEqual({ repository: { enabled: true } });
});
});
17 changes: 8 additions & 9 deletions src/app/board/page.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,13 +7,12 @@ import { getTrackedRepos } from "@/lib/config";

export const dynamic = "force-dynamic";

async function getIssues(repo?: string, agent?: string, owner?: string, project?: string, priority?: string) {
async function getIssues(repo?: string, agent?: string, owner?: string, priority?: string) {
const where: Record<string, unknown> = { repository: { enabled: true } };

if (repo) where.repository = { ...(where.repository as object), fullName: repo };
if (agent) where.labels = { has: agent };
if (owner) where.labels = { has: owner };
if (project) where.labels = { has: `project/${project}` };
if (priority) where.labels = { has: priority };

return prisma.issue.findMany({
Expand DownExpand Up@@ -70,12 +69,13 @@ async function getIssueSyncStatus() {
}

interface PageProps {
searchParams: { repo?: string; agent?: string; owner?: string; project?: string; priority?: string };
searchParams: Promise<{ repo?: string; agent?: string; owner?: string; priority?: string }>;
}

export default async function BoardPage({ searchParams }: PageProps) {
const params = await searchParams;
const [issues, repos, filterOptions, syncStatus] = await Promise.all([
getIssues(searchParams.repo, searchParams.agent, searchParams.owner, searchParams.project, searchParams.priority),
getIssues(params.repo, params.agent, params.owner, params.priority),
getRepos(),
getFilterOptions(),
getIssueSyncStatus(),
Expand All@@ -100,11 +100,10 @@ export default async function BoardPage({ searchParams }: PageProps) {
agents={filterOptions.agents}
owners={filterOptions.owners}
activeFilters={{
repo: searchParams.repo || "",
agent: searchParams.agent || "",
owner: searchParams.owner || "",
project: searchParams.project || "",
priority: searchParams.priority || "",
repo: params.repo || "",
agent: params.agent || "",
owner: params.owner || "",
priority: params.priority || "",
}}
/>

Expand Down
17 changes: 6 additions & 11 deletions src/components/filter-bar.tsx
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
"use client";

import { useRouter, useSearchParams } from "next/navigation";
import { Input } from "@/components/ui/input";
import { PRIORITY_LABELS, AGENT_PREFIX, OWNER_PREFIX } from "@/types";

interface FilterBarProps {
Expand All@@ -12,7 +11,6 @@ interface FilterBarProps {
repo: string;
agent: string;
owner: string;
project: string;
priority: string;
};
}
Expand DownExpand Up@@ -47,11 +45,13 @@ export function FilterBar({ repos, agents, owners, activeFilters }: FilterBarPro
</select>

<select
aria-label="Filter by agent label"
title="Filters by agent/* label only"
value={activeFilters.agent}
onChange={(e) => updateFilter("agent", e.target.value)}
className="h-9 px-3 rounded-md border border-input bg-background text-sm"
>
<option value="">All Agents</option>
<option value="">All agent labels</option>
{agents.map((a) => (
<option key={a} value={a}>
{a.replace(AGENT_PREFIX, "")}
Expand All@@ -60,25 +60,20 @@ export function FilterBar({ repos, agents, owners, activeFilters }: FilterBarPro
</select>

<select
aria-label="Filter by owner label"
title="Filters by owner/* label only"
value={activeFilters.owner}
onChange={(e) => updateFilter("owner", e.target.value)}
className="h-9 px-3 rounded-md border border-input bg-background text-sm"
>
<option value="">All Owners</option>
<option value="">All owner labels</option>
{owners.map((o) => (
<option key={o} value={o}>
{o.replace(OWNER_PREFIX, "")}
</option>
))}
</select>

<Input
placeholder="Project name"
value={activeFilters.project}
onChange={(e) => updateFilter("project", e.target.value)}
className="h-9 w-40"
/>

<select
value={activeFilters.priority}
onChange={(e) => updateFilter("priority", e.target.value)}
Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
37 changes: 36 additions & 1 deletion AGENTS.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -132,4 +132,39 @@ src/
}
```

Returns 503 if database is unreachable.
Returns 503 if database is unreachable.

## OpenClaw Agent Workflow Contract

This section is the source of truth for how an OpenClaw agent should interact with Mission Control. Agents should follow this contract instead of grooming a GitHub Project board.

### Heartbeat lifecycle

At the **start** of each heartbeat:

1. **Best-effort `POST /api/sync`** to refresh Mission Control's issue cache. Treat any non-2xx, timeout, or network error as a freshness warning — log it and continue. **Do not fail the heartbeat on a sync failure.**

At the **end** of each heartbeat:

2. **`POST /api/agent-runs`** (bearer-auth with `MISSION_CONTROL_AGENT_TOKEN`) with run metadata: `agentName`, `runType`, `status`, `startedAt`, `finishedAt`, `summary`, `touchedIssueUrls`.

### Reading work

3. **Read issues from `GET /api/issues`.** Do not query the Postgres cache directly — the API is the contract.
4. **Prefer issues assigned via `agent/<agent-id>` label** if present. If no `agent/*` label exists, fall back to general backlog.
5. **Treat "no status label" or `status/backlog` as backlog work.** Both are valid entry states.

### Source of truth

6. **GitHub Issues and PRs remain the source of truth.** Mission Control's Postgres is a cache; do not write back to it as if it were authoritative.
7. **Do not rely on GitHub Projects.** The Projects board is deprecated for this workflow — group by repository instead.
8. **Do not auto-close issues without explicit evidence of completion.** Mission Control's audit log is not a license to close — a green pipeline, merged PR, or human approval is.

### Failure modes

9. **Mission Control failures must not fail the heartbeat.** Sync, agent-run POST, and issue read are all best-effort from the heartbeat's perspective. Log a warning, continue.
10. **Tokens are secrets.** `MISSION_CONTROL_AGENT_TOKEN` and `GITHUB_TOKEN` must never be logged, echoed, or persisted to disk.

### Auditability

11. **Every state-changing move on Mission Control must produce an AuditLog row.** Operators trace agent activity through `/api/audit`. Drag-and-drop moves on the Kanban board already write audit entries via `POST /api/issues/move`; agents using the same endpoint inherit this behavior.
21 changes: 21 additions & 0 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -324,6 +324,27 @@ For control actions (rerun, dispatch):
- UI shows stale warnings when `lastSyncedAt` > 1 hour ago
- Sync runs are recorded in `AutomationSyncRun` table with stats

## Pre-migration Smoke Checklist

Run this checklist before pointing an OpenClaw agent at Mission Control as its task-visibility layer (instead of a GitHub Project board). Every step should pass; stop and investigate on the first failure.

Set `BASE` to your Mission Control URL (e.g. `BASE=https://mission-control.internal`) before running.

| # | Check | Expected |
|---|-------|----------|
| 1 | `curl -fsS "$BASE/api/health"` | `{"ok":true,"database":"ok",...}` |
| 2 | `curl -fsS -X POST "$BASE/api/automation/sync"` | 2xx, no error body |
| 3 | `curl -fsS "$BASE/api/automation/repos"` | JSON array, non-empty if repos are configured |
| 4 | `curl -fsS -X POST "$BASE/api/sync"` | `syncedCount > 0` |
| 5 | `curl -fsS "$BASE/api/issues"` | JSON array, length > 0 |
| 6 | Open `/board` in a browser | Issues render — no "no issues synced yet" empty state |
| 7 | Open `/projects` | Repo groups render |
| 8 | Open `/agents` | Recent OpenClaw agent heartbeat visible with agent name |
| 9 | Move a low-risk test issue between columns | GitHub label changes; AuditLog row appears in `GET /api/audit` |
| 10 | `kubectl logs -n <ns> <pod>` (or equivalent) | No Prisma / BigInt / FK errors |

Only flip the agent's workflow over once all ten steps pass.

## Scheduled Issue Sync Strategy

Mission Control keeps GitHub as the source of truth and stores issues only as a local cache. Cache freshness is owned by **openclaw agent heartbeat sync** rather than by a Mission Control background worker or new cluster CronJob.
Expand Down
19 changes: 19 additions & 0 deletions src/app/api/issues/move/route.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -204,4 +204,23 @@ describe("POST /api/issues/move — validation", () => {
);
expect(res.status).toBe(400);
});

it("writes an AuditLog entry with success=false when the GitHub mutation fails", async () => {
mocks.removeIssueLabel.mockRejectedValueOnce(new Error("github 500"));

const res = await postRequest(makePayload());
expect(res.status).toBe(500);
const body = await res.json();
expect(body.error).toBe("github 500");

expect(mocks.createAuditLog).toHaveBeenCalledWith({
data: expect.objectContaining({
action: "move_issue",
repoFullName: "org/repo",
issueNumber: 42,
success: false,
errorMessage: "github 500",
}),
});
});
});
78 changes: 78 additions & 0 deletions src/app/board/page.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
import { describe, expect, it, vi, beforeEach } from "vitest";

const { mocks } = vi.hoisted(() => ({
mocks: {
findManyIssues: vi.fn().mockResolvedValue([]),
findManyRepos: vi.fn().mockResolvedValue([]),
aggregateIssues: vi.fn().mockResolvedValue({ _count: { _all: 0 }, _max: { lastSyncedAt: null } }),
getTrackedRepos: vi.fn().mockResolvedValue([]),
},
}));

vi.mock("@/lib/prisma", () => ({
prisma: {
issue: { findMany: mocks.findManyIssues, aggregate: mocks.aggregateIssues },
repository: { findMany: mocks.findManyRepos },
},
}));

vi.mock("@/lib/config", () => ({
getTrackedRepos: mocks.getTrackedRepos,
}));

// Components render to React elements but are not deep-rendered here.
vi.mock("@/components/kanban-board", () => ({ KanbanBoard: () => null }));
vi.mock("@/components/filter-bar", () => ({ FilterBar: () => null }));
vi.mock("@/components/sync-issues-button", () => ({ SyncIssuesButton: () => null }));
vi.mock("@/components/ui/card", () => ({
Card: ({ children }: { children: unknown }) => children,
CardContent: ({ children }: { children: unknown }) => children,
}));

import BoardPage from "./page";

describe("BoardPage searchParams handling (Next 16 async)", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.findManyIssues.mockResolvedValue([]);
mocks.findManyRepos.mockResolvedValue([]);
mocks.aggregateIssues.mockResolvedValue({ _count: { _all: 0 }, _max: { lastSyncedAt: null } });
mocks.getTrackedRepos.mockResolvedValue([]);
});

it("awaits searchParams and applies repo filter to the issue query", async () => {
await BoardPage({ searchParams: Promise.resolve({ repo: "myorg/repo1" }) });

// The first findMany call is the filtered issue query (the second is filter-options).
const filteredCall = mocks.findManyIssues.mock.calls[0][0];
expect(filteredCall.where.repository).toEqual({ enabled: true, fullName: "myorg/repo1" });
});

it("awaits searchParams and applies agent label filter", async () => {
await BoardPage({ searchParams: Promise.resolve({ agent: "agent/alpha" }) });

const filteredCall = mocks.findManyIssues.mock.calls[0][0];
expect(filteredCall.where.labels).toEqual({ has: "agent/alpha" });
});

it("awaits searchParams and applies owner label filter", async () => {
await BoardPage({ searchParams: Promise.resolve({ owner: "owner/alice" }) });

const filteredCall = mocks.findManyIssues.mock.calls[0][0];
expect(filteredCall.where.labels).toEqual({ has: "owner/alice" });
});

it("awaits searchParams and applies priority filter", async () => {
await BoardPage({ searchParams: Promise.resolve({ priority: "priority/p1" }) });

const filteredCall = mocks.findManyIssues.mock.calls[0][0];
expect(filteredCall.where.labels).toEqual({ has: "priority/p1" });
});

it("applies no extra filters when searchParams resolves empty", async () => {
await BoardPage({ searchParams: Promise.resolve({}) });

const filteredCall = mocks.findManyIssues.mock.calls[0][0];
expect(filteredCall.where).toEqual({ repository: { enabled: true } });
});
});
17 changes: 8 additions & 9 deletions src/app/board/page.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,13 +7,12 @@ import { getTrackedRepos } from "@/lib/config";

export const dynamic = "force-dynamic";

async function getIssues(repo?: string, agent?: string, owner?: string, project?: string, priority?: string) {
async function getIssues(repo?: string, agent?: string, owner?: string, priority?: string) {
const where: Record<string, unknown> = { repository: { enabled: true } };

if (repo) where.repository = { ...(where.repository as object), fullName: repo };
if (agent) where.labels = { has: agent };
if (owner) where.labels = { has: owner };
if (project) where.labels = { has: `project/${project}` };
if (priority) where.labels = { has: priority };

return prisma.issue.findMany({
Expand DownExpand Up@@ -70,12 +69,13 @@ async function getIssueSyncStatus() {
}

interface PageProps {
searchParams: { repo?: string; agent?: string; owner?: string; project?: string; priority?: string };
searchParams: Promise<{ repo?: string; agent?: string; owner?: string; priority?: string }>;
}

export default async function BoardPage({ searchParams }: PageProps) {
const params = await searchParams;
const [issues, repos, filterOptions, syncStatus] = await Promise.all([
getIssues(searchParams.repo, searchParams.agent, searchParams.owner, searchParams.project, searchParams.priority),
getIssues(params.repo, params.agent, params.owner, params.priority),
getRepos(),
getFilterOptions(),
getIssueSyncStatus(),
Expand All@@ -100,11 +100,10 @@ export default async function BoardPage({ searchParams }: PageProps) {
agents={filterOptions.agents}
owners={filterOptions.owners}
activeFilters={{
repo: searchParams.repo || "",
agent: searchParams.agent || "",
owner: searchParams.owner || "",
project: searchParams.project || "",
priority: searchParams.priority || "",
repo: params.repo || "",
agent: params.agent || "",
owner: params.owner || "",
priority: params.priority || "",
}}
/>

Expand Down
17 changes: 6 additions & 11 deletions src/components/filter-bar.tsx
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
"use client";

import { useRouter, useSearchParams } from "next/navigation";
import { Input } from "@/components/ui/input";
import { PRIORITY_LABELS, AGENT_PREFIX, OWNER_PREFIX } from "@/types";

interface FilterBarProps {
Expand All@@ -12,7 +11,6 @@ interface FilterBarProps {
repo: string;
agent: string;
owner: string;
project: string;
priority: string;
};
}
Expand DownExpand Up@@ -47,11 +45,13 @@ export function FilterBar({ repos, agents, owners, activeFilters }: FilterBarPro
</select>

<select
aria-label="Filter by agent label"
title="Filters by agent/* label only"
value={activeFilters.agent}
onChange={(e) => updateFilter("agent", e.target.value)}
className="h-9 px-3 rounded-md border border-input bg-background text-sm"
>
<option value="">All Agents</option>
<option value="">All agent labels</option>
{agents.map((a) => (
<option key={a} value={a}>
{a.replace(AGENT_PREFIX, "")}
Expand All@@ -60,25 +60,20 @@ export function FilterBar({ repos, agents, owners, activeFilters }: FilterBarPro
</select>

<select
aria-label="Filter by owner label"
title="Filters by owner/* label only"
value={activeFilters.owner}
onChange={(e) => updateFilter("owner", e.target.value)}
className="h-9 px-3 rounded-md border border-input bg-background text-sm"
>
<option value="">All Owners</option>
<option value="">All owner labels</option>
{owners.map((o) => (
<option key={o} value={o}>
{o.replace(OWNER_PREFIX, "")}
</option>
))}
</select>

<Input
placeholder="Project name"
value={activeFilters.project}
onChange={(e) => updateFilter("project", e.target.value)}
className="h-9 w-40"
/>

<select
value={activeFilters.priority}
onChange={(e) => updateFilter("priority", e.target.value)}
Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
37 changes: 36 additions & 1 deletion AGENTS.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -132,4 +132,39 @@ src/
}
```

Returns 503 if database is unreachable.
Returns 503 if database is unreachable.

## OpenClaw Agent Workflow Contract

This section is the source of truth for how an OpenClaw agent should interact with Mission Control. Agents should follow this contract instead of grooming a GitHub Project board.

### Heartbeat lifecycle

At the **start** of each heartbeat:

1. **Best-effort `POST /api/sync`** to refresh Mission Control's issue cache. Treat any non-2xx, timeout, or network error as a freshness warning — log it and continue. **Do not fail the heartbeat on a sync failure.**

At the **end** of each heartbeat:

2. **`POST /api/agent-runs`** (bearer-auth with `MISSION_CONTROL_AGENT_TOKEN`) with run metadata: `agentName`, `runType`, `status`, `startedAt`, `finishedAt`, `summary`, `touchedIssueUrls`.

### Reading work

3. **Read issues from `GET /api/issues`.** Do not query the Postgres cache directly — the API is the contract.
4. **Prefer issues assigned via `agent/<agent-id>` label** if present. If no `agent/*` label exists, fall back to general backlog.
5. **Treat "no status label" or `status/backlog` as backlog work.** Both are valid entry states.

### Source of truth

6. **GitHub Issues and PRs remain the source of truth.** Mission Control's Postgres is a cache; do not write back to it as if it were authoritative.
7. **Do not rely on GitHub Projects.** The Projects board is deprecated for this workflow — group by repository instead.
8. **Do not auto-close issues without explicit evidence of completion.** Mission Control's audit log is not a license to close — a green pipeline, merged PR, or human approval is.

### Failure modes

9. **Mission Control failures must not fail the heartbeat.** Sync, agent-run POST, and issue read are all best-effort from the heartbeat's perspective. Log a warning, continue.
10. **Tokens are secrets.** `MISSION_CONTROL_AGENT_TOKEN` and `GITHUB_TOKEN` must never be logged, echoed, or persisted to disk.

### Auditability

11. **Every state-changing move on Mission Control must produce an AuditLog row.** Operators trace agent activity through `/api/audit`. Drag-and-drop moves on the Kanban board already write audit entries via `POST /api/issues/move`; agents using the same endpoint inherit this behavior.
21 changes: 21 additions & 0 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -324,6 +324,27 @@ For control actions (rerun, dispatch):
- UI shows stale warnings when `lastSyncedAt` > 1 hour ago
- Sync runs are recorded in `AutomationSyncRun` table with stats

## Pre-migration Smoke Checklist

Run this checklist before pointing an OpenClaw agent at Mission Control as its task-visibility layer (instead of a GitHub Project board). Every step should pass; stop and investigate on the first failure.

Set `BASE` to your Mission Control URL (e.g. `BASE=https://mission-control.internal`) before running.

| # | Check | Expected |
|---|-------|----------|
| 1 | `curl -fsS "$BASE/api/health"` | `{"ok":true,"database":"ok",...}` |
| 2 | `curl -fsS -X POST "$BASE/api/automation/sync"` | 2xx, no error body |
| 3 | `curl -fsS "$BASE/api/automation/repos"` | JSON array, non-empty if repos are configured |
| 4 | `curl -fsS -X POST "$BASE/api/sync"` | `syncedCount > 0` |
| 5 | `curl -fsS "$BASE/api/issues"` | JSON array, length > 0 |
| 6 | Open `/board` in a browser | Issues render — no "no issues synced yet" empty state |
| 7 | Open `/projects` | Repo groups render |
| 8 | Open `/agents` | Recent OpenClaw agent heartbeat visible with agent name |
| 9 | Move a low-risk test issue between columns | GitHub label changes; AuditLog row appears in `GET /api/audit` |
| 10 | `kubectl logs -n <ns> <pod>` (or equivalent) | No Prisma / BigInt / FK errors |

Only flip the agent's workflow over once all ten steps pass.

## Scheduled Issue Sync Strategy

Mission Control keeps GitHub as the source of truth and stores issues only as a local cache. Cache freshness is owned by **openclaw agent heartbeat sync** rather than by a Mission Control background worker or new cluster CronJob.
Expand Down
19 changes: 19 additions & 0 deletions src/app/api/issues/move/route.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -204,4 +204,23 @@ describe("POST /api/issues/move — validation", () => {
);
expect(res.status).toBe(400);
});

it("writes an AuditLog entry with success=false when the GitHub mutation fails", async () => {
mocks.removeIssueLabel.mockRejectedValueOnce(new Error("github 500"));

const res = await postRequest(makePayload());
expect(res.status).toBe(500);
const body = await res.json();
expect(body.error).toBe("github 500");

expect(mocks.createAuditLog).toHaveBeenCalledWith({
data: expect.objectContaining({
action: "move_issue",
repoFullName: "org/repo",
issueNumber: 42,
success: false,
errorMessage: "github 500",
}),
});
});
});
78 changes: 78 additions & 0 deletions src/app/board/page.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
import { describe, expect, it, vi, beforeEach } from "vitest";

const { mocks } = vi.hoisted(() => ({
mocks: {
findManyIssues: vi.fn().mockResolvedValue([]),
findManyRepos: vi.fn().mockResolvedValue([]),
aggregateIssues: vi.fn().mockResolvedValue({ _count: { _all: 0 }, _max: { lastSyncedAt: null } }),
getTrackedRepos: vi.fn().mockResolvedValue([]),
},
}));

vi.mock("@/lib/prisma", () => ({
prisma: {
issue: { findMany: mocks.findManyIssues, aggregate: mocks.aggregateIssues },
repository: { findMany: mocks.findManyRepos },
},
}));

vi.mock("@/lib/config", () => ({
getTrackedRepos: mocks.getTrackedRepos,
}));

// Components render to React elements but are not deep-rendered here.
vi.mock("@/components/kanban-board", () => ({ KanbanBoard: () => null }));
vi.mock("@/components/filter-bar", () => ({ FilterBar: () => null }));
vi.mock("@/components/sync-issues-button", () => ({ SyncIssuesButton: () => null }));
vi.mock("@/components/ui/card", () => ({
Card: ({ children }: { children: unknown }) => children,
CardContent: ({ children }: { children: unknown }) => children,
}));

import BoardPage from "./page";

describe("BoardPage searchParams handling (Next 16 async)", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.findManyIssues.mockResolvedValue([]);
mocks.findManyRepos.mockResolvedValue([]);
mocks.aggregateIssues.mockResolvedValue({ _count: { _all: 0 }, _max: { lastSyncedAt: null } });
mocks.getTrackedRepos.mockResolvedValue([]);
});

it("awaits searchParams and applies repo filter to the issue query", async () => {
await BoardPage({ searchParams: Promise.resolve({ repo: "myorg/repo1" }) });

// The first findMany call is the filtered issue query (the second is filter-options).
const filteredCall = mocks.findManyIssues.mock.calls[0][0];
expect(filteredCall.where.repository).toEqual({ enabled: true, fullName: "myorg/repo1" });
});

it("awaits searchParams and applies agent label filter", async () => {
await BoardPage({ searchParams: Promise.resolve({ agent: "agent/alpha" }) });

const filteredCall = mocks.findManyIssues.mock.calls[0][0];
expect(filteredCall.where.labels).toEqual({ has: "agent/alpha" });
});

it("awaits searchParams and applies owner label filter", async () => {
await BoardPage({ searchParams: Promise.resolve({ owner: "owner/alice" }) });

const filteredCall = mocks.findManyIssues.mock.calls[0][0];
expect(filteredCall.where.labels).toEqual({ has: "owner/alice" });
});

it("awaits searchParams and applies priority filter", async () => {
await BoardPage({ searchParams: Promise.resolve({ priority: "priority/p1" }) });

const filteredCall = mocks.findManyIssues.mock.calls[0][0];
expect(filteredCall.where.labels).toEqual({ has: "priority/p1" });
});

it("applies no extra filters when searchParams resolves empty", async () => {
await BoardPage({ searchParams: Promise.resolve({}) });

const filteredCall = mocks.findManyIssues.mock.calls[0][0];
expect(filteredCall.where).toEqual({ repository: { enabled: true } });
});
});
17 changes: 8 additions & 9 deletions src/app/board/page.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,13 +7,12 @@ import { getTrackedRepos } from "@/lib/config";

export const dynamic = "force-dynamic";

async function getIssues(repo?: string, agent?: string, owner?: string, project?: string, priority?: string) {
async function getIssues(repo?: string, agent?: string, owner?: string, priority?: string) {
const where: Record<string, unknown> = { repository: { enabled: true } };

if (repo) where.repository = { ...(where.repository as object), fullName: repo };
if (agent) where.labels = { has: agent };
if (owner) where.labels = { has: owner };
if (project) where.labels = { has: `project/${project}` };
if (priority) where.labels = { has: priority };

return prisma.issue.findMany({
Expand DownExpand Up@@ -70,12 +69,13 @@ async function getIssueSyncStatus() {
}

interface PageProps {
searchParams: { repo?: string; agent?: string; owner?: string; project?: string; priority?: string };
searchParams: Promise<{ repo?: string; agent?: string; owner?: string; priority?: string }>;
}

export default async function BoardPage({ searchParams }: PageProps) {
const params = await searchParams;
const [issues, repos, filterOptions, syncStatus] = await Promise.all([
getIssues(searchParams.repo, searchParams.agent, searchParams.owner, searchParams.project, searchParams.priority),
getIssues(params.repo, params.agent, params.owner, params.priority),
getRepos(),
getFilterOptions(),
getIssueSyncStatus(),
Expand All@@ -100,11 +100,10 @@ export default async function BoardPage({ searchParams }: PageProps) {
agents={filterOptions.agents}
owners={filterOptions.owners}
activeFilters={{
repo: searchParams.repo || "",
agent: searchParams.agent || "",
owner: searchParams.owner || "",
project: searchParams.project || "",
priority: searchParams.priority || "",
repo: params.repo || "",
agent: params.agent || "",
owner: params.owner || "",
priority: params.priority || "",
}}
/>

Expand Down
17 changes: 6 additions & 11 deletions src/components/filter-bar.tsx
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
"use client";

import { useRouter, useSearchParams } from "next/navigation";
import { Input } from "@/components/ui/input";
import { PRIORITY_LABELS, AGENT_PREFIX, OWNER_PREFIX } from "@/types";

interface FilterBarProps {
Expand All@@ -12,7 +11,6 @@ interface FilterBarProps {
repo: string;
agent: string;
owner: string;
project: string;
priority: string;
};
}
Expand DownExpand Up@@ -47,11 +45,13 @@ export function FilterBar({ repos, agents, owners, activeFilters }: FilterBarPro
</select>

<select
aria-label="Filter by agent label"
title="Filters by agent/* label only"
value={activeFilters.agent}
onChange={(e) => updateFilter("agent", e.target.value)}
className="h-9 px-3 rounded-md border border-input bg-background text-sm"
>
<option value="">All Agents</option>
<option value="">All agent labels</option>
{agents.map((a) => (
<option key={a} value={a}>
{a.replace(AGENT_PREFIX, "")}
Expand All@@ -60,25 +60,20 @@ export function FilterBar({ repos, agents, owners, activeFilters }: FilterBarPro
</select>

<select
aria-label="Filter by owner label"
title="Filters by owner/* label only"
value={activeFilters.owner}
onChange={(e) => updateFilter("owner", e.target.value)}
className="h-9 px-3 rounded-md border border-input bg-background text-sm"
>
<option value="">All Owners</option>
<option value="">All owner labels</option>
{owners.map((o) => (
<option key={o} value={o}>
{o.replace(OWNER_PREFIX, "")}
</option>
))}
</select>

<Input
placeholder="Project name"
value={activeFilters.project}
onChange={(e) => updateFilter("project", e.target.value)}
className="h-9 w-40"
/>

<select
value={activeFilters.priority}
onChange={(e) => updateFilter("priority", e.target.value)}
Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
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
37 changes: 36 additions & 1 deletion AGENTS.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -132,4 +132,39 @@ src/
}
```

Returns 503 if database is unreachable.
Returns 503 if database is unreachable.

## OpenClaw Agent Workflow Contract

This section is the source of truth for how an OpenClaw agent should interact with Mission Control. Agents should follow this contract instead of grooming a GitHub Project board.

### Heartbeat lifecycle

At the **start** of each heartbeat:

1. **Best-effort `POST /api/sync`** to refresh Mission Control's issue cache. Treat any non-2xx, timeout, or network error as a freshness warning — log it and continue. **Do not fail the heartbeat on a sync failure.**

At the **end** of each heartbeat:

2. **`POST /api/agent-runs`** (bearer-auth with `MISSION_CONTROL_AGENT_TOKEN`) with run metadata: `agentName`, `runType`, `status`, `startedAt`, `finishedAt`, `summary`, `touchedIssueUrls`.

### Reading work

3. **Read issues from `GET /api/issues`.** Do not query the Postgres cache directly — the API is the contract.
4. **Prefer issues assigned via `agent/<agent-id>` label** if present. If no `agent/*` label exists, fall back to general backlog.
5. **Treat "no status label" or `status/backlog` as backlog work.** Both are valid entry states.

### Source of truth

6. **GitHub Issues and PRs remain the source of truth.** Mission Control's Postgres is a cache; do not write back to it as if it were authoritative.
7. **Do not rely on GitHub Projects.** The Projects board is deprecated for this workflow — group by repository instead.
8. **Do not auto-close issues without explicit evidence of completion.** Mission Control's audit log is not a license to close — a green pipeline, merged PR, or human approval is.

### Failure modes

9. **Mission Control failures must not fail the heartbeat.** Sync, agent-run POST, and issue read are all best-effort from the heartbeat's perspective. Log a warning, continue.
10. **Tokens are secrets.** `MISSION_CONTROL_AGENT_TOKEN` and `GITHUB_TOKEN` must never be logged, echoed, or persisted to disk.

### Auditability

11. **Every state-changing move on Mission Control must produce an AuditLog row.** Operators trace agent activity through `/api/audit`. Drag-and-drop moves on the Kanban board already write audit entries via `POST /api/issues/move`; agents using the same endpoint inherit this behavior.
21 changes: 21 additions & 0 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -324,6 +324,27 @@ For control actions (rerun, dispatch):
- UI shows stale warnings when `lastSyncedAt` > 1 hour ago
- Sync runs are recorded in `AutomationSyncRun` table with stats

## Pre-migration Smoke Checklist

Run this checklist before pointing an OpenClaw agent at Mission Control as its task-visibility layer (instead of a GitHub Project board). Every step should pass; stop and investigate on the first failure.

Set `BASE` to your Mission Control URL (e.g. `BASE=https://mission-control.internal`) before running.

| # | Check | Expected |
|---|-------|----------|
| 1 | `curl -fsS "$BASE/api/health"` | `{"ok":true,"database":"ok",...}` |
| 2 | `curl -fsS -X POST "$BASE/api/automation/sync"` | 2xx, no error body |
| 3 | `curl -fsS "$BASE/api/automation/repos"` | JSON array, non-empty if repos are configured |
| 4 | `curl -fsS -X POST "$BASE/api/sync"` | `syncedCount > 0` |
| 5 | `curl -fsS "$BASE/api/issues"` | JSON array, length > 0 |
| 6 | Open `/board` in a browser | Issues render — no "no issues synced yet" empty state |
| 7 | Open `/projects` | Repo groups render |
| 8 | Open `/agents` | Recent OpenClaw agent heartbeat visible with agent name |
| 9 | Move a low-risk test issue between columns | GitHub label changes; AuditLog row appears in `GET /api/audit` |
| 10 | `kubectl logs -n <ns> <pod>` (or equivalent) | No Prisma / BigInt / FK errors |

Only flip the agent's workflow over once all ten steps pass.

## Scheduled Issue Sync Strategy

Mission Control keeps GitHub as the source of truth and stores issues only as a local cache. Cache freshness is owned by **openclaw agent heartbeat sync** rather than by a Mission Control background worker or new cluster CronJob.
Expand Down
19 changes: 19 additions & 0 deletions src/app/api/issues/move/route.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -204,4 +204,23 @@ describe("POST /api/issues/move — validation", () => {
);
expect(res.status).toBe(400);
});

it("writes an AuditLog entry with success=false when the GitHub mutation fails", async () => {
mocks.removeIssueLabel.mockRejectedValueOnce(new Error("github 500"));

const res = await postRequest(makePayload());
expect(res.status).toBe(500);
const body = await res.json();
expect(body.error).toBe("github 500");

expect(mocks.createAuditLog).toHaveBeenCalledWith({
data: expect.objectContaining({
action: "move_issue",
repoFullName: "org/repo",
issueNumber: 42,
success: false,
errorMessage: "github 500",
}),
});
});
});
78 changes: 78 additions & 0 deletions src/app/board/page.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
import { describe, expect, it, vi, beforeEach } from "vitest";

const { mocks } = vi.hoisted(() => ({
mocks: {
findManyIssues: vi.fn().mockResolvedValue([]),
findManyRepos: vi.fn().mockResolvedValue([]),
aggregateIssues: vi.fn().mockResolvedValue({ _count: { _all: 0 }, _max: { lastSyncedAt: null } }),
getTrackedRepos: vi.fn().mockResolvedValue([]),
},
}));

vi.mock("@/lib/prisma", () => ({
prisma: {
issue: { findMany: mocks.findManyIssues, aggregate: mocks.aggregateIssues },
repository: { findMany: mocks.findManyRepos },
},
}));

vi.mock("@/lib/config", () => ({
getTrackedRepos: mocks.getTrackedRepos,
}));

// Components render to React elements but are not deep-rendered here.
vi.mock("@/components/kanban-board", () => ({ KanbanBoard: () => null }));
vi.mock("@/components/filter-bar", () => ({ FilterBar: () => null }));
vi.mock("@/components/sync-issues-button", () => ({ SyncIssuesButton: () => null }));
vi.mock("@/components/ui/card", () => ({
Card: ({ children }: { children: unknown }) => children,
CardContent: ({ children }: { children: unknown }) => children,
}));

import BoardPage from "./page";

describe("BoardPage searchParams handling (Next 16 async)", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.findManyIssues.mockResolvedValue([]);
mocks.findManyRepos.mockResolvedValue([]);
mocks.aggregateIssues.mockResolvedValue({ _count: { _all: 0 }, _max: { lastSyncedAt: null } });
mocks.getTrackedRepos.mockResolvedValue([]);
});

it("awaits searchParams and applies repo filter to the issue query", async () => {
await BoardPage({ searchParams: Promise.resolve({ repo: "myorg/repo1" }) });

// The first findMany call is the filtered issue query (the second is filter-options).
const filteredCall = mocks.findManyIssues.mock.calls[0][0];
expect(filteredCall.where.repository).toEqual({ enabled: true, fullName: "myorg/repo1" });
});

it("awaits searchParams and applies agent label filter", async () => {
await BoardPage({ searchParams: Promise.resolve({ agent: "agent/alpha" }) });

const filteredCall = mocks.findManyIssues.mock.calls[0][0];
expect(filteredCall.where.labels).toEqual({ has: "agent/alpha" });
});

it("awaits searchParams and applies owner label filter", async () => {
await BoardPage({ searchParams: Promise.resolve({ owner: "owner/alice" }) });

const filteredCall = mocks.findManyIssues.mock.calls[0][0];
expect(filteredCall.where.labels).toEqual({ has: "owner/alice" });
});

it("awaits searchParams and applies priority filter", async () => {
await BoardPage({ searchParams: Promise.resolve({ priority: "priority/p1" }) });

const filteredCall = mocks.findManyIssues.mock.calls[0][0];
expect(filteredCall.where.labels).toEqual({ has: "priority/p1" });
});

it("applies no extra filters when searchParams resolves empty", async () => {
await BoardPage({ searchParams: Promise.resolve({}) });

const filteredCall = mocks.findManyIssues.mock.calls[0][0];
expect(filteredCall.where).toEqual({ repository: { enabled: true } });
});
});
17 changes: 8 additions & 9 deletions src/app/board/page.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,13 +7,12 @@ import { getTrackedRepos } from "@/lib/config";

export const dynamic = "force-dynamic";

async function getIssues(repo?: string, agent?: string, owner?: string, project?: string, priority?: string) {
async function getIssues(repo?: string, agent?: string, owner?: string, priority?: string) {
const where: Record<string, unknown> = { repository: { enabled: true } };

if (repo) where.repository = { ...(where.repository as object), fullName: repo };
if (agent) where.labels = { has: agent };
if (owner) where.labels = { has: owner };
if (project) where.labels = { has: `project/${project}` };
if (priority) where.labels = { has: priority };

return prisma.issue.findMany({
Expand DownExpand Up@@ -70,12 +69,13 @@ async function getIssueSyncStatus() {
}

interface PageProps {
searchParams: { repo?: string; agent?: string; owner?: string; project?: string; priority?: string };
searchParams: Promise<{ repo?: string; agent?: string; owner?: string; priority?: string }>;
}

export default async function BoardPage({ searchParams }: PageProps) {
const params = await searchParams;
const [issues, repos, filterOptions, syncStatus] = await Promise.all([
getIssues(searchParams.repo, searchParams.agent, searchParams.owner, searchParams.project, searchParams.priority),
getIssues(params.repo, params.agent, params.owner, params.priority),
getRepos(),
getFilterOptions(),
getIssueSyncStatus(),
Expand All@@ -100,11 +100,10 @@ export default async function BoardPage({ searchParams }: PageProps) {
agents={filterOptions.agents}
owners={filterOptions.owners}
activeFilters={{
repo: searchParams.repo || "",
agent: searchParams.agent || "",
owner: searchParams.owner || "",
project: searchParams.project || "",
priority: searchParams.priority || "",
repo: params.repo || "",
agent: params.agent || "",
owner: params.owner || "",
priority: params.priority || "",
}}
/>

Expand Down
17 changes: 6 additions & 11 deletions src/components/filter-bar.tsx
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
"use client";

import { useRouter, useSearchParams } from "next/navigation";
import { Input } from "@/components/ui/input";
import { PRIORITY_LABELS, AGENT_PREFIX, OWNER_PREFIX } from "@/types";

interface FilterBarProps {
Expand All@@ -12,7 +11,6 @@ interface FilterBarProps {
repo: string;
agent: string;
owner: string;
project: string;
priority: string;
};
}
Expand DownExpand Up@@ -47,11 +45,13 @@ export function FilterBar({ repos, agents, owners, activeFilters }: FilterBarPro
</select>

<select
aria-label="Filter by agent label"
title="Filters by agent/* label only"
value={activeFilters.agent}
onChange={(e) => updateFilter("agent", e.target.value)}
className="h-9 px-3 rounded-md border border-input bg-background text-sm"
>
<option value="">All Agents</option>
<option value="">All agent labels</option>
{agents.map((a) => (
<option key={a} value={a}>
{a.replace(AGENT_PREFIX, "")}
Expand All@@ -60,25 +60,20 @@ export function FilterBar({ repos, agents, owners, activeFilters }: FilterBarPro
</select>

<select
aria-label="Filter by owner label"
title="Filters by owner/* label only"
value={activeFilters.owner}
onChange={(e) => updateFilter("owner", e.target.value)}
className="h-9 px-3 rounded-md border border-input bg-background text-sm"
>
<option value="">All Owners</option>
<option value="">All owner labels</option>
{owners.map((o) => (
<option key={o} value={o}>
{o.replace(OWNER_PREFIX, "")}
</option>
))}
</select>

<Input
placeholder="Project name"
value={activeFilters.project}
onChange={(e) => updateFilter("project", e.target.value)}
className="h-9 w-40"
/>

<select
value={activeFilters.priority}
onChange={(e) => updateFilter("priority", e.target.value)}
Expand Down