- replace default Convex control-plane wording with Rust SQLite control-plane across architecture, AGENTS, Reasonix, and design docs - retire root Convex functions source and deploy script into recycle while keeping explicit cloud/compat/sync-replica boundaries - add control-plane migration guard/docs and keep CodeGraph refreshed after the SQLite control-plane cutover
434 lines
13 KiB
TypeScript
434 lines
13 KiB
TypeScript
import { mutation, query } from "./_generated/server";
|
|
import { v } from "convex/values";
|
|
|
|
const runtimeRunArgs = {
|
|
schema: v.literal("mnote.acp_runtime_run.v1"),
|
|
source: v.literal("acp"),
|
|
userId: v.string(),
|
|
workspaceId: v.optional(v.union(v.string(), v.null())),
|
|
documentId: v.string(),
|
|
sessionId: v.string(),
|
|
runId: v.string(),
|
|
title: v.optional(v.union(v.string(), v.null())),
|
|
profile: v.string(),
|
|
acpRuntime: v.string(),
|
|
traceId: v.string(),
|
|
status: v.string(),
|
|
runtime: v.any(),
|
|
payload: v.any(),
|
|
retention: v.any(),
|
|
createdAt: v.number(),
|
|
updatedAt: v.number(),
|
|
};
|
|
|
|
const runtimeEventArgs = {
|
|
schema: v.literal("mnote.acp_runtime_event.v1"),
|
|
source: v.literal("acp"),
|
|
eventId: v.string(),
|
|
userId: v.string(),
|
|
workspaceId: v.optional(v.union(v.string(), v.null())),
|
|
documentId: v.string(),
|
|
sessionId: v.string(),
|
|
runId: v.string(),
|
|
profile: v.string(),
|
|
acpRuntime: v.string(),
|
|
eventType: v.string(),
|
|
payload: v.any(),
|
|
createdAt: v.number(),
|
|
};
|
|
|
|
function isoFromMillis(value: number) {
|
|
return new Date(value).toISOString();
|
|
}
|
|
|
|
async function requireScopedUser(ctx: any, expectedUserId: string) {
|
|
const identity = await ctx.auth.getUserIdentity();
|
|
const subject = String(identity?.subject ?? "");
|
|
const identityUserId = subject.split("|")[0] || "";
|
|
if (identityUserId && identityUserId !== expectedUserId) {
|
|
throw new Error("无权限");
|
|
}
|
|
if (!identityUserId && !expectedUserId.trim()) {
|
|
throw new Error("未登录");
|
|
}
|
|
return expectedUserId;
|
|
}
|
|
|
|
function rowToClient(row: any) {
|
|
return {
|
|
id: row.id,
|
|
userId: row.user_id,
|
|
workspaceId: row.workspace_id,
|
|
documentId: row.document_id,
|
|
sessionId: row.session_id,
|
|
runId: row.run_id,
|
|
profile: row.profile,
|
|
title: row.title,
|
|
acpRuntime: row.acp_runtime,
|
|
source: row.source,
|
|
status: row.status,
|
|
traceId: row.trace_id,
|
|
runtime: row.runtime,
|
|
usage: row.usage ?? null,
|
|
payload: row.payload,
|
|
retention: row.retention,
|
|
createdAt: row.created_at,
|
|
updatedAt: row.updated_at,
|
|
};
|
|
}
|
|
|
|
export const upsertRuntimeRun = mutation({
|
|
args: runtimeRunArgs,
|
|
handler: async (ctx, args) => {
|
|
const userId = await requireScopedUser(ctx, args.userId);
|
|
const workspaceId = args.workspaceId ?? null;
|
|
const nowIso = isoFromMillis(args.updatedAt);
|
|
const existing = await ctx.db
|
|
.query("acp_runtime_runs")
|
|
.withIndex("by_run_id", (q: any) => q.eq("run_id", args.runId))
|
|
.first();
|
|
|
|
const patch = {
|
|
id: `acp_run:${args.runId}`,
|
|
user_id: userId,
|
|
workspace_id: workspaceId,
|
|
document_id: args.documentId,
|
|
session_id: args.sessionId,
|
|
run_id: args.runId,
|
|
title: args.title ?? null,
|
|
profile: args.profile,
|
|
acp_runtime: args.acpRuntime,
|
|
source: args.source,
|
|
status: args.status,
|
|
trace_id: args.traceId,
|
|
runtime: args.runtime,
|
|
usage: null,
|
|
payload: args.payload,
|
|
retention: args.retention,
|
|
updated_at: nowIso,
|
|
deleted_at: null,
|
|
};
|
|
|
|
if (existing) {
|
|
if (String(existing.user_id) !== userId) {
|
|
throw new Error("无权限");
|
|
}
|
|
await ctx.db.patch(existing._id, patch);
|
|
return { ok: true, id: existing.id, runId: args.runId, updated: true };
|
|
}
|
|
|
|
await ctx.db.insert("acp_runtime_runs", {
|
|
...patch,
|
|
created_at: isoFromMillis(args.createdAt),
|
|
});
|
|
return { ok: true, id: patch.id, runId: args.runId, created: true };
|
|
},
|
|
});
|
|
|
|
export const getRuntimeRun = query({
|
|
args: {
|
|
userId: v.string(),
|
|
runId: v.string(),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const userId = await requireScopedUser(ctx, args.userId);
|
|
const row = await ctx.db
|
|
.query("acp_runtime_runs")
|
|
.withIndex("by_run_id", (q: any) => q.eq("run_id", args.runId))
|
|
.first();
|
|
if (!row || row.deleted_at !== null || String(row.user_id) !== userId) {
|
|
return null;
|
|
}
|
|
return rowToClient(row);
|
|
},
|
|
});
|
|
|
|
export const listRuntimeRuns = query({
|
|
args: {
|
|
userId: v.string(),
|
|
workspaceId: v.optional(v.union(v.string(), v.null())),
|
|
documentId: v.optional(v.union(v.string(), v.null())),
|
|
sessionId: v.optional(v.union(v.string(), v.null())),
|
|
limit: v.optional(v.number()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const userId = await requireScopedUser(ctx, args.userId);
|
|
const workspaceId = args.workspaceId ?? null;
|
|
const limit = Math.min(Math.max(args.limit ?? 50, 1), 100);
|
|
let rows;
|
|
if (args.sessionId) {
|
|
rows = await ctx.db
|
|
.query("acp_runtime_runs")
|
|
.withIndex("by_user_workspace_session", (q: any) =>
|
|
q.eq("user_id", userId).eq("workspace_id", workspaceId).eq("session_id", args.sessionId),
|
|
)
|
|
.order("desc")
|
|
.take(limit);
|
|
} else if (args.documentId) {
|
|
rows = await ctx.db
|
|
.query("acp_runtime_runs")
|
|
.withIndex("by_user_workspace_document", (q: any) =>
|
|
q.eq("user_id", userId).eq("workspace_id", workspaceId).eq("document_id", args.documentId),
|
|
)
|
|
.order("desc")
|
|
.take(limit);
|
|
} else {
|
|
rows = await ctx.db
|
|
.query("acp_runtime_runs")
|
|
.withIndex("by_user_created_at", (q: any) => q.eq("user_id", userId))
|
|
.order("desc")
|
|
.take(limit);
|
|
}
|
|
|
|
return rows
|
|
.filter((row: any) => row.deleted_at === null)
|
|
.map(rowToClient);
|
|
},
|
|
});
|
|
|
|
export const appendRuntimeEvent = mutation({
|
|
args: runtimeEventArgs,
|
|
handler: async (ctx, args) => {
|
|
const userId = await requireScopedUser(ctx, args.userId);
|
|
const workspaceId = args.workspaceId ?? null;
|
|
const existingRun = await ctx.db
|
|
.query("acp_runtime_runs")
|
|
.withIndex("by_run_id", (q: any) => q.eq("run_id", args.runId))
|
|
.first();
|
|
if (existingRun && String(existingRun.user_id) !== userId) {
|
|
throw new Error("无权限");
|
|
}
|
|
|
|
await ctx.db.insert("acp_runtime_events", {
|
|
id: args.eventId,
|
|
user_id: userId,
|
|
workspace_id: workspaceId,
|
|
document_id: args.documentId,
|
|
session_id: args.sessionId,
|
|
run_id: args.runId,
|
|
profile: args.profile,
|
|
acp_runtime: args.acpRuntime,
|
|
source: args.source,
|
|
event_type: args.eventType,
|
|
payload: args.payload,
|
|
created_at: isoFromMillis(args.createdAt),
|
|
});
|
|
const usage = usageFromRuntimeEvent(args.eventType, args.payload);
|
|
if (existingRun && usage) {
|
|
await ctx.db.patch(existingRun._id, {
|
|
usage,
|
|
updated_at: isoFromMillis(args.createdAt),
|
|
});
|
|
}
|
|
return { ok: true, id: args.eventId, runId: args.runId };
|
|
},
|
|
});
|
|
|
|
function usageFromRuntimeEvent(eventType: string, payload: any) {
|
|
if (eventType === "usage.updated") {
|
|
return {
|
|
source: "usage_update",
|
|
contextUsed: Number(payload?.used ?? payload?.contextUsed ?? 0),
|
|
contextSize: Number(payload?.size ?? payload?.contextSize ?? 0),
|
|
};
|
|
}
|
|
if (eventType === "run.completed" && payload?.usage && typeof payload.usage === "object") {
|
|
return {
|
|
source: "run_completed",
|
|
...payload.usage,
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export const listRuntimeEvents = query({
|
|
args: {
|
|
userId: v.string(),
|
|
runId: v.string(),
|
|
limit: v.optional(v.number()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const userId = await requireScopedUser(ctx, args.userId);
|
|
const limit = Math.min(Math.max(args.limit ?? 100, 1), 500);
|
|
const rows = await ctx.db
|
|
.query("acp_runtime_events")
|
|
.withIndex("by_run_created_at", (q: any) => q.eq("run_id", args.runId))
|
|
.order("asc")
|
|
.take(limit);
|
|
return rows
|
|
.filter((row: any) => String(row.user_id) === userId)
|
|
.map((row: any) => ({
|
|
id: row.id,
|
|
userId: row.user_id,
|
|
workspaceId: row.workspace_id,
|
|
documentId: row.document_id,
|
|
sessionId: row.session_id,
|
|
runId: row.run_id,
|
|
profile: row.profile,
|
|
acpRuntime: row.acp_runtime,
|
|
source: row.source,
|
|
eventType: row.event_type,
|
|
payload: row.payload,
|
|
createdAt: row.created_at,
|
|
}));
|
|
},
|
|
});
|
|
|
|
function titleFromRun(row: any) {
|
|
const payload = row?.payload ?? {};
|
|
const raw = String(payload.message ?? payload.input ?? payload.title ?? "").trim();
|
|
if (!raw) return "当前页问答";
|
|
return raw.length > 32 ? `${raw.slice(0, 32)}...` : raw;
|
|
}
|
|
|
|
async function sessionRows(ctx: any, userId: string, workspaceId: string | null, sessionId: string) {
|
|
return await ctx.db
|
|
.query("acp_runtime_runs")
|
|
.withIndex("by_user_workspace_session", (q: any) =>
|
|
q.eq("user_id", userId).eq("workspace_id", workspaceId).eq("session_id", sessionId),
|
|
)
|
|
.collect();
|
|
}
|
|
|
|
export const renameRuntimeSession = mutation({
|
|
args: {
|
|
userId: v.string(),
|
|
workspaceId: v.optional(v.union(v.string(), v.null())),
|
|
sessionId: v.string(),
|
|
title: v.string(),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const userId = await requireScopedUser(ctx, args.userId);
|
|
const workspaceId = args.workspaceId ?? null;
|
|
const title = args.title.trim();
|
|
if (!title) throw new Error("标题不能为空");
|
|
const rows = await sessionRows(ctx, userId, workspaceId, args.sessionId);
|
|
for (const row of rows) {
|
|
if (row.deleted_at === null) {
|
|
await ctx.db.patch(row._id, {
|
|
title,
|
|
updated_at: new Date().toISOString(),
|
|
});
|
|
}
|
|
}
|
|
return { ok: true, sessionId: args.sessionId, title, updated: rows.length };
|
|
},
|
|
});
|
|
|
|
export const autoTitleRuntimeSession = mutation({
|
|
args: {
|
|
userId: v.string(),
|
|
workspaceId: v.optional(v.union(v.string(), v.null())),
|
|
sessionId: v.string(),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const userId = await requireScopedUser(ctx, args.userId);
|
|
const workspaceId = args.workspaceId ?? null;
|
|
const rows = await sessionRows(ctx, userId, workspaceId, args.sessionId);
|
|
const title = titleFromRun(rows.find((row: any) => row.deleted_at === null));
|
|
for (const row of rows) {
|
|
if (row.deleted_at === null) {
|
|
await ctx.db.patch(row._id, {
|
|
title,
|
|
updated_at: new Date().toISOString(),
|
|
});
|
|
}
|
|
}
|
|
return { ok: true, sessionId: args.sessionId, title, updated: rows.length };
|
|
},
|
|
});
|
|
|
|
export const deleteRuntimeSession = mutation({
|
|
args: {
|
|
userId: v.string(),
|
|
workspaceId: v.optional(v.union(v.string(), v.null())),
|
|
sessionId: v.string(),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const userId = await requireScopedUser(ctx, args.userId);
|
|
const workspaceId = args.workspaceId ?? null;
|
|
const rows = await sessionRows(ctx, userId, workspaceId, args.sessionId);
|
|
const deletedAt = new Date().toISOString();
|
|
for (const row of rows) {
|
|
if (row.deleted_at === null) {
|
|
await ctx.db.patch(row._id, {
|
|
deleted_at: deletedAt,
|
|
updated_at: deletedAt,
|
|
});
|
|
}
|
|
}
|
|
return { ok: true, sessionId: args.sessionId, deleted: rows.length };
|
|
},
|
|
});
|
|
|
|
function searchText(row: any) {
|
|
const payload = row?.payload ?? {};
|
|
return [
|
|
row.title,
|
|
row.session_id,
|
|
row.run_id,
|
|
row.profile,
|
|
row.status,
|
|
payload.message,
|
|
payload.input,
|
|
payload.title,
|
|
]
|
|
.map((value) => String(value ?? ""))
|
|
.join(" ")
|
|
.toLowerCase();
|
|
}
|
|
|
|
function snippetFor(row: any, q: string) {
|
|
const payload = row?.payload ?? {};
|
|
const raw = String(row.title ?? payload.message ?? payload.input ?? row.session_id ?? "").trim();
|
|
if (!raw) return "";
|
|
const index = raw.toLowerCase().indexOf(q.toLowerCase());
|
|
if (index < 0) return raw.length > 80 ? `${raw.slice(0, 80)}...` : raw;
|
|
const start = Math.max(0, index - 24);
|
|
const end = Math.min(raw.length, index + q.length + 56);
|
|
return `${start > 0 ? "..." : ""}${raw.slice(start, end)}${end < raw.length ? "..." : ""}`;
|
|
}
|
|
|
|
export const searchRuntimeSessions = query({
|
|
args: {
|
|
userId: v.string(),
|
|
workspaceId: v.optional(v.union(v.string(), v.null())),
|
|
q: v.string(),
|
|
limit: v.optional(v.number()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const userId = await requireScopedUser(ctx, args.userId);
|
|
const workspaceId = args.workspaceId ?? null;
|
|
const q = args.q.trim().toLowerCase();
|
|
if (!q) return [];
|
|
const limit = Math.min(Math.max(args.limit ?? 20, 1), 50);
|
|
const rows = await ctx.db
|
|
.query("acp_runtime_runs")
|
|
.withIndex("by_user_created_at", (query: any) => query.eq("user_id", userId))
|
|
.order("desc")
|
|
.take(500);
|
|
const seen = new Set<string>();
|
|
const results = [];
|
|
for (const row of rows) {
|
|
if (row.deleted_at !== null) continue;
|
|
if ((row.workspace_id ?? null) !== workspaceId) continue;
|
|
if (!searchText(row).includes(q)) continue;
|
|
if (seen.has(row.session_id)) continue;
|
|
seen.add(row.session_id);
|
|
results.push({
|
|
sessionId: row.session_id,
|
|
runId: row.run_id,
|
|
workspaceId: row.workspace_id,
|
|
documentId: row.document_id,
|
|
title: row.title ?? titleFromRun(row),
|
|
profile: row.profile,
|
|
status: row.status,
|
|
snippet: snippetFor(row, args.q),
|
|
score: 1,
|
|
});
|
|
if (results.length >= limit) break;
|
|
}
|
|
return results;
|
|
},
|
|
});
|