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
13 changes: 11 additions & 2 deletions API.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,10 +197,14 @@ New endpoints for Agent Mission Control with scoped API keys.
- API key (`X-API-Key: pa_xxx...`) for `/api/v1/*` endpoints

### API Keys
- `GET /api/v1/auth/keys` — list keys (JWT only)
- `GET /api/v1/auth/keys` — list keys + recent rotation events (JWT only)
- `POST /api/v1/auth/keys` — create key (JWT only)
- body: `{ "label": "CI Agent", "scopes": ["tasks:read","memory:write"] }`
- `DELETE /api/v1/auth/keys/:keyId` — revoke key (JWT only)
- `POST /api/v1/auth/keys/:keyId/rotate` — zero-downtime rotation (JWT only)
- creates a new key, keeps old key active for grace period
- body: `{ "gracePeriodHours": 24, "label": "CI Agent v2" }`
- `POST /api/v1/auth/keys/:keyId/finalize-rotation` — revoke old key after cutover (JWT only)
- `DELETE /api/v1/auth/keys/:keyId` — revoke key immediately (JWT only)

### Agent Registration / Profiles
- `GET /api/v1/agents` — list agent profiles (`agents:read`)
Expand All @@ -216,6 +220,8 @@ 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
- `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..." }`

### Mission Runs (P0-6 hardening)
Expand All @@ -229,6 +235,9 @@ New endpoints for Agent Mission Control with scoped API keys.
- `POST /api/v1/runs/:runId/artifacts` (`runs:write`)
- body: `{ "type": "screenshot|log|diff|file|url", "ref": "...", "label": "...optional..." }`
- `POST /api/v1/runs/monitor` (`runs:control`) — applies heartbeat timeout state updates for all owner runs
- `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`)

### Run Dashboard
- `GET /api/v1/dashboard/runs?[windowMs=86400000]` (`dashboard:read`)
Expand Down
10 changes: 10 additions & 0 deletions convex/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
runsHandler,
runsDashboardHandler,
schedulesHandler,
runRetentionHandler,
} from "./missionControlApi";

// Rate limit configuration
Expand Down Expand Up @@ -461,8 +462,14 @@ http.route({ path: "/api/v1/auth/keys", method: "GET", handler: apiKeysHandler }
http.route({ path: "/api/v1/auth/keys", method: "POST", handler: apiKeysHandler });
http.route({ path: "/api/v1/auth/keys", method: "OPTIONS", handler: v1AuthCors });
http.route({ pathPrefix: "/api/v1/auth/keys/", method: "DELETE", handler: apiKeyByIdHandler });
http.route({ pathPrefix: "/api/v1/auth/keys/", method: "POST", handler: apiKeyByIdHandler });
http.route({ pathPrefix: "/api/v1/auth/keys/", method: "OPTIONS", handler: v1AuthCors });

http.route({ path: "/api/v1/runs/retention", method: "GET", handler: runRetentionHandler });
http.route({ path: "/api/v1/runs/retention", method: "PUT", handler: runRetentionHandler });
http.route({ path: "/api/v1/runs/retention", method: "POST", handler: runRetentionHandler });
http.route({ path: "/api/v1/runs/retention", method: "OPTIONS", handler: v1AuthCors });

http.route({ path: "/api/v1/agents", method: "GET", handler: agentsHandler });
http.route({ path: "/api/v1/agents", method: "POST", handler: agentsHandler });
http.route({ path: "/api/v1/agents", method: "OPTIONS", handler: v1AuthCors });
Expand All @@ -477,7 +484,10 @@ http.route({ path: "/api/v1/activity", method: "OPTIONS", handler: v1AuthCors })

http.route({ path: "/api/v1/memory", method: "GET", handler: memoryHandler });
http.route({ path: "/api/v1/memory", method: "POST", handler: memoryHandler });
http.route({ pathPrefix: "/api/v1/memory/", method: "GET", handler: memoryHandler });
http.route({ pathPrefix: "/api/v1/memory/", method: "POST", handler: memoryHandler });
http.route({ path: "/api/v1/memory", method: "OPTIONS", handler: v1AuthCors });
http.route({ pathPrefix: "/api/v1/memory/", method: "OPTIONS", handler: v1AuthCors });

http.route({ path: "/api/v1/schedules", method: "GET", handler: schedulesHandler });
http.route({ pathPrefix: "/api/v1/schedules/", method: "POST", handler: schedulesHandler });
Expand Down
185 changes: 178 additions & 7 deletions convex/memories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,32 +2,162 @@ import { v } from "convex/values";
import { mutation, query } from "./_generated/server";

const memorySource = v.union(v.literal("manual"), v.literal("openclaw"), v.literal("clawboot"), v.literal("import"), v.literal("api"));
const conflictPolicy = v.union(v.literal("lww"), v.literal("preserve_both"));

function normalizeTags(tags?: string[]) {
const cleaned = tags?.map((t) => t.trim().toLowerCase()).filter(Boolean) ?? [];
return cleaned.length ? Array.from(new Set(cleaned)) : undefined;
}

function computeSearchText(title: string, content: string, tags?: string[]) {
const tagText = tags?.length ? `\n${tags.join(" ")}` : "";
return `${title}\n${content}${tagText}`;
}

export const createMemory = mutation({
args: { ownerDid: v.string(), authorDid: v.string(), title: v.string(), content: v.string(), tags: v.optional(v.array(v.string())), source: v.optional(memorySource), sourceRef: v.optional(v.string()) },
args: {
ownerDid: v.string(),
authorDid: v.string(),
title: v.string(),
content: v.string(),
tags: v.optional(v.array(v.string())),
source: v.optional(memorySource),
sourceRef: v.optional(v.string()),
externalId: v.optional(v.string()),
externalUpdatedAt: v.optional(v.number()),
},
handler: async (ctx, args) => {
const now = Date.now();
const title = args.title.trim();
const content = args.content.trim();
if (!title || !content) throw new Error("title and content are required");
const tags = args.tags?.map((t) => t.trim().toLowerCase()).filter(Boolean);
const tags = normalizeTags(args.tags);
return await ctx.db.insert("memories", {
ownerDid: args.ownerDid,
authorDid: args.authorDid,
title,
content,
searchText: `${title}\n${content}`,
tags: tags?.length ? Array.from(new Set(tags)) : undefined,
searchText: computeSearchText(title, content, tags),
tags,
source: args.source,
sourceRef: args.sourceRef,
externalId: args.externalId,
externalUpdatedAt: args.externalUpdatedAt,
lastSyncedAt: args.source === "openclaw" ? now : undefined,
syncStatus: args.source === "openclaw" ? "synced" : undefined,
conflictNote: undefined,
createdAt: now,
updatedAt: now,
});
}
});

export const upsertOpenClawMemory = mutation({
args: {
ownerDid: v.string(),
authorDid: v.string(),
externalId: v.string(),
title: v.string(),
content: v.string(),
tags: v.optional(v.array(v.string())),
sourceRef: v.optional(v.string()),
externalUpdatedAt: v.number(),
policy: v.optional(conflictPolicy),
},
handler: async (ctx, args) => {
const now = Date.now();
const title = args.title.trim();
const content = args.content.trim();
if (!title || !content) throw new Error("title and content are required");

const tags = normalizeTags(args.tags);
const policy = args.policy ?? "lww";
const existing = await ctx.db
.query("memories")
.withIndex("by_owner_external", (q) => q.eq("ownerDid", args.ownerDid).eq("externalId", args.externalId))
.first();

if (!existing) {
const id = await ctx.db.insert("memories", {
ownerDid: args.ownerDid,
authorDid: args.authorDid,
title,
content,
searchText: computeSearchText(title, content, tags),
tags,
source: "openclaw",
sourceRef: args.sourceRef,
externalId: args.externalId,
externalUpdatedAt: args.externalUpdatedAt,
lastSyncedAt: now,
syncStatus: "synced",
conflictNote: undefined,
createdAt: now,
updatedAt: now,
});
return { id, status: "created" as const };
}

const localIsNewer = (existing.updatedAt ?? 0) > args.externalUpdatedAt;
const contentChanged = existing.title !== title || existing.content !== content;

if (localIsNewer && contentChanged) {
if (policy === "preserve_both") {
const conflictId = await ctx.db.insert("memories", {
ownerDid: args.ownerDid,
authorDid: args.authorDid,
title: `${title} (remote conflicted copy)`,
content,
searchText: computeSearchText(`${title} (remote conflicted copy)`, content, tags),
tags,
source: "openclaw",
sourceRef: args.sourceRef,
externalId: `${args.externalId}:conflict:${now}`,
externalUpdatedAt: args.externalUpdatedAt,
lastSyncedAt: now,
syncStatus: "conflict",
conflictNote: "Remote update older than local edit; preserved as conflicted copy.",
createdAt: now,
updatedAt: now,
});

await ctx.db.patch(existing._id, {
syncStatus: "conflict",
conflictNote: "Remote update older than local edit; local version kept.",
lastSyncedAt: now,
});

return { id: existing._id, status: "conflict_preserved" as const, conflictId };
}

await ctx.db.patch(existing._id, {
syncStatus: "conflict",
conflictNote: "Skipped stale remote update (LWW kept newer local version).",
lastSyncedAt: now,
});
return { id: existing._id, status: "conflict_skipped" as const };
}

await ctx.db.patch(existing._id, {
title,
content,
tags,
source: "openclaw",
sourceRef: args.sourceRef,
externalUpdatedAt: args.externalUpdatedAt,
searchText: computeSearchText(title, content, tags),
syncStatus: "synced",
conflictNote: undefined,
lastSyncedAt: now,
updatedAt: Math.max(now, args.externalUpdatedAt),
});

return { id: existing._id, status: "updated" as const };
},
});

export const listMemories = query({
args: { ownerDid: v.string(), query: v.optional(v.string()), tag: v.optional(v.string()), source: v.optional(memorySource), limit: v.optional(v.number()) },
args: { ownerDid: v.string(), query: v.optional(v.string()), tag: v.optional(v.string()), source: v.optional(memorySource), limit: v.optional(v.number()), syncStatus: v.optional(v.union(v.literal("synced"), v.literal("conflict"), v.literal("pending"))) },
handler: async (ctx, args) => {
const limit = Math.min(Math.max(args.limit ?? 50, 1), 100);
const queryText = args.query?.trim();
Expand All @@ -43,8 +173,49 @@ export const listMemories = query({
}

const tag = args.tag?.trim().toLowerCase();
const memories = rows.filter((m) => (tag ? (m.tags ?? []).includes(tag) : true)).slice(0, limit);
const memories = rows
.filter((m) => (tag ? (m.tags ?? []).includes(tag) : true))
.filter((m) => (args.syncStatus ? m.syncStatus === args.syncStatus : true))
.slice(0, limit);
const availableTags = Array.from(new Set(memories.flatMap((m) => m.tags ?? []))).sort((a, b) => a.localeCompare(b));
return { memories, availableTags };
const conflictCount = rows.filter((m) => m.syncStatus === "conflict").length;
return { memories, availableTags, conflictCount };
}
});

export const listMemoryChangesSince = query({
args: { ownerDid: v.string(), since: v.optional(v.number()), limit: v.optional(v.number()) },
handler: async (ctx, args) => {
const limit = Math.min(Math.max(args.limit ?? 100, 1), 250);
const rows = await ctx.db
.query("memories")
.withIndex("by_owner_time", (q) => q.eq("ownerDid", args.ownerDid))
.order("desc")
.take(400);

const since = args.since ?? 0;
const changes = rows
.filter((row) => row.updatedAt > since)
.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[0].updatedAt : since,
};
},
});
Loading
Loading