diff --git a/API.md b/API.md index bb0ea01..51517a4 100644 --- a/API.md +++ b/API.md @@ -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`) @@ -216,6 +220,8 @@ New endpoints for Agent Mission Control with scoped API keys. ### Memory - `GET /api/v1/memory?agentSlug=[&key=]` (`memory:read`) - `POST /api/v1/memory` (`memory:write`) +- `GET /api/v1/memory/sync?since=&limit=` (`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) @@ -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`) diff --git a/convex/http.ts b/convex/http.ts index d6311ff..16b44aa 100644 --- a/convex/http.ts +++ b/convex/http.ts @@ -46,6 +46,7 @@ import { runsHandler, runsDashboardHandler, schedulesHandler, + runRetentionHandler, } from "./missionControlApi"; // Rate limit configuration @@ -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 }); @@ -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 }); diff --git a/convex/memories.ts b/convex/memories.ts index dfe1b8a..ea0edfd 100644 --- a/convex/memories.ts +++ b/convex/memories.ts @@ -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(); @@ -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, + }; + }, +}); \ No newline at end of file diff --git a/convex/missionControlApi.ts b/convex/missionControlApi.ts index 7541492..ccc3ca0 100644 --- a/convex/missionControlApi.ts +++ b/convex/missionControlApi.ts @@ -74,6 +74,9 @@ async function authenticate(ctx: ActionCtx, request: Request): Promise { return new Response(null, { status: 204, @@ -128,7 +144,11 @@ export const apiKeysHandler = httpAction(async (ctx, request) => { const userDid = await getUserDidFromJwt(ctx, request); if (request.method === "GET") { - const keys = await ctx.runQuery((api as any).missionControlCore.listApiKeys, { ownerDid: userDid }) as any[]; + const [keys, rotationEvents] = await Promise.all([ + ctx.runQuery((api as any).missionControlCore.listApiKeys, { ownerDid: userDid }) as Promise, + ctx.runQuery((api as any).missionControlCore.listApiKeyRotationEvents, { ownerDid: userDid, limit: 20 }) as Promise, + ]); + return jsonResponse(request, { apiKeys: keys.map((k) => ({ _id: k._id, @@ -136,11 +156,15 @@ export const apiKeysHandler = httpAction(async (ctx, request) => { keyPrefix: k.keyPrefix, scopes: k.scopes, agentProfileId: k.agentProfileId, + rotatedFromKeyId: k.rotatedFromKeyId, + rotatedToKeyId: k.rotatedToKeyId, + rotationGraceEndsAt: k.rotationGraceEndsAt, createdAt: k.createdAt, lastUsedAt: k.lastUsedAt, revokedAt: k.revokedAt, expiresAt: k.expiresAt, })), + rotationEvents, }); } @@ -184,17 +208,122 @@ export const apiKeyByIdHandler = httpAction(async (ctx, request) => { try { const userDid = await getUserDidFromJwt(ctx, request); const path = new URL(request.url).pathname; - const id = path.split("/").pop(); - if (!id) return errorResponse(request, "key id required", 400); + const { keyId, action } = parseApiKeyPath(path); + if (!keyId || !action) return errorResponse(request, "key id required", 400); - if (request.method === "DELETE") { + if (request.method === "DELETE" && action === "delete") { await ctx.runMutation((api as any).missionControlCore.revokeApiKey, { - keyId: id, + keyId, ownerDid: userDid, }); return jsonResponse(request, { success: true }); } + if (request.method === "POST" && action === "rotate") { + const body = await request.json().catch(() => ({})) as { + label?: string; + gracePeriodHours?: number; + expiresAt?: number; + }; + + const existing = await ctx.runQuery((api as any).missionControlCore.listApiKeys, { ownerDid: userDid }) as any[]; + const oldKey = existing.find((k) => k._id === keyId); + if (!oldKey) return errorResponse(request, "API key not found", 404); + if (oldKey.revokedAt) return errorResponse(request, "Cannot rotate revoked API key", 400); + + const gracePeriodHours = Math.min(Math.max(Math.floor(body.gracePeriodHours ?? 24), 1), 168); + const graceEndsAt = Date.now() + gracePeriodHours * 60 * 60 * 1000; + const rawKey = `pa_${randomToken(8)}_${randomToken(24)}`; + const keyPrefix = rawKey.slice(0, 12); + const keyHash = await sha256Hex(rawKey); + + const result = await ctx.runMutation((api as any).missionControlCore.createRotatedApiKey, { + ownerDid: userDid, + rotatedByDid: userDid, + oldKeyId: keyId, + label: body.label ?? `${oldKey.label} (rotated)`, + keyPrefix, + keyHash, + scopes: oldKey.scopes, + agentProfileId: oldKey.agentProfileId, + expiresAt: body.expiresAt, + graceEndsAt, + }) as any; + + return jsonResponse(request, { + success: true, + rotationEventId: result.rotationEventId, + oldKeyId: keyId, + oldKeyGraceEndsAt: graceEndsAt, + newKeyId: result.newKeyId, + apiKey: rawKey, + keyPrefix, + zeroDowntime: true, + next: "Use the new key in production, then POST /api/v1/auth/keys/:id/finalize-rotation to revoke the old key.", + }, 201); + } + + if (request.method === "POST" && action === "finalize") { + const result = await ctx.runMutation((api as any).missionControlCore.finalizeApiKeyRotation, { + ownerDid: userDid, + oldKeyId: keyId, + }) as any; + return jsonResponse(request, { success: true, ...result }); + } + + return errorResponse(request, "Method not allowed", 405); + } catch (error) { + if (error instanceof AuthError) return unauthorizedResponseWithCors(request, error.message); + return errorResponse(request, error instanceof Error ? error.message : "Failed", 500); + } +}); + +export const runRetentionHandler = httpAction(async (ctx, request) => { + try { + const userDid = await getUserDidFromJwt(ctx, request); + + if (request.method === "GET") { + const [settings, logs] = await Promise.all([ + ctx.runQuery((api as any).missionControlCore.getMissionControlSettings, { ownerDid: userDid }), + ctx.runQuery((api as any).missionControlCore.listArtifactDeletionLogs, { ownerDid: userDid, limit: 25 }), + ]); + + return jsonResponse(request, { settings, deletionLogs: logs }); + } + + if (request.method === "PUT") { + const body = await request.json() as { artifactRetentionDays?: number }; + if (!body.artifactRetentionDays || !Number.isFinite(body.artifactRetentionDays)) { + return errorResponse(request, "artifactRetentionDays is required", 400); + } + + const result = await ctx.runMutation((api as any).missionControlCore.upsertMissionControlSettings, { + ownerDid: userDid, + updatedByDid: userDid, + artifactRetentionDays: body.artifactRetentionDays, + }); + + return jsonResponse(request, { success: true, ...result }); + } + + if (request.method === "POST") { + const body = await request.json().catch(() => ({})) as { + retentionDays?: number; + dryRun?: boolean; + maxRuns?: number; + }; + + const result = await ctx.runMutation((api as any).missionControlCore.applyArtifactRetention, { + ownerDid: userDid, + actorDid: userDid, + retentionDays: body.retentionDays, + dryRun: body.dryRun ?? true, + maxRuns: body.maxRuns, + }); + + return jsonResponse(request, result, 200); + } + return errorResponse(request, "Method not allowed", 405); } catch (error) { if (error instanceof AuthError) return unauthorizedResponseWithCors(request, error.message); @@ -305,12 +434,30 @@ export const activityHandler = httpAction(async (ctx, request) => { export const memoryHandler = httpAction(async (ctx, request) => { try { const authCtx = await authenticate(ctx, request); + const url = new URL(request.url); + const isSyncRoute = url.pathname.endsWith("/sync"); if (request.method === "GET") { const missing = requireScopes(authCtx, ["memory:read"]); if (missing) return errorResponse(request, `Missing required scope: ${missing}`, 403); - const url = new URL(request.url); + if (isSyncRoute) { + const since = parseOptionalNumber(url.searchParams.get("since")); + const limit = Number(url.searchParams.get("limit") ?? "100"); + const result = await ctx.runQuery((api as any).memories.listMemoryChangesSince, { + ownerDid: authCtx.userDid, + since, + limit, + }); + return jsonResponse(request, { + ...result, + sync: { + mode: "bidirectional", + policy: "lww", + }, + }); + } + const agentSlug = url.searchParams.get("agentSlug"); if (!agentSlug) return errorResponse(request, "agentSlug query param required", 400); const key = url.searchParams.get("key") ?? undefined; @@ -326,6 +473,50 @@ export const memoryHandler = httpAction(async (ctx, request) => { const missing = requireScopes(authCtx, ["memory:write"]); if (missing) return errorResponse(request, `Missing required scope: ${missing}`, 403); + if (isSyncRoute) { + const body = await request.json() as { + policy?: "lww" | "preserve_both"; + entries: Array<{ + externalId: string; + title: string; + content: string; + tags?: string[]; + sourceRef?: string; + updatedAt: number; + authorDid?: string; + }>; + }; + + if (!Array.isArray(body.entries)) return errorResponse(request, "entries array is required", 400); + + const results: Array<{ externalId: string; status: string; id: string; conflictId?: string }> = []; + for (const entry of body.entries) { + if (!entry?.externalId || !entry?.title || typeof entry?.content !== "string" || !Number.isFinite(entry?.updatedAt)) { + return errorResponse(request, "Each entry requires externalId, title, content, and updatedAt", 400); + } + const res = await ctx.runMutation((api as any).memories.upsertOpenClawMemory, { + ownerDid: authCtx.userDid, + authorDid: entry.authorDid ?? authCtx.userDid, + externalId: entry.externalId, + title: entry.title, + content: entry.content, + tags: entry.tags, + sourceRef: entry.sourceRef, + externalUpdatedAt: entry.updatedAt, + policy: body.policy ?? "lww", + }) as { id: string; status: string; conflictId?: string }; + results.push({ externalId: entry.externalId, status: res.status, id: res.id, conflictId: res.conflictId }); + } + + const conflicts = results.filter((r) => r.status.startsWith("conflict")).length; + return jsonResponse(request, { + applied: results.length, + conflicts, + policy: body.policy ?? "lww", + results, + }, 200); + } + const body = await request.json() as { agentSlug: string; key: string; value: string; listId?: Id<"lists"> }; if (!body.agentSlug || !body.key || typeof body.value !== "string") { return errorResponse(request, "agentSlug, key, and value are required", 400); diff --git a/convex/missionControlCore.ts b/convex/missionControlCore.ts index 8d41cc8..79d547c 100644 --- a/convex/missionControlCore.ts +++ b/convex/missionControlCore.ts @@ -131,6 +131,205 @@ export const touchApiKeyUsage = mutation({ }, }); +export const createRotatedApiKey = mutation({ + args: { + ownerDid: v.string(), + rotatedByDid: v.string(), + oldKeyId: v.id("apiKeys"), + label: v.string(), + keyPrefix: v.string(), + keyHash: v.string(), + scopes: v.array(v.string()), + agentProfileId: v.optional(v.id("agentProfiles")), + expiresAt: v.optional(v.number()), + graceEndsAt: v.number(), + }, + handler: async (ctx, args) => { + const now = Date.now(); + const oldKey = await ctx.db.get(args.oldKeyId); + if (!oldKey || oldKey.ownerDid !== args.ownerDid) throw new Error("API key not found"); + if (oldKey.revokedAt) throw new Error("Cannot rotate revoked API key"); + + const newKeyId = await ctx.db.insert("apiKeys", { + ownerDid: args.ownerDid, + label: args.label, + keyPrefix: args.keyPrefix, + keyHash: args.keyHash, + scopes: args.scopes, + agentProfileId: args.agentProfileId, + rotatedFromKeyId: oldKey._id, + createdAt: now, + expiresAt: args.expiresAt, + }); + + await ctx.db.patch(oldKey._id, { + rotatedToKeyId: newKeyId, + rotationGraceEndsAt: args.graceEndsAt, + }); + + const rotationEventId = await ctx.db.insert("apiKeyRotationEvents", { + ownerDid: args.ownerDid, + oldKeyId: oldKey._id, + newKeyId, + rotatedByDid: args.rotatedByDid, + graceEndsAt: args.graceEndsAt, + createdAt: now, + updatedAt: now, + }); + + return { newKeyId, rotationEventId }; + }, +}); + +export const finalizeApiKeyRotation = mutation({ + args: { ownerDid: v.string(), oldKeyId: v.id("apiKeys") }, + handler: async (ctx, args) => { + const oldKey = await ctx.db.get(args.oldKeyId); + if (!oldKey || oldKey.ownerDid !== args.ownerDid) throw new Error("API key not found"); + if (!oldKey.rotatedToKeyId) throw new Error("API key is not in rotation"); + + const now = Date.now(); + await ctx.db.patch(oldKey._id, { revokedAt: now }); + + const event = await ctx.db + .query("apiKeyRotationEvents") + .withIndex("by_old_key", (q) => q.eq("oldKeyId", oldKey._id)) + .first(); + + if (event) { + await ctx.db.patch(event._id, { oldKeyRevokedAt: now, updatedAt: now }); + } + + return { ok: true, revokedAt: now }; + }, +}); + +export const listApiKeyRotationEvents = query({ + args: { ownerDid: v.string(), limit: v.optional(v.number()) }, + handler: async (ctx, args) => { + const limit = Math.min(Math.max(args.limit ?? 20, 1), 100); + return await ctx.db + .query("apiKeyRotationEvents") + .withIndex("by_owner_created", (q) => q.eq("ownerDid", args.ownerDid)) + .order("desc") + .take(limit); + }, +}); + +const DEFAULT_ARTIFACT_RETENTION_DAYS = 30; + +export const getMissionControlSettings = query({ + args: { ownerDid: v.string() }, + handler: async (ctx, args) => { + const settings = await ctx.db + .query("missionControlSettings") + .withIndex("by_owner", (q) => q.eq("ownerDid", args.ownerDid)) + .first(); + + return settings ?? { artifactRetentionDays: DEFAULT_ARTIFACT_RETENTION_DAYS }; + }, +}); + +export const upsertMissionControlSettings = mutation({ + args: { ownerDid: v.string(), updatedByDid: v.string(), artifactRetentionDays: v.number() }, + handler: async (ctx, args) => { + const now = Date.now(); + const artifactRetentionDays = Math.min(Math.max(Math.floor(args.artifactRetentionDays), 1), 365); + const existing = await ctx.db + .query("missionControlSettings") + .withIndex("by_owner", (q) => q.eq("ownerDid", args.ownerDid)) + .first(); + + if (existing) { + await ctx.db.patch(existing._id, { artifactRetentionDays, updatedByDid: args.updatedByDid, updatedAt: now }); + return { ok: true, artifactRetentionDays }; + } + + await ctx.db.insert("missionControlSettings", { + ownerDid: args.ownerDid, + artifactRetentionDays, + updatedByDid: args.updatedByDid, + createdAt: now, + updatedAt: now, + }); + + return { ok: true, artifactRetentionDays }; + }, +}); + +export const applyArtifactRetention = mutation({ + args: { + ownerDid: v.string(), + actorDid: v.string(), + retentionDays: v.optional(v.number()), + dryRun: v.optional(v.boolean()), + maxRuns: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const settings = await ctx.db + .query("missionControlSettings") + .withIndex("by_owner", (q) => q.eq("ownerDid", args.ownerDid)) + .first(); + + const retentionDays = Math.min(Math.max(Math.floor(args.retentionDays ?? settings?.artifactRetentionDays ?? DEFAULT_ARTIFACT_RETENTION_DAYS), 1), 365); + const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1000; + const dryRun = args.dryRun ?? true; + const maxRuns = Math.min(Math.max(args.maxRuns ?? 250, 1), 1000); + + const runs = await ctx.db + .query("missionRuns") + .withIndex("by_owner_created", (q) => q.eq("ownerDid", args.ownerDid)) + .order("desc") + .take(maxRuns); + + let runsTouched = 0; + let deletedArtifacts = 0; + const now = Date.now(); + + for (const run of runs) { + const artifacts = run.artifactRefs ?? []; + const staleArtifacts = artifacts.filter((a) => a.createdAt < cutoff); + if (!staleArtifacts.length) continue; + + runsTouched += 1; + deletedArtifacts += staleArtifacts.length; + + await ctx.db.insert("missionArtifactDeletionLogs", { + ownerDid: args.ownerDid, + runId: run._id, + deletedCount: staleArtifacts.length, + dryRun, + retentionCutoffAt: cutoff, + actorDid: args.actorDid, + trigger: "operator", + deletedArtifacts: staleArtifacts, + createdAt: now, + }); + + if (!dryRun) { + await ctx.db.patch(run._id, { + artifactRefs: artifacts.filter((a) => a.createdAt >= cutoff), + updatedAt: now, + }); + } + } + + return { ok: true, dryRun, retentionDays, retentionCutoffAt: cutoff, runsScanned: runs.length, runsTouched, deletedArtifacts }; + }, +}); + +export const listArtifactDeletionLogs = query({ + args: { ownerDid: v.string(), limit: v.optional(v.number()) }, + handler: async (ctx, args) => { + const limit = Math.min(Math.max(args.limit ?? 50, 1), 200); + return await ctx.db + .query("missionArtifactDeletionLogs") + .withIndex("by_owner_created", (q) => q.eq("ownerDid", args.ownerDid)) + .order("desc") + .take(limit); + }, +}); + export const listTasksForList = query({ args: { listId: v.id("lists"), userDid: v.string(), limit: v.optional(v.number()) }, handler: async (ctx, args) => { diff --git a/convex/schema.ts b/convex/schema.ts index 1f64e1f..4d36070 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -416,6 +416,9 @@ export default defineSchema({ keyHash: v.string(), scopes: v.array(v.string()), agentProfileId: v.optional(v.id("agentProfiles")), + rotatedFromKeyId: v.optional(v.id("apiKeys")), + rotatedToKeyId: v.optional(v.id("apiKeys")), + rotationGraceEndsAt: v.optional(v.number()), createdAt: v.number(), lastUsedAt: v.optional(v.number()), revokedAt: v.optional(v.number()), @@ -425,6 +428,46 @@ export default defineSchema({ .index("by_hash", ["keyHash"]) .index("by_prefix", ["keyPrefix"]), + apiKeyRotationEvents: defineTable({ + ownerDid: v.string(), + oldKeyId: v.id("apiKeys"), + newKeyId: v.id("apiKeys"), + rotatedByDid: v.string(), + graceEndsAt: v.number(), + oldKeyRevokedAt: v.optional(v.number()), + createdAt: v.number(), + updatedAt: v.number(), + }) + .index("by_owner_created", ["ownerDid", "createdAt"]) + .index("by_old_key", ["oldKeyId"]), + + missionControlSettings: defineTable({ + ownerDid: v.string(), + artifactRetentionDays: v.number(), + updatedByDid: v.string(), + createdAt: v.number(), + updatedAt: v.number(), + }) + .index("by_owner", ["ownerDid"]), + + missionArtifactDeletionLogs: defineTable({ + ownerDid: v.string(), + runId: v.id("missionRuns"), + deletedCount: v.number(), + dryRun: v.boolean(), + retentionCutoffAt: v.number(), + actorDid: v.string(), + trigger: v.union(v.literal("operator"), v.literal("system")), + deletedArtifacts: v.array(v.object({ + type: v.union(v.literal("screenshot"), v.literal("log"), v.literal("diff"), v.literal("file"), v.literal("url")), + ref: v.string(), + label: v.optional(v.string()), + createdAt: v.number(), + })), + createdAt: v.number(), + }) + .index("by_owner_created", ["ownerDid", "createdAt"]), + // Agent memory KV entries for long-lived runtime context agentMemory: defineTable({ ownerDid: v.string(), @@ -455,6 +498,11 @@ export default defineSchema({ v.literal("api") )), sourceRef: v.optional(v.string()), + externalId: v.optional(v.string()), + externalUpdatedAt: v.optional(v.number()), + lastSyncedAt: v.optional(v.number()), + syncStatus: v.optional(v.union(v.literal("synced"), v.literal("conflict"), v.literal("pending"))), + conflictNote: v.optional(v.string()), createdAt: v.number(), updatedAt: v.number(), }) @@ -462,9 +510,11 @@ export default defineSchema({ .index("by_owner_time", ["ownerDid", "updatedAt"]) .index("by_owner_source", ["ownerDid", "source"]) .index("by_owner_author", ["ownerDid", "authorDid"]) + .index("by_owner_external", ["ownerDid", "externalId"]) + .index("by_owner_sync_status", ["ownerDid", "syncStatus"]) .searchIndex("search_content", { searchField: "searchText", - filterFields: ["ownerDid", "source", "authorDid"], + filterFields: ["ownerDid", "source", "authorDid", "syncStatus"], }), // Mission Control schedule entries (Phase 4 schedule/calendar) diff --git a/src/pages/Memory.tsx b/src/pages/Memory.tsx index ac1fb67..50356e2 100644 --- a/src/pages/Memory.tsx +++ b/src/pages/Memory.tsx @@ -10,24 +10,27 @@ export function Memory() { const [q, setQ] = useState(""); const [tag, setTag] = useState(""); const [source, setSource] = useState(""); + const [syncStatus, setSyncStatus] = useState(""); const [title, setTitle] = useState(""); const [content, setContent] = useState(""); const createMemory = useMutation(anyApi.memories.createMemory); - const data = useQuery(anyApi.memories.listMemories, did ? { ownerDid: did, query: q || undefined, tag: tag || undefined, source: source || undefined } : "skip"); + const data = useQuery(anyApi.memories.listMemories, did ? { ownerDid: did, query: q || undefined, tag: tag || undefined, source: source || undefined, syncStatus: syncStatus || undefined } : "skip"); return

Memory

-
+ {(data?.conflictCount ?? 0) > 0 &&
OpenClaw sync has {data?.conflictCount} conflict{data?.conflictCount === 1 ? "" : "s"}.
} +
setQ(e.target.value)} /> setTag(e.target.value)} /> +
setTitle(e.target.value)} />