Normalize the topology so web/desktop/mobile clients communicate only with Convex. Convex admits durable commands, dispatches private Flue turns through FLUE_URL, and stores the product-facing result before clients observe it. - Normalized relational schema: organizations, projects, conversations/turns/ messages/attachments, signals/sources/constraints, works/events/attachments. - Conversation turns queued in Convex; the agent runAction dispatches to Flue and persists assistant response, signals, and proposed work back into Convex. - Browser chat moved off the Flue transport: use-chat-agent now reads reactive Convex rows and sends through conversationMessages.send. - Fixed signed-in blank screen: gate all product queries on useConvexAuth (isAuthenticated && !isRefreshing) plus personal-org bootstrap. - Pinned react/react-dom to 19.2.8 via catalog to fix SSR dual-React crash. - Removed ~16.6k net lines: daemon, AgentOS/Orb, project issues/runs/artifacts, todos, browser Flue transport, mobile execution views, smoke scripts. Verified end-to-end on cheaptricks: connect repo, send actionable message, Flue turn completes, Signal + proposed Work created with exact provenance, persists across refresh.
382 lines
11 KiB
TypeScript
382 lines
11 KiB
TypeScript
import { makeFunctionReference } from "convex/server";
|
|
import { ConvexError, v } from "convex/values";
|
|
|
|
import type { Doc, Id } from "./_generated/dataModel";
|
|
import {
|
|
env,
|
|
internalAction,
|
|
internalMutation,
|
|
internalQuery,
|
|
mutation,
|
|
query,
|
|
} from "./_generated/server";
|
|
import { requireOrganizationMember } from "./authz";
|
|
|
|
const MAX_ATTEMPTS = 3;
|
|
|
|
const runTurnRef = makeFunctionReference<
|
|
"action",
|
|
{ turnId: Id<"conversationTurns">; attempt: number }
|
|
>("conversationMessages:runTurn");
|
|
const getTurnRef = makeFunctionReference<
|
|
"query",
|
|
{ turnId: Id<"conversationTurns"> },
|
|
{
|
|
turn: Doc<"conversationTurns">;
|
|
organizationId: Id<"organizations">;
|
|
user: Doc<"conversationMessages">;
|
|
assistant: Doc<"conversationMessages">;
|
|
attachments: Doc<"conversationAttachments">[];
|
|
} | null
|
|
>("conversationMessages:getTurn");
|
|
const markProcessingRef = makeFunctionReference<
|
|
"mutation",
|
|
{ turnId: Id<"conversationTurns"> },
|
|
boolean
|
|
>("conversationMessages:markProcessing");
|
|
const completeTurnRef = makeFunctionReference<
|
|
"mutation",
|
|
{ turnId: Id<"conversationTurns">; submissionId: string; text: string },
|
|
null
|
|
>("conversationMessages:completeTurn");
|
|
const failTurnRef = makeFunctionReference<
|
|
"mutation",
|
|
{ turnId: Id<"conversationTurns">; error: string; retry: boolean },
|
|
null
|
|
>("conversationMessages:failTurn");
|
|
|
|
export const generateUploadUrl = mutation({
|
|
args: {},
|
|
handler: async (ctx): Promise<string> => {
|
|
if (!(await ctx.auth.getUserIdentity())) {
|
|
throw new ConvexError("Authentication required");
|
|
}
|
|
return await ctx.storage.generateUploadUrl();
|
|
},
|
|
});
|
|
|
|
export const send = mutation({
|
|
args: {
|
|
organizationId: v.id("organizations"),
|
|
clientRequestId: v.string(),
|
|
rawText: v.string(),
|
|
images: v.array(
|
|
v.object({
|
|
storageId: v.id("_storage"),
|
|
filename: v.optional(v.string()),
|
|
mimeType: v.string(),
|
|
})
|
|
),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
await requireOrganizationMember(ctx, args.organizationId);
|
|
let conversation = await ctx.db
|
|
.query("conversations")
|
|
.withIndex("by_organizationId", (q) =>
|
|
q.eq("organizationId", args.organizationId)
|
|
)
|
|
.unique();
|
|
if (!conversation) {
|
|
const conversationId = await ctx.db.insert("conversations", {
|
|
createdAt: Date.now(),
|
|
organizationId: args.organizationId,
|
|
});
|
|
conversation = await ctx.db.get(conversationId);
|
|
}
|
|
if (!conversation) {
|
|
throw new Error("Conversation could not be created");
|
|
}
|
|
|
|
const existing = await ctx.db
|
|
.query("conversationTurns")
|
|
.withIndex("by_conversationId_and_clientRequestId", (q) =>
|
|
q
|
|
.eq("conversationId", conversation._id)
|
|
.eq("clientRequestId", args.clientRequestId)
|
|
)
|
|
.unique();
|
|
if (existing) {
|
|
const user = await ctx.db
|
|
.query("conversationMessages")
|
|
.withIndex("by_turnId_and_role", (q) =>
|
|
q.eq("turnId", existing._id).eq("role", "user")
|
|
)
|
|
.unique();
|
|
return { messageId: user?._id ?? null, turnId: existing._id };
|
|
}
|
|
|
|
const createdAt = Date.now();
|
|
const lastMessage = await ctx.db
|
|
.query("conversationMessages")
|
|
.withIndex("by_conversationId_and_ordinal", (q) =>
|
|
q.eq("conversationId", conversation._id)
|
|
)
|
|
.order("desc")
|
|
.first();
|
|
const ordinal = (lastMessage?.ordinal ?? -1) + 1;
|
|
const turnId = await ctx.db.insert("conversationTurns", {
|
|
clientRequestId: args.clientRequestId,
|
|
conversationId: conversation._id,
|
|
createdAt,
|
|
status: "queued",
|
|
});
|
|
const messageId = await ctx.db.insert("conversationMessages", {
|
|
content: args.rawText,
|
|
conversationId: conversation._id,
|
|
createdAt,
|
|
ordinal,
|
|
role: "user",
|
|
turnId,
|
|
});
|
|
for (const image of args.images) {
|
|
await ctx.db.insert("conversationAttachments", {
|
|
...image,
|
|
createdAt,
|
|
messageId,
|
|
});
|
|
}
|
|
await ctx.db.insert("conversationMessages", {
|
|
content: "",
|
|
conversationId: conversation._id,
|
|
createdAt: createdAt + 1,
|
|
ordinal: ordinal + 1,
|
|
role: "assistant",
|
|
turnId,
|
|
});
|
|
await ctx.scheduler.runAfter(0, runTurnRef, { attempt: 1, turnId });
|
|
return { messageId, turnId };
|
|
},
|
|
});
|
|
|
|
export const listForCurrentOrganization = query({
|
|
args: { organizationId: v.id("organizations") },
|
|
handler: async (ctx, args) => {
|
|
await requireOrganizationMember(ctx, args.organizationId);
|
|
const conversation = await ctx.db
|
|
.query("conversations")
|
|
.withIndex("by_organizationId", (q) =>
|
|
q.eq("organizationId", args.organizationId)
|
|
)
|
|
.unique();
|
|
if (!conversation) {
|
|
return [];
|
|
}
|
|
const messages = await ctx.db
|
|
.query("conversationMessages")
|
|
.withIndex("by_conversationId_and_ordinal", (q) =>
|
|
q.eq("conversationId", conversation._id)
|
|
)
|
|
.order("asc")
|
|
.take(200);
|
|
return await Promise.all(
|
|
messages.map(async (message) => {
|
|
const turn = await ctx.db.get(message.turnId);
|
|
const attachments = await ctx.db
|
|
.query("conversationAttachments")
|
|
.withIndex("by_messageId", (q) => q.eq("messageId", message._id))
|
|
.collect();
|
|
return {
|
|
attachments: await Promise.all(
|
|
attachments.map(async (attachment) => ({
|
|
filename: attachment.filename ?? null,
|
|
id: String(attachment._id),
|
|
mediaType: attachment.mimeType,
|
|
url: await ctx.storage.getUrl(attachment.storageId),
|
|
}))
|
|
),
|
|
error: turn?.error ?? null,
|
|
messageId: String(message._id),
|
|
rawText: message.content,
|
|
role: message.role,
|
|
status: turn?.status ?? "failed",
|
|
};
|
|
})
|
|
);
|
|
},
|
|
});
|
|
|
|
export const getTurn = internalQuery({
|
|
args: { turnId: v.id("conversationTurns") },
|
|
handler: async (ctx, args) => {
|
|
const turn = await ctx.db.get(args.turnId);
|
|
if (!turn) {
|
|
return null;
|
|
}
|
|
const conversation = await ctx.db.get(turn.conversationId);
|
|
const user = await ctx.db
|
|
.query("conversationMessages")
|
|
.withIndex("by_turnId_and_role", (q) =>
|
|
q.eq("turnId", turn._id).eq("role", "user")
|
|
)
|
|
.unique();
|
|
const assistant = await ctx.db
|
|
.query("conversationMessages")
|
|
.withIndex("by_turnId_and_role", (q) =>
|
|
q.eq("turnId", turn._id).eq("role", "assistant")
|
|
)
|
|
.unique();
|
|
if (!conversation || !user || !assistant) {
|
|
return null;
|
|
}
|
|
const attachments = await ctx.db
|
|
.query("conversationAttachments")
|
|
.withIndex("by_messageId", (q) => q.eq("messageId", user._id))
|
|
.collect();
|
|
return {
|
|
assistant,
|
|
attachments,
|
|
organizationId: conversation.organizationId,
|
|
turn,
|
|
user,
|
|
};
|
|
},
|
|
});
|
|
|
|
export const markProcessing = internalMutation({
|
|
args: { turnId: v.id("conversationTurns") },
|
|
handler: async (ctx, args): Promise<boolean> => {
|
|
const turn = await ctx.db.get(args.turnId);
|
|
if (!turn || turn.status === "completed") {
|
|
return false;
|
|
}
|
|
await ctx.db.patch(turn._id, { error: undefined, status: "processing" });
|
|
return true;
|
|
},
|
|
});
|
|
|
|
export const completeTurn = internalMutation({
|
|
args: {
|
|
turnId: v.id("conversationTurns"),
|
|
submissionId: v.string(),
|
|
text: v.string(),
|
|
},
|
|
handler: async (ctx, args): Promise<null> => {
|
|
const assistant = await ctx.db
|
|
.query("conversationMessages")
|
|
.withIndex("by_turnId_and_role", (q) =>
|
|
q.eq("turnId", args.turnId).eq("role", "assistant")
|
|
)
|
|
.unique();
|
|
if (assistant) {
|
|
await ctx.db.patch(assistant._id, { content: args.text });
|
|
}
|
|
await ctx.db.patch(args.turnId, {
|
|
completedAt: Date.now(),
|
|
error: undefined,
|
|
status: "completed",
|
|
submissionId: args.submissionId,
|
|
});
|
|
return null;
|
|
},
|
|
});
|
|
|
|
export const failTurn = internalMutation({
|
|
args: {
|
|
turnId: v.id("conversationTurns"),
|
|
error: v.string(),
|
|
retry: v.boolean(),
|
|
},
|
|
handler: async (ctx, args): Promise<null> => {
|
|
const turn = await ctx.db.get(args.turnId);
|
|
if (turn && turn.status !== "completed") {
|
|
await ctx.db.patch(turn._id, {
|
|
completedAt: args.retry ? undefined : Date.now(),
|
|
error: args.error,
|
|
status: args.retry ? "queued" : "failed",
|
|
});
|
|
}
|
|
return null;
|
|
},
|
|
});
|
|
|
|
const toBase64 = (buffer: ArrayBuffer): string => {
|
|
let binary = "";
|
|
for (const byte of new Uint8Array(buffer)) {
|
|
binary += String.fromCodePoint(byte);
|
|
}
|
|
return btoa(binary);
|
|
};
|
|
|
|
export const runTurn = internalAction({
|
|
args: { turnId: v.id("conversationTurns"), attempt: v.number() },
|
|
handler: async (ctx, args): Promise<null> => {
|
|
const turn = await ctx.runQuery(getTurnRef, { turnId: args.turnId });
|
|
if (
|
|
!turn ||
|
|
!(await ctx.runMutation(markProcessingRef, { turnId: args.turnId }))
|
|
) {
|
|
return null;
|
|
}
|
|
try {
|
|
const flueUrl = (env as typeof env & { readonly FLUE_URL?: string })
|
|
.FLUE_URL;
|
|
if (!flueUrl) {
|
|
throw new Error("FLUE_URL is not configured in Convex");
|
|
}
|
|
const images = await Promise.all(
|
|
turn.attachments.map(async (attachment) => {
|
|
const url = await ctx.storage.getUrl(attachment.storageId);
|
|
const response = url ? await fetch(url) : null;
|
|
if (!response?.ok) {
|
|
throw new Error("Conversation image could not be loaded");
|
|
}
|
|
return {
|
|
data: toBase64(await response.arrayBuffer()),
|
|
mimeType: attachment.mimeType,
|
|
type: "image" as const,
|
|
};
|
|
})
|
|
);
|
|
const endpoint = new URL(
|
|
`agents/zopu/${encodeURIComponent(String(turn.organizationId))}`,
|
|
`${flueUrl.replace(/\/+$/u, "")}/`
|
|
);
|
|
endpoint.searchParams.set("wait", "result");
|
|
const response = await fetch(endpoint, {
|
|
body: JSON.stringify({ images, message: turn.user.content }),
|
|
headers: {
|
|
authorization: `Bearer ${env.FLUE_DB_TOKEN}`,
|
|
"content-type": "application/json",
|
|
"x-zopu-organization-id": String(turn.organizationId),
|
|
"x-zopu-request-id": turn.turn.clientRequestId,
|
|
},
|
|
method: "POST",
|
|
});
|
|
const payload: unknown = await response.json().catch(() => null);
|
|
if (
|
|
!response.ok ||
|
|
typeof payload !== "object" ||
|
|
payload === null ||
|
|
!("submissionId" in payload) ||
|
|
typeof payload.submissionId !== "string" ||
|
|
!("result" in payload) ||
|
|
typeof payload.result !== "object" ||
|
|
payload.result === null ||
|
|
!("text" in payload.result) ||
|
|
typeof payload.result.text !== "string"
|
|
) {
|
|
throw new Error(`Flue turn failed (${response.status})`);
|
|
}
|
|
await ctx.runMutation(completeTurnRef, {
|
|
submissionId: payload.submissionId,
|
|
text: payload.result.text,
|
|
turnId: args.turnId,
|
|
});
|
|
} catch (error) {
|
|
const retry = args.attempt < MAX_ATTEMPTS;
|
|
await ctx.runMutation(failTurnRef, {
|
|
error: error instanceof Error ? error.message : String(error),
|
|
retry,
|
|
turnId: args.turnId,
|
|
});
|
|
if (retry) {
|
|
await ctx.scheduler.runAfter(args.attempt * 1000, runTurnRef, {
|
|
attempt: args.attempt + 1,
|
|
turnId: args.turnId,
|
|
});
|
|
}
|
|
}
|
|
return null;
|
|
},
|
|
});
|