Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
b8f18d6
feat(mission-control): add quick assign UI and unskip AC1 gating
Mar 3, 2026
83e71f9
chore(mission-control): update temp tracker with AC1 slice + PR
Mar 3, 2026
e9cad52
test(mission-control): unskip AC2/AC5b by using item details activity…
Mar 3, 2026
0bdeca0
feat(mission-control): wire list presence indicator and AC3 gate
Mar 3, 2026
03d2d48
test(mission-control): harden seeded auth readiness and list creation…
Mar 3, 2026
0e210f9
test(e2e): make mission-control auth readiness explicit and env-seeded
Mar 3, 2026
3dd1bae
test(mission-control): harden AC5 perf fixture seeding path
Mar 3, 2026
dc0e6a5
chore(mission-control): tighten observability routing provisioning va…
Mar 3, 2026
f0134cb
fix(mission-control): dedupe active presence and prune expired sessions
Mar 3, 2026
ff4ff6b
polish readiness drill auth split for launch-gate checks
Mar 3, 2026
7071bea
mission-control: enforce severity-based alert routing policy
Mar 3, 2026
e010ed3
test(e2e): add AC0 auth readiness probe with auto diagnostics artifacts
Mar 3, 2026
fc38ac5
mission-control: enforce severity-based alert routing policy
Mar 3, 2026
bdc69bc
test(e2e): add AC0 auth readiness probe with auto diagnostics artifacts
Mar 3, 2026
3fd2deb
Harden AC5 perf gates for CI with deterministic reporting
Mar 3, 2026
0e48dd9
Harden API key rotation flow and drill contracts
Mar 3, 2026
576a197
feat(mc): harden artifact retention idempotency and audit schema
Mar 3, 2026
ea9f785
merge: mission control AC1 assign slice (#153)
Mar 3, 2026
0be7044
merge: 5-wide integration baseline (#161)
Mar 3, 2026
857c938
merge: perf thresholds CI enforceability (#164)
Mar 3, 2026
be01757
merge: observability severity routing policy (#162)
Mar 3, 2026
93addc5
merge: auth readiness AC0 + diagnostics (#163)
Mar 3, 2026
43727de
merge: api key rotation hardening (#165)
Mar 3, 2026
299ba1f
merge: retention hardening idempotency (#166)
Mar 3, 2026
44178e8
test(mc): harden readiness drill contracts for key rotation/retention
Mar 3, 2026
646d78b
test(ci): add mission-control phase1 quality gates workflow
Mar 3, 2026
6ff3e0b
fix(memory-sync): make since cursor paging lossless
Mar 3, 2026
4c99a60
feat(observability): close out remaining run-control and heartbeat me…
Mar 3, 2026
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
54 changes: 54 additions & 0 deletions .github/workflows/mission-control-quality-gates.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
name: mission-control-quality-gates

on:
pull_request:
paths:
- "e2e/**"
- "playwright.config.ts"
- "package.json"
- ".github/workflows/mission-control-quality-gates.yml"
workflow_dispatch:

jobs:
phase1-quality-gates:
runs-on: ubuntu-latest
timeout-minutes: 30

steps:
- name: Checkout
uses: actions/checkout@v4

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

- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.5

- name: Install dependencies
run: npm ci

- name: Install Playwright browser
run: npx playwright install --with-deps chromium

- name: Perf fixture parser gate
run: npm run test:e2e -- e2e/mission-control-perf-fixture.spec.ts --reporter=line

- name: Mission Control Phase 1 acceptance + perf gates
env:
MISSION_CONTROL_FIXTURE_PATH: e2e/fixtures/mission-control.production.json
run: npm run test:e2e -- e2e/mission-control-phase1.spec.ts --reporter=line

- name: Upload Playwright artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: mission-control-playwright-artifacts
path: |
playwright-report/
test-results/
if-no-files-found: ignore
10 changes: 9 additions & 1 deletion API.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ New endpoints for Agent Mission Control with scoped API keys.
### Memory
- `GET /api/v1/memory?agentSlug=<slug>[&key=<key>]` (`memory:read`)
- `POST /api/v1/memory` (`memory:write`)
- `GET /api/v1/memory/sync?since=<ms>&limit=<n>` (`memory:read`) — pull Convex memory changes for OpenClaw
- `GET /api/v1/memory/sync?since=<ms>&limit=<n>` (`memory:read`) — pull Convex memory changes for OpenClaw (results are ordered oldest→newest after `since`; response `cursor` equals newest returned `updatedAt` for lossless paging)
- `POST /api/v1/memory/sync` (`memory:write`) — push OpenClaw memory entries into Convex with conflict policy (`lww` or `preserve_both`)
- body: `{ "agentSlug": "platform", "key": "runbook", "value": "...", "listId": "...optional..." }`

Expand All @@ -238,6 +238,14 @@ New endpoints for Agent Mission Control with scoped API keys.
- `GET /api/v1/runs/retention` (JWT only) — retention config + recent deletion logs
- `PUT /api/v1/runs/retention` (JWT only) — set artifact retention days (default 30)
- `POST /api/v1/runs/retention` (JWT only) — run retention job (`dryRun` defaults to `true`)
- retention clamp: `1..365` days, stale rule: `artifact.createdAt < cutoff`
- audit logs are idempotent on `(runId, retentionCutoffAt, dryRun, deletedArtifacts fingerprint)`

### Launch-gate drill auth split
For `npm run mission-control:readiness-drill`:
- `MISSION_CONTROL_BASE_URL` — Convex site base URL
- `MISSION_CONTROL_API_KEY` — used for API-key routes (dashboard/run controls)
- `MISSION_CONTROL_JWT` — used for JWT-only routes (API key rotation inventory + retention/audit endpoints)

### Run Dashboard
- `GET /api/v1/dashboard/runs?[windowMs=86400000]` (`dashboard:read`)
Expand Down
38 changes: 25 additions & 13 deletions MISSION-CONTROL-TEMP-TRACKER.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"sprintDate": "2026-02-23",
"block": "5/5",
"updatedAt": "2026-03-02T07:35:00Z",
"updatedAt": "2026-03-03T08:05:00Z",
"pooAppAgentApiTracking": {
"attempted": true,
"status": "blocked",
Expand Down Expand Up @@ -46,31 +46,43 @@
{
"id": "MC-P1-PR-OPEN",
"title": "Open/update PR with overnight mission control scope summary",
"status": "pending",
"artifacts": []
"status": "done",
"artifacts": [
"https://github.com/aviarytech/todo/pull/153"
]
},
{
"id": "MC-P1-AC3-PRESENCE-WIRE",
"title": "Wire list-level presence indicator + heartbeat and unskip AC3 feature gate",
"status": "done",
"artifacts": [
"src/pages/ListView.tsx",
"e2e/mission-control-phase1.spec.ts"
]
}
],
"validation": {
"playwrightSpecRun": "partial",
"command": "npm run test:e2e -- e2e/mission-control-phase1.spec.ts",
"command": "npm run test:e2e -- e2e/mission-control-phase1.spec.ts -g \"AC3 presence freshness\"",
"result": {
"passed": 1,
"skipped": 6,
"passed": 0,
"skipped": 1,
"failed": 0
},
"observabilityValidation": {
"command": "npm run mission-control:validate-observability",
"passed": true
},
"notes": [
"Seeded local auth fixture added for OTP-gated routes; baseline harness remains runnable.",
"AC1/AC2/AC3/AC5b remain conditionally skipped when assignee/activity/presence UI surfaces are absent in current build.",
"Perf harness supports production-sized fixture path via MISSION_CONTROL_FIXTURE_PATH."
"Added quick Assign action in list item UI wired to items.updateItem(assigneeDid=userDid).",
"Removed AC1 feature-availability dynamic skip; AC1 now asserts Assign control visibility.",
"Remaining AC1 skip is environment readiness gate (authenticated app shell availability).",
"AC3 feature dynamic skip removed; scenario still environment-gated on authenticated app-shell readiness."
]
},
"next": [
"Wire assignee/activity/presence UI+backend then remove dynamic skips",
"Run production-sized perf profile: MISSION_CONTROL_FIXTURE_PATH=e2e/fixtures/mission-control.production.json npm run test:e2e -- e2e/mission-control-phase1.spec.ts",
"Open PR with this P0-3/P0-4 delta and CI artifacts"
"Acquire stable authenticated e2e backend session so AC3 can execute instead of setup-skip",
"Run full mission-control-phase1 spec on production-sized fixture to capture AC5 metrics without skips",
"Close MC-P1-TRACKING-AUTH blocker once agent API credentials/session are provisioned"
]
}
}
33 changes: 33 additions & 0 deletions convex/lib/artifactRetention.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, expect, test } from "bun:test";
import { artifactFingerprint, clampRetentionDays, computeRetentionCutoff, isValidArtifactRef, normalizeArtifactRefs, selectStaleArtifacts, shouldInsertDeletionLog } from "./artifactRetention";

describe("artifact retention helpers", () => {
test("clamps retention day boundaries to [1, 365]", () => {
expect(clampRetentionDays(undefined, 30)).toBe(30);
expect(clampRetentionDays(0, 30)).toBe(1);
expect(clampRetentionDays(999, 30)).toBe(365);
});

test("uses strict < cutoff semantics", () => {
const cutoff = computeRetentionCutoff(1_000_000, 1);
const artifacts = [
{ type: "log" as const, ref: "old", createdAt: cutoff - 1 },
{ type: "log" as const, ref: "edge", createdAt: cutoff },
];
expect(selectStaleArtifacts(artifacts, cutoff).map((a) => a.ref)).toEqual(["old"]);
});

test("normalizes artifact schema", () => {
expect(isValidArtifactRef({ type: "log", ref: "ok", createdAt: 1 })).toBe(true);
expect(normalizeArtifactRefs([{ type: "log", ref: "ok", createdAt: 1 }, { type: "oops", ref: "no", createdAt: 2 }])).toEqual([
{ type: "log", ref: "ok", createdAt: 1 },
]);
});

test("fingerprint supports idempotency checks", () => {
const a = [{ type: "log" as const, ref: "1", createdAt: 1 }, { type: "file" as const, ref: "2", createdAt: 2 }];
const b = [...a].reverse();
expect(artifactFingerprint(a)).toBe(artifactFingerprint(b));
expect(shouldInsertDeletionLog(a, b)).toBe(false);
});
});
47 changes: 47 additions & 0 deletions convex/lib/artifactRetention.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
export const ARTIFACT_TYPES = ["screenshot", "log", "diff", "file", "url"] as const;

export type ArtifactType = (typeof ARTIFACT_TYPES)[number];
export type ArtifactRef = {
type: ArtifactType;
ref: string;
label?: string;
createdAt: number;
};

export function clampRetentionDays(value: number | undefined, fallback: number): number {
return Math.min(Math.max(Math.floor(value ?? fallback), 1), 365);
}

export function computeRetentionCutoff(now: number, retentionDays: number): number {
return now - retentionDays * 24 * 60 * 60 * 1000;
}

export function isValidArtifactRef(value: unknown): value is ArtifactRef {
if (!value || typeof value !== "object") return false;
const v = value as Record<string, unknown>;
return (
typeof v.ref === "string" &&
v.ref.length > 0 &&
typeof v.createdAt === "number" &&
Number.isFinite(v.createdAt) &&
ARTIFACT_TYPES.includes(v.type as ArtifactType) &&
(v.label === undefined || typeof v.label === "string")
);
}

export function normalizeArtifactRefs(input: unknown): ArtifactRef[] {
if (!Array.isArray(input)) return [];
return input.filter(isValidArtifactRef);
}

export function selectStaleArtifacts(artifacts: ArtifactRef[], cutoff: number): ArtifactRef[] {
return artifacts.filter((a) => a.createdAt < cutoff);
}

export function artifactFingerprint(artifacts: ArtifactRef[]): string {
return artifacts.map((a) => `${a.type}|${a.ref}|${a.label ?? ""}|${a.createdAt}`).sort().join("\n");
}

export function shouldInsertDeletionLog(existingArtifacts: ArtifactRef[], candidateArtifacts: ArtifactRef[]): boolean {
return artifactFingerprint(existingArtifacts) !== artifactFingerprint(candidateArtifacts);
}
46 changes: 46 additions & 0 deletions convex/lib/memorySync.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { describe, expect, test } from "bun:test";

import { selectMemoryChangesSince, type MemorySyncRow } from "./memorySync";

function row(updatedAt: number, id?: string): MemorySyncRow<string> {
return {
_id: id ?? `m-${updatedAt}`,
ownerDid: "did:example:owner",
authorDid: "did:example:author",
title: `t-${updatedAt}`,
content: `c-${updatedAt}`,
updatedAt,
};
}

describe("memory sync cursor semantics", () => {
test("returns ascending updates with cursor at newest delivered item", () => {
const rows = [row(400), row(300), row(200), row(100)];

const result = selectMemoryChangesSince(rows, 0, 3);

expect(result.changes.map((c) => c.updatedAt)).toEqual([100, 200, 300]);
expect(result.cursor).toBe(300);
});

test("supports lossless paging with since+limit", () => {
const rows = [row(500), row(400), row(300), row(200), row(100)];

const page1 = selectMemoryChangesSince(rows, 0, 2);
const page2 = selectMemoryChangesSince(rows, page1.cursor, 2);
const page3 = selectMemoryChangesSince(rows, page2.cursor, 2);

expect(page1.changes.map((c) => c.updatedAt)).toEqual([100, 200]);
expect(page2.changes.map((c) => c.updatedAt)).toEqual([300, 400]);
expect(page3.changes.map((c) => c.updatedAt)).toEqual([500]);
});

test("returns stable cursor when no changes exist", () => {
const rows = [row(300), row(200), row(100)];

const result = selectMemoryChangesSince(rows, 300, 50);

expect(result.changes.length).toBe(0);
expect(result.cursor).toBe(300);
});
});
46 changes: 46 additions & 0 deletions convex/lib/memorySync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
export type MemorySyncRow<TId = string> = {
_id: TId;
ownerDid: string;
authorDid: string;
externalId?: string;
title: string;
content: string;
tags?: string[];
source?: "manual" | "openclaw" | "clawboot" | "import" | "api";
sourceRef?: string;
updatedAt: number;
externalUpdatedAt?: number;
syncStatus?: "synced" | "conflict" | "pending";
conflictNote?: string;
};

export function selectMemoryChangesSince<TId = string>(
rows: MemorySyncRow<TId>[],
since: number,
limit: number,
) {
const changes = rows
.filter((row) => row.updatedAt > since)
.sort((a, b) => a.updatedAt - b.updatedAt)
.slice(0, limit)
.map((row) => ({
id: row._id,
ownerDid: row.ownerDid,
authorDid: row.authorDid,
externalId: row.externalId,
title: row.title,
content: row.content,
tags: row.tags,
source: row.source,
sourceRef: row.sourceRef,
updatedAt: row.updatedAt,
externalUpdatedAt: row.externalUpdatedAt,
syncStatus: row.syncStatus,
conflictNote: row.conflictNote,
}));

return {
changes,
cursor: changes.length ? changes[changes.length - 1].updatedAt : since,
};
}
34 changes: 34 additions & 0 deletions convex/lib/presenceSessions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { describe, expect, test } from "bun:test";
import { dedupeActivePresenceSessions, isSessionActive } from "./presenceSessions";

describe("presence session helpers", () => {
test("isSessionActive uses strict expiresAt > now semantics", () => {
expect(isSessionActive({ userDid: "did:a", sessionId: "s1", lastSeenAt: 100, expiresAt: 200 }, 199)).toBe(true);
expect(isSessionActive({ userDid: "did:a", sessionId: "s1", lastSeenAt: 100, expiresAt: 200 }, 200)).toBe(false);
});

test("dedupes by userDid and keeps most recent active session", () => {
const sessions = [
{ userDid: "did:a", sessionId: "s-old", lastSeenAt: 100, expiresAt: 500 },
{ userDid: "did:a", sessionId: "s-new", lastSeenAt: 300, expiresAt: 600 },
{ userDid: "did:b", sessionId: "s-b", lastSeenAt: 250, expiresAt: 700 },
];

expect(dedupeActivePresenceSessions(sessions, 200)).toEqual([
{ userDid: "did:a", sessionId: "s-new", lastSeenAt: 300, expiresAt: 600 },
{ userDid: "did:b", sessionId: "s-b", lastSeenAt: 250, expiresAt: 700 },
]);
});

test("drops expired sessions before dedupe", () => {
const sessions = [
{ userDid: "did:a", sessionId: "s-expired", lastSeenAt: 100, expiresAt: 120 },
{ userDid: "did:a", sessionId: "s-active", lastSeenAt: 130, expiresAt: 300 },
{ userDid: "did:b", sessionId: "s-expired-b", lastSeenAt: 150, expiresAt: 150 },
];

expect(dedupeActivePresenceSessions(sessions, 150)).toEqual([
{ userDid: "did:a", sessionId: "s-active", lastSeenAt: 130, expiresAt: 300 },
]);
});
});
28 changes: 28 additions & 0 deletions convex/lib/presenceSessions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
type PresenceSession = {
_id?: string;
_creationTime?: number;
listId?: string;
userDid: string;
sessionId: string;
lastSeenAt: number;
expiresAt: number;
};

export function isSessionActive(session: PresenceSession, now: number) {
return session.expiresAt > now;
}

export function dedupeActivePresenceSessions<T extends PresenceSession>(sessions: T[], now: number): T[] {
const latestByUser = new Map<string, T>();

for (const session of sessions) {
if (!isSessionActive(session, now)) continue;

const existing = latestByUser.get(session.userDid);
if (!existing || session.lastSeenAt > existing.lastSeenAt) {
latestByUser.set(session.userDid, session);
}
}

return Array.from(latestByUser.values()).sort((a, b) => b.lastSeenAt - a.lastSeenAt);
}
Loading
Loading