Convex-only Slice 1: clients talk to Convex, Flue is a private worker
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.
This commit is contained in:
@@ -1,16 +1,16 @@
|
||||
# Convex Backend
|
||||
|
||||
This directory contains the Convex control plane for the app and daemon runtime. Generated files under `_generated/` are maintained by Convex and should not be edited by hand.
|
||||
Convex is the only application API used by web, desktop, and mobile clients.
|
||||
The active Slice 1 backend is intentionally limited to tenancy, projects,
|
||||
conversation turns, Signals, Work, and Flue's required persistence adapter.
|
||||
|
||||
## Daemon Control Plane
|
||||
## Domain relations
|
||||
|
||||
- `schema.ts` defines daemon definitions, high-churn daemon presence, leased daemon commands, append-only command events, and the sample `todos` table.
|
||||
- `daemons.ts` manages stable daemon definitions with `upsert`, `get`, and `list`.
|
||||
- `daemonRuntime.ts` records daemon session lifecycle: `connect`, `heartbeat`, `disconnect`, `recordEvent`, and bounded event listing.
|
||||
- `daemonCommands.ts` owns the command queue: `enqueue`, `available`, `claim`, `start`, `succeed`, `fail`, `cancel`, and bounded command listing.
|
||||
- `organizations` and `organizationMembers` own tenancy.
|
||||
- `projects` owns the single v0 Git source; `projectContextDocuments` stores repository context.
|
||||
- `conversations`, `conversationTurns`, `conversationMessages`, and `conversationAttachments` own chat admission and reactive history.
|
||||
- `signals`, `signalConstraints`, and `signalSources` preserve structured intent and exact evidence.
|
||||
- `works`, `signalWorkAttachments`, and `workEvents` own proposed Work and provenance.
|
||||
|
||||
Commands are claimed by daemon session. Preserve the `claimedBySessionId` ownership checks, `leaseExpiresAt` expiry semantics, terminal statuses, and bounded query results when changing this flow.
|
||||
|
||||
## Usage
|
||||
|
||||
Convex functions are exposed by file path through the generated `api` object, for example `api.daemonCommands.enqueue` or `api.daemonRuntime.connect`. Run `bun run dev:server` from the repository root to start the development deployment, and `bun run dev:setup` when configuring Convex for the first time.
|
||||
The `flue*` tables are infrastructure tables required by Flue's persistence
|
||||
contract. They are deliberately separate from the product relations.
|
||||
|
||||
22
packages/backend/convex/_generated/api.d.ts
vendored
22
packages/backend/convex/_generated/api.d.ts
vendored
@@ -8,28 +8,17 @@
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type * as agentWorkspace from "../agentWorkspace.js";
|
||||
import type * as artifactModel from "../artifactModel.js";
|
||||
import type * as auth from "../auth.js";
|
||||
import type * as authz from "../authz.js";
|
||||
import type * as conversationMessages from "../conversationMessages.js";
|
||||
import type * as daemonCommands from "../daemonCommands.js";
|
||||
import type * as daemonRuntime from "../daemonRuntime.js";
|
||||
import type * as daemons from "../daemons.js";
|
||||
import type * as fluePersistence from "../fluePersistence.js";
|
||||
import type * as healthCheck from "../healthCheck.js";
|
||||
import type * as http from "../http.js";
|
||||
import type * as organizations from "../organizations.js";
|
||||
import type * as privateData from "../privateData.js";
|
||||
import type * as projectArtifacts from "../projectArtifacts.js";
|
||||
import type * as projectIssues from "../projectIssues.js";
|
||||
import type * as projectStore from "../projectStore.js";
|
||||
import type * as projects from "../projects.js";
|
||||
import type * as publicGit from "../publicGit.js";
|
||||
import type * as signalRouting from "../signalRouting.js";
|
||||
import type * as signals from "../signals.js";
|
||||
import type * as todos from "../todos.js";
|
||||
import type * as workflows from "../workflows.js";
|
||||
import type * as works from "../works.js";
|
||||
|
||||
import type {
|
||||
@@ -39,28 +28,17 @@ import type {
|
||||
} from "convex/server";
|
||||
|
||||
declare const fullApi: ApiFromModules<{
|
||||
agentWorkspace: typeof agentWorkspace;
|
||||
artifactModel: typeof artifactModel;
|
||||
auth: typeof auth;
|
||||
authz: typeof authz;
|
||||
conversationMessages: typeof conversationMessages;
|
||||
daemonCommands: typeof daemonCommands;
|
||||
daemonRuntime: typeof daemonRuntime;
|
||||
daemons: typeof daemons;
|
||||
fluePersistence: typeof fluePersistence;
|
||||
healthCheck: typeof healthCheck;
|
||||
http: typeof http;
|
||||
organizations: typeof organizations;
|
||||
privateData: typeof privateData;
|
||||
projectArtifacts: typeof projectArtifacts;
|
||||
projectIssues: typeof projectIssues;
|
||||
projectStore: typeof projectStore;
|
||||
projects: typeof projects;
|
||||
publicGit: typeof publicGit;
|
||||
signalRouting: typeof signalRouting;
|
||||
signals: typeof signals;
|
||||
todos: typeof todos;
|
||||
workflows: typeof workflows;
|
||||
works: typeof works;
|
||||
}>;
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import type { DataModel } from "./dataModel.js";
|
||||
*/
|
||||
type Env = {
|
||||
readonly FLUE_DB_TOKEN: string;
|
||||
readonly FLUE_URL: string | undefined;
|
||||
readonly GITEA_TOKEN: string | undefined;
|
||||
readonly GITEA_URL: string | undefined;
|
||||
readonly NATIVE_APP_URL: string | undefined;
|
||||
|
||||
@@ -1,388 +0,0 @@
|
||||
import { env } from "@code/env/convex";
|
||||
import {
|
||||
makeIssueWorkspacePlan,
|
||||
transitionWorkspaceRun,
|
||||
WorkspaceStateError,
|
||||
WorkspaceValidationError,
|
||||
} from "@code/primitives/project-workspace";
|
||||
import { ConvexError, v } from "convex/values";
|
||||
import { Effect } from "effect";
|
||||
|
||||
import { mutation, query } from "./_generated/server";
|
||||
import { artifactPath } from "./artifactModel";
|
||||
|
||||
const requireAgent = (token: string) => {
|
||||
if (token !== env.FLUE_DB_TOKEN) {
|
||||
throw new ConvexError("Invalid agent control token");
|
||||
}
|
||||
};
|
||||
|
||||
const agentStatus = v.union(
|
||||
v.literal("working"),
|
||||
v.literal("needs-input"),
|
||||
v.literal("completed"),
|
||||
v.literal("failed")
|
||||
);
|
||||
|
||||
const workspacePlanFor = (input: {
|
||||
readonly issueBody: string;
|
||||
readonly issueId: string;
|
||||
readonly issueNumber: number;
|
||||
readonly issueTitle: string;
|
||||
readonly sourceUrl: string | undefined;
|
||||
readonly defaultBranch: string | undefined;
|
||||
}) => {
|
||||
try {
|
||||
return Effect.runSync(
|
||||
makeIssueWorkspacePlan({
|
||||
artifacts: [],
|
||||
branchName: undefined,
|
||||
checkoutPath: undefined,
|
||||
contextFiles: [],
|
||||
defaultBranch: input.defaultBranch,
|
||||
issueBody: input.issueBody,
|
||||
issueId: input.issueId,
|
||||
issueNumber: input.issueNumber,
|
||||
issueTitle: input.issueTitle,
|
||||
sourceUrl: input.sourceUrl,
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof WorkspaceValidationError) {
|
||||
throw new ConvexError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const get = query({
|
||||
args: { issueId: v.id("projectIssues"), token: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
requireAgent(args.token);
|
||||
const issue = await ctx.db.get("projectIssues", args.issueId);
|
||||
if (!issue) {
|
||||
throw new ConvexError("Issue not found");
|
||||
}
|
||||
const project = await ctx.db.get("projects", issue.projectId);
|
||||
if (!project) {
|
||||
throw new ConvexError("Project not found");
|
||||
}
|
||||
const artifacts = await ctx.db
|
||||
.query("projectArtifacts")
|
||||
.withIndex("by_project", (q) => q.eq("projectId", project._id))
|
||||
.take(25);
|
||||
const [source] = await ctx.db
|
||||
.query("projectSources")
|
||||
.withIndex("by_project", (q) => q.eq("projectId", project._id))
|
||||
.take(1);
|
||||
const contextDocuments = await ctx.db
|
||||
.query("projectContextDocuments")
|
||||
.withIndex("by_project", (q) => q.eq("projectId", project._id))
|
||||
.take(25);
|
||||
const run = await ctx.db
|
||||
.query("projectWorkRuns")
|
||||
.withIndex("by_issue", (q) => q.eq("issueId", issue._id))
|
||||
.unique();
|
||||
return {
|
||||
artifacts,
|
||||
contextDocuments,
|
||||
issue,
|
||||
project,
|
||||
run: run ?? null,
|
||||
source: source ?? null,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const ensureRun = mutation({
|
||||
args: {
|
||||
daemonId: v.string(),
|
||||
issueId: v.id("projectIssues"),
|
||||
token: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
requireAgent(args.token);
|
||||
const issue = await ctx.db.get("projectIssues", args.issueId);
|
||||
if (!issue) {
|
||||
throw new ConvexError("Issue not found");
|
||||
}
|
||||
const [source] = await ctx.db
|
||||
.query("projectSources")
|
||||
.withIndex("by_project", (q) => q.eq("projectId", issue.projectId))
|
||||
.take(1);
|
||||
const plan = workspacePlanFor({
|
||||
defaultBranch: source?.defaultBranch,
|
||||
issueBody: issue.body,
|
||||
issueId: String(issue._id),
|
||||
issueNumber: issue.number,
|
||||
issueTitle: issue.title,
|
||||
sourceUrl: source?.url,
|
||||
});
|
||||
const existing = await ctx.db
|
||||
.query("projectWorkRuns")
|
||||
.withIndex("by_issue", (q) => q.eq("issueId", issue._id))
|
||||
.unique();
|
||||
if (existing) {
|
||||
if (
|
||||
existing.baseBranch !== plan.baseBranch ||
|
||||
existing.branchName !== plan.branchName ||
|
||||
existing.checkoutPath !== plan.checkoutPath ||
|
||||
existing.sourceUrl !== plan.sourceUrl
|
||||
) {
|
||||
await ctx.db.patch("projectWorkRuns", existing._id, {
|
||||
baseBranch: plan.baseBranch,
|
||||
branchName: plan.branchName,
|
||||
checkoutPath: plan.checkoutPath,
|
||||
sourceUrl: plan.sourceUrl,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
return await ctx.db.get("projectWorkRuns", existing._id);
|
||||
}
|
||||
const timestamp = Date.now();
|
||||
const actorKey = [
|
||||
"project",
|
||||
String(issue.projectId),
|
||||
"issue",
|
||||
String(issue._id),
|
||||
];
|
||||
const runId = await ctx.db.insert("projectWorkRuns", {
|
||||
actorKey,
|
||||
agentId: String(issue._id),
|
||||
baseBranch: plan.baseBranch,
|
||||
branchName: plan.branchName,
|
||||
checkoutPath: plan.checkoutPath,
|
||||
createdAt: timestamp,
|
||||
daemonId: args.daemonId,
|
||||
issueId: issue._id,
|
||||
projectId: issue.projectId,
|
||||
sourceUrl: plan.sourceUrl,
|
||||
status: "queued",
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
await ctx.db.insert("projectEvents", {
|
||||
createdAt: timestamp,
|
||||
data: {
|
||||
actorKey,
|
||||
branchName: plan.branchName,
|
||||
checkoutPath: plan.checkoutPath,
|
||||
daemonId: args.daemonId,
|
||||
sourceUrl: plan.sourceUrl,
|
||||
},
|
||||
issueId: issue._id,
|
||||
kind: "agent.workspace.created",
|
||||
projectId: issue.projectId,
|
||||
runId,
|
||||
});
|
||||
return await ctx.db.get("projectWorkRuns", runId);
|
||||
},
|
||||
});
|
||||
|
||||
export const updateArtifact = mutation({
|
||||
args: {
|
||||
content: v.string(),
|
||||
issueId: v.id("projectIssues"),
|
||||
path: artifactPath,
|
||||
token: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
requireAgent(args.token);
|
||||
if (args.content.length > 200_000) {
|
||||
throw new ConvexError("Artifact content exceeds 200000 characters");
|
||||
}
|
||||
const issue = await ctx.db.get("projectIssues", args.issueId);
|
||||
if (!issue) {
|
||||
throw new ConvexError("Issue not found");
|
||||
}
|
||||
const artifact = await ctx.db
|
||||
.query("projectArtifacts")
|
||||
.withIndex("by_project_and_path", (q) =>
|
||||
q.eq("projectId", issue.projectId).eq("path", args.path)
|
||||
)
|
||||
.unique();
|
||||
if (!artifact) {
|
||||
throw new ConvexError(`Artifact ${args.path} not found`);
|
||||
}
|
||||
const timestamp = Date.now();
|
||||
await ctx.db.patch("projectArtifacts", artifact._id, {
|
||||
content: args.content,
|
||||
revision: artifact.revision + 1,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
await ctx.db.insert("projectEvents", {
|
||||
createdAt: timestamp,
|
||||
data: { path: args.path, revision: artifact.revision + 1 },
|
||||
issueId: issue._id,
|
||||
kind: "artifact.updated",
|
||||
projectId: issue.projectId,
|
||||
});
|
||||
return artifact.revision + 1;
|
||||
},
|
||||
});
|
||||
|
||||
export const setStatus = mutation({
|
||||
args: {
|
||||
issueId: v.id("projectIssues"),
|
||||
status: agentStatus,
|
||||
summary: v.optional(v.string()),
|
||||
token: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
requireAgent(args.token);
|
||||
const issue = await ctx.db.get("projectIssues", args.issueId);
|
||||
if (!issue) {
|
||||
throw new ConvexError("Issue not found");
|
||||
}
|
||||
const run = await ctx.db
|
||||
.query("projectWorkRuns")
|
||||
.withIndex("by_issue", (q) => q.eq("issueId", issue._id))
|
||||
.unique();
|
||||
if (!run) {
|
||||
throw new ConvexError("Agent work run not found");
|
||||
}
|
||||
try {
|
||||
Effect.runSync(
|
||||
transitionWorkspaceRun({
|
||||
from: run.status,
|
||||
to: args.status,
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof WorkspaceStateError) {
|
||||
throw new ConvexError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const timestamp = Date.now();
|
||||
const terminal = args.status === "completed" || args.status === "failed";
|
||||
await ctx.db.patch("projectIssues", issue._id, {
|
||||
status: args.status,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
await ctx.db.patch("projectWorkRuns", run._id, {
|
||||
status: args.status,
|
||||
summary: args.summary?.slice(0, 4000),
|
||||
updatedAt: timestamp,
|
||||
...(args.status === "working" && run.startedAt === undefined
|
||||
? { startedAt: timestamp }
|
||||
: {}),
|
||||
...(terminal ? { completedAt: timestamp } : {}),
|
||||
});
|
||||
await ctx.db.insert("projectEvents", {
|
||||
createdAt: timestamp,
|
||||
data: { summary: args.summary?.slice(0, 1000) },
|
||||
issueId: issue._id,
|
||||
kind: `agent.${args.status}`,
|
||||
projectId: issue.projectId,
|
||||
runId: run._id,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const lifecycleStatus = v.union(
|
||||
v.literal("no_changes"),
|
||||
v.literal("committed"),
|
||||
v.literal("pushed"),
|
||||
v.literal("pull_request_open"),
|
||||
v.literal("failed")
|
||||
);
|
||||
|
||||
const pullRequestMetadata = v.object({
|
||||
baseBranch: v.string(),
|
||||
branch: v.string(),
|
||||
number: v.number(),
|
||||
status: v.union(v.literal("open"), v.literal("closed"), v.literal("merged")),
|
||||
url: v.string(),
|
||||
});
|
||||
|
||||
export const recordGiteaLifecycle = mutation({
|
||||
args: {
|
||||
baseBranch: v.string(),
|
||||
branch: v.string(),
|
||||
commitSha: v.optional(v.string()),
|
||||
error: v.optional(v.string()),
|
||||
issueId: v.id("projectIssues"),
|
||||
pullRequest: v.optional(pullRequestMetadata),
|
||||
status: lifecycleStatus,
|
||||
token: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
requireAgent(args.token);
|
||||
const issue = await ctx.db.get("projectIssues", args.issueId);
|
||||
if (!issue) {
|
||||
throw new ConvexError("Issue not found");
|
||||
}
|
||||
const run = await ctx.db
|
||||
.query("projectWorkRuns")
|
||||
.withIndex("by_issue", (q) => q.eq("issueId", issue._id))
|
||||
.unique();
|
||||
if (!run) {
|
||||
throw new ConvexError("Agent work run not found");
|
||||
}
|
||||
|
||||
const events = await ctx.db
|
||||
.query("projectEvents")
|
||||
.withIndex("by_issue_and_createdAt", (q) => q.eq("issueId", issue._id))
|
||||
.order("desc")
|
||||
.take(100);
|
||||
const existing = events.find((event) => {
|
||||
if (!event.kind.startsWith("gitea.")) {
|
||||
return false;
|
||||
}
|
||||
const data = event.data as {
|
||||
branch?: unknown;
|
||||
commitSha?: unknown;
|
||||
status?: unknown;
|
||||
};
|
||||
return (
|
||||
data.branch === args.branch &&
|
||||
data.commitSha === args.commitSha &&
|
||||
data.status === args.status
|
||||
);
|
||||
});
|
||||
if (existing) {
|
||||
return existing.data;
|
||||
}
|
||||
|
||||
const timestamp = Date.now();
|
||||
const data = {
|
||||
baseBranch: args.baseBranch,
|
||||
branch: args.branch,
|
||||
...(args.commitSha === undefined ? {} : { commitSha: args.commitSha }),
|
||||
...(args.error === undefined ? {} : { error: args.error.slice(0, 2000) }),
|
||||
...(args.pullRequest === undefined
|
||||
? {}
|
||||
: { pullRequest: args.pullRequest }),
|
||||
status: args.status,
|
||||
};
|
||||
const artifact = await ctx.db
|
||||
.query("projectArtifacts")
|
||||
.withIndex("by_project_and_path", (q) =>
|
||||
q.eq("projectId", issue.projectId).eq("path", "artifacts.md")
|
||||
)
|
||||
.unique();
|
||||
if (artifact) {
|
||||
const pullRequestLine = args.pullRequest
|
||||
? `- Pull request: [#${args.pullRequest.number}](${args.pullRequest.url}) (${args.pullRequest.status})\n`
|
||||
: "";
|
||||
const commitLine = args.commitSha ? `- Commit: ${args.commitSha}\n` : "";
|
||||
await ctx.db.patch("projectArtifacts", artifact._id, {
|
||||
content: `${artifact.content}\n## Git lifecycle\n\n- Status: ${args.status}\n- Branch: ${args.branch}\n- Base branch: ${args.baseBranch}\n${commitLine}${pullRequestLine}${args.error ? `- Error: ${args.error.slice(0, 1000)}\n` : ""}`,
|
||||
revision: artifact.revision + 1,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
}
|
||||
await ctx.db.insert("projectEvents", {
|
||||
createdAt: timestamp,
|
||||
data,
|
||||
issueId: issue._id,
|
||||
kind:
|
||||
args.pullRequest === undefined
|
||||
? "gitea.lifecycle.updated"
|
||||
: "gitea.pull_request.created",
|
||||
projectId: issue.projectId,
|
||||
runId: run._id,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
});
|
||||
@@ -1,71 +0,0 @@
|
||||
import { v } from "convex/values";
|
||||
|
||||
export const ARTIFACT_PATHS = [
|
||||
"agent.md",
|
||||
"work.md",
|
||||
"steps.md",
|
||||
"artifacts.md",
|
||||
"signals.md",
|
||||
"agent-manager.md",
|
||||
"context.md",
|
||||
"card.md",
|
||||
] as const;
|
||||
|
||||
export type ArtifactPath = (typeof ARTIFACT_PATHS)[number];
|
||||
|
||||
export const artifactPath = v.union(
|
||||
v.literal("agent.md"),
|
||||
v.literal("work.md"),
|
||||
v.literal("steps.md"),
|
||||
v.literal("artifacts.md"),
|
||||
v.literal("signals.md"),
|
||||
v.literal("agent-manager.md"),
|
||||
v.literal("context.md"),
|
||||
v.literal("card.md")
|
||||
);
|
||||
|
||||
export const createInitialArtifacts = (): readonly {
|
||||
path: ArtifactPath;
|
||||
content: string;
|
||||
}[] => [
|
||||
{
|
||||
content:
|
||||
"# Agent\n\n## Role\n\nOwn one project issue at a time. Read the project artifacts before editing code, ask a focused question when blocked, and support completion claims with command output.\n\n## Guardrails\n\n- Keep issue work isolated in its AgentOS workspace.\n- Update work.md, steps.md, artifacts.md, and context.md as facts change.\n- Never mark work complete without a relevant runtime check.\n",
|
||||
path: "agent.md",
|
||||
},
|
||||
{
|
||||
content:
|
||||
"# Work\n\nNo issue has entered the agent queue yet. New issues are appended here by the control plane.\n",
|
||||
path: "work.md",
|
||||
},
|
||||
{
|
||||
content:
|
||||
"# Steps\n\n1. Read the issue and project artifacts.\n2. Inspect the repository context.\n3. Ask for missing decisions only when work cannot continue safely.\n4. Implement the smallest complete change.\n5. Run the relevant command or scenario.\n6. Publish changed artifacts and the final signal.\n",
|
||||
path: "steps.md",
|
||||
},
|
||||
{
|
||||
content:
|
||||
"# Artifacts\n\nThis file indexes concrete outputs from issue runs, including changed paths, command results, and preview links.\n",
|
||||
path: "artifacts.md",
|
||||
},
|
||||
{
|
||||
content:
|
||||
"# Project events\n\nThe agent manager emits `issue.queued`, `agent.started`, `agent.needs-input`, `agent.completed`, and `agent.failed`. These are durable project events and drive the web status.\n",
|
||||
path: "signals.md",
|
||||
},
|
||||
{
|
||||
content:
|
||||
"# Agent manager\n\n## State machine\n\n`open -> queued -> working -> completed`\n\nA blocked run moves from `working` to `needs-input`; a resumed run returns to `queued`. An unrecoverable run moves to `failed`.\n\nEach issue owns one Flue agent identity and one AgentOS actor key, so conversation and workspace state remain isolated.\n",
|
||||
path: "agent-manager.md",
|
||||
},
|
||||
{
|
||||
content:
|
||||
"# Context\n\nCanonical project knowledge is staged under /workspace/context. Issue-specific facts belong below.\n",
|
||||
path: "context.md",
|
||||
},
|
||||
{
|
||||
content:
|
||||
"# Project card\n\nThe web project workspace reads and writes Projects, context documents, artifacts, and issues through the generated Convex interface. Agent work uses the issue-scoped workspace and durable daemon commands.\n",
|
||||
path: "card.md",
|
||||
},
|
||||
];
|
||||
@@ -1,90 +0,0 @@
|
||||
/**
|
||||
* Targeted runtime smoke for the authenticated conversation ingress.
|
||||
*
|
||||
* Simulates the exact sequence the Flue route middleware drives, end to end,
|
||||
* against the real Convex schema and modules via convexTest:
|
||||
*
|
||||
* 1. ensurePersonalOrganization (bearer auth → org resolution)
|
||||
* 2. beginUserMessage (evidence before Flue admission)
|
||||
* 3. markAdmitted (Flue receipt submission id)
|
||||
*
|
||||
* Then verifies the stored normalized evidence has exact raw text, organization
|
||||
* scope, conversation/message identity, request id, role user, and the admitted
|
||||
* submission id.
|
||||
*
|
||||
* This is a smoke, not a unit test: delete after the live browser smoke is
|
||||
* confirmed.
|
||||
*/
|
||||
import { convexTest } from "convex-test";
|
||||
import { anyApi } from "convex/server";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import schema from "./schema";
|
||||
|
||||
declare global {
|
||||
interface ImportMeta {
|
||||
readonly glob: (pattern: string) => Record<string, () => Promise<unknown>>;
|
||||
}
|
||||
}
|
||||
|
||||
const modules = import.meta.glob("./**/*.ts");
|
||||
const api = anyApi;
|
||||
|
||||
const ID_A = "https://convex.test|smoke-user";
|
||||
const identityA = { tokenIdentifier: ID_A };
|
||||
|
||||
const newTest = () => convexTest({ schema, modules });
|
||||
|
||||
describe("conversation ingress smoke", () => {
|
||||
test("one user message produces normalized admitted evidence end to end", async () => {
|
||||
const t = newTest();
|
||||
|
||||
// 1. Auth + org resolution (the middleware resolves this from the bearer
|
||||
// token via a request-scoped ConvexHttpClient).
|
||||
const org = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.organizations.ensurePersonalOrganization, {});
|
||||
|
||||
const rawText = " Fix the login bug \n\t please ";
|
||||
const clientRequestId = "smoke-req-001";
|
||||
|
||||
// 2. Begin evidence before Flue admission.
|
||||
const begun = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.conversationMessages.beginUserMessage, {
|
||||
clientRequestId,
|
||||
organizationId: org._id,
|
||||
rawText,
|
||||
});
|
||||
expect(begun.status).toBe("admitting");
|
||||
expect(begun.rawText).toBe(rawText);
|
||||
|
||||
// 3. Mark admitted with the Flue receipt submission id.
|
||||
const submissionId = "flue-sub-abc123";
|
||||
await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.conversationMessages.markAdmitted, {
|
||||
clientRequestId,
|
||||
organizationId: org._id,
|
||||
submissionId,
|
||||
});
|
||||
|
||||
// 4. Verify normalized evidence via authorized observation.
|
||||
const observed = await t
|
||||
.withIdentity(identityA)
|
||||
.query(api.conversationMessages.getByRequest, {
|
||||
clientRequestId,
|
||||
organizationId: org._id,
|
||||
});
|
||||
|
||||
expect(observed).not.toBeNull();
|
||||
expect(observed?.rawText).toBe(rawText);
|
||||
expect(observed?.organizationId).toBe(org._id);
|
||||
expect(observed?.conversationId).toBe(org._id);
|
||||
expect(typeof observed?.messageId).toBe("string");
|
||||
expect(observed?.clientRequestId).toBe(clientRequestId);
|
||||
expect(observed?.role).toBe("user");
|
||||
expect(observed?.status).toBe("admitted");
|
||||
expect(observed?.submissionId).toBe(submissionId);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,6 @@ import { convexTest } from "convex-test";
|
||||
import { anyApi } from "convex/server";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { requireOrganizationMember } from "./authz";
|
||||
import schema from "./schema";
|
||||
|
||||
declare global {
|
||||
@@ -13,288 +12,101 @@ declare global {
|
||||
|
||||
const modules = import.meta.glob("./**/*.ts");
|
||||
const api = anyApi;
|
||||
|
||||
const ID_A = "https://convex.test|user-a";
|
||||
const ID_B = "https://convex.test|user-b";
|
||||
|
||||
const identityA = { tokenIdentifier: ID_A };
|
||||
const identityB = { tokenIdentifier: ID_B };
|
||||
|
||||
const newTest = () => convexTest({ schema, modules });
|
||||
const identityA = { tokenIdentifier: "https://convex.test|user-a" };
|
||||
const identityB = { tokenIdentifier: "https://convex.test|user-b" };
|
||||
const newTest = () => convexTest({ modules, schema });
|
||||
|
||||
const ensureOrg = async (
|
||||
t: ReturnType<typeof newTest>,
|
||||
identity: { readonly tokenIdentifier: string }
|
||||
) => {
|
||||
const org = await t
|
||||
) =>
|
||||
await t
|
||||
.withIdentity(identity)
|
||||
.mutation(api.organizations.ensurePersonalOrganization, {});
|
||||
return org._id as string;
|
||||
};
|
||||
|
||||
describe("conversationMessages", () => {
|
||||
test("unauthenticated access is rejected", async () => {
|
||||
test("queues one durable user turn and one assistant placeholder", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t, identityA);
|
||||
const organization = await ensureOrg(t, identityA);
|
||||
|
||||
await expect(
|
||||
t.mutation(api.conversationMessages.beginUserMessage, {
|
||||
clientRequestId: "req-1",
|
||||
organizationId: orgId,
|
||||
rawText: "hello",
|
||||
})
|
||||
).rejects.toThrow(/Authentication required/u);
|
||||
await t.withIdentity(identityA).mutation(api.conversationMessages.send, {
|
||||
clientRequestId: "request-1",
|
||||
images: [],
|
||||
organizationId: organization._id,
|
||||
rawText: " Keep this exact text. ",
|
||||
});
|
||||
|
||||
await expect(
|
||||
t.mutation(api.conversationMessages.markAdmitted, {
|
||||
clientRequestId: "req-1",
|
||||
organizationId: orgId,
|
||||
submissionId: "sub-1",
|
||||
})
|
||||
).rejects.toThrow(/Authentication required/u);
|
||||
|
||||
await expect(
|
||||
t.query(api.conversationMessages.getByRequest, {
|
||||
clientRequestId: "req-1",
|
||||
organizationId: orgId,
|
||||
})
|
||||
).rejects.toThrow(/Authentication required/u);
|
||||
const rows = await t
|
||||
.withIdentity(identityA)
|
||||
.query(api.conversationMessages.listForCurrentOrganization, {
|
||||
organizationId: organization._id,
|
||||
});
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0]).toMatchObject({
|
||||
rawText: " Keep this exact text. ",
|
||||
role: "user",
|
||||
status: "queued",
|
||||
});
|
||||
expect(rows[1]).toMatchObject({
|
||||
rawText: "",
|
||||
role: "assistant",
|
||||
status: "queued",
|
||||
});
|
||||
});
|
||||
|
||||
test("cross-organization access is denied for begin and observation", async () => {
|
||||
test("is idempotent by client request id", async () => {
|
||||
const t = newTest();
|
||||
const orgA = await ensureOrg(t, identityA);
|
||||
const _orgB = await ensureOrg(t, identityB);
|
||||
const organization = await ensureOrg(t, identityA);
|
||||
const input = {
|
||||
clientRequestId: "request-duplicate",
|
||||
images: [],
|
||||
organizationId: organization._id,
|
||||
rawText: "First payload wins",
|
||||
};
|
||||
|
||||
// User A captures a message in its own organization.
|
||||
const created = await t
|
||||
const first = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.conversationMessages.beginUserMessage, {
|
||||
clientRequestId: "req-cross",
|
||||
organizationId: orgA,
|
||||
rawText: "private to org A",
|
||||
.mutation(api.conversationMessages.send, input);
|
||||
const second = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.conversationMessages.send, {
|
||||
...input,
|
||||
rawText: "Ignored duplicate payload",
|
||||
});
|
||||
|
||||
// User B cannot begin a message under organization A.
|
||||
await expect(
|
||||
t
|
||||
.withIdentity(identityB)
|
||||
.mutation(api.conversationMessages.beginUserMessage, {
|
||||
clientRequestId: "req-cross",
|
||||
organizationId: orgA,
|
||||
rawText: "intruder",
|
||||
})
|
||||
).rejects.toThrow(/Organization membership required/u);
|
||||
expect(second).toEqual(first);
|
||||
const rows = await t
|
||||
.withIdentity(identityA)
|
||||
.query(api.conversationMessages.listForCurrentOrganization, {
|
||||
organizationId: organization._id,
|
||||
});
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0]?.rawText).toBe("First payload wins");
|
||||
});
|
||||
|
||||
// User B cannot observe organization A's message by request id.
|
||||
await expect(
|
||||
t.withIdentity(identityB).query(api.conversationMessages.getByRequest, {
|
||||
clientRequestId: "req-cross",
|
||||
organizationId: orgA,
|
||||
})
|
||||
).rejects.toThrow(/Organization membership required/u);
|
||||
test("rejects unauthenticated and cross-organization access", async () => {
|
||||
const t = newTest();
|
||||
const organization = await ensureOrg(t, identityA);
|
||||
await ensureOrg(t, identityB);
|
||||
const input = {
|
||||
clientRequestId: "request-private",
|
||||
images: [],
|
||||
organizationId: organization._id,
|
||||
rawText: "Private",
|
||||
};
|
||||
|
||||
// User B cannot list organization A's conversation.
|
||||
await expect(
|
||||
t.mutation(api.conversationMessages.send, input)
|
||||
).rejects.toThrow(/Authentication required/u);
|
||||
await expect(
|
||||
t.withIdentity(identityB).mutation(api.conversationMessages.send, input)
|
||||
).rejects.toThrow(/Organization membership required/u);
|
||||
await expect(
|
||||
t
|
||||
.withIdentity(identityB)
|
||||
.query(api.conversationMessages.listForCurrentOrganization, {
|
||||
organizationId: orgA,
|
||||
organizationId: organization._id,
|
||||
})
|
||||
).rejects.toThrow(/Organization membership required/u);
|
||||
|
||||
// The membership boundary independently denies cross-org access.
|
||||
await expect(
|
||||
t
|
||||
.withIdentity(identityB)
|
||||
.query((ctx) => requireOrganizationMember(ctx, orgA as never))
|
||||
).rejects.toThrow(/Organization membership required/u);
|
||||
|
||||
expect(created.status).toBe("admitting");
|
||||
});
|
||||
|
||||
test("duplicate request id is idempotent and preserves exact whitespace and casing", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t, identityA);
|
||||
const rawText = " Hello WORLD \n\t with spaces ";
|
||||
|
||||
const first = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.conversationMessages.beginUserMessage, {
|
||||
clientRequestId: "req-dup",
|
||||
organizationId: orgId,
|
||||
rawText,
|
||||
});
|
||||
|
||||
// A second begin with the same request id returns the same row — no
|
||||
// duplicate is created.
|
||||
const second = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.conversationMessages.beginUserMessage, {
|
||||
clientRequestId: "req-dup",
|
||||
organizationId: orgId,
|
||||
rawText: "DIFFERENT TEXT MUST BE IGNORED",
|
||||
});
|
||||
|
||||
expect(second._id).toBe(first._id);
|
||||
expect(second.messageId).toBe(first.messageId);
|
||||
expect(second.rawText).toBe(rawText);
|
||||
expect(second.clientRequestId).toBe("req-dup");
|
||||
expect(second.role).toBe("user");
|
||||
expect(second.status).toBe("admitting");
|
||||
|
||||
// The conversation id equals the organization id (global agent scope).
|
||||
expect(first.conversationId).toBe(orgId);
|
||||
});
|
||||
|
||||
test("admitted transition records the submission id", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t, identityA);
|
||||
|
||||
const begun = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.conversationMessages.beginUserMessage, {
|
||||
clientRequestId: "req-admit",
|
||||
organizationId: orgId,
|
||||
rawText: "please help",
|
||||
});
|
||||
expect(begun.status).toBe("admitting");
|
||||
expect(begun.submissionId).toBeNull();
|
||||
|
||||
const admitted = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.conversationMessages.markAdmitted, {
|
||||
clientRequestId: "req-admit",
|
||||
organizationId: orgId,
|
||||
submissionId: "sub-receipt-123",
|
||||
});
|
||||
|
||||
expect(admitted.status).toBe("admitted");
|
||||
expect(admitted.submissionId).toBe("sub-receipt-123");
|
||||
|
||||
// Marking admitted again is idempotent and keeps the same submission id.
|
||||
const again = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.conversationMessages.markAdmitted, {
|
||||
clientRequestId: "req-admit",
|
||||
organizationId: orgId,
|
||||
submissionId: "sub-receipt-123",
|
||||
});
|
||||
expect(again.status).toBe("admitted");
|
||||
expect(again.submissionId).toBe("sub-receipt-123");
|
||||
});
|
||||
|
||||
test("failed transition records the failed status and preserves evidence", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t, identityA);
|
||||
|
||||
await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.conversationMessages.beginUserMessage, {
|
||||
clientRequestId: "req-fail",
|
||||
organizationId: orgId,
|
||||
rawText: "doomed message",
|
||||
});
|
||||
|
||||
const failed = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.conversationMessages.markFailed, {
|
||||
clientRequestId: "req-fail",
|
||||
organizationId: orgId,
|
||||
});
|
||||
|
||||
expect(failed.status).toBe("failed");
|
||||
expect(failed.submissionId).toBeNull();
|
||||
expect(failed.rawText).toBe("doomed message");
|
||||
|
||||
// A failed message is still observable as evidence.
|
||||
const observed = await t
|
||||
.withIdentity(identityA)
|
||||
.query(api.conversationMessages.getByRequest, {
|
||||
clientRequestId: "req-fail",
|
||||
organizationId: orgId,
|
||||
});
|
||||
expect(observed?.status).toBe("failed");
|
||||
expect(observed?.rawText).toBe("doomed message");
|
||||
});
|
||||
|
||||
test("admission wins over a concurrent failure and cannot be overwritten", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t, identityA);
|
||||
|
||||
const begun = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.conversationMessages.beginUserMessage, {
|
||||
clientRequestId: "req-race",
|
||||
organizationId: orgId,
|
||||
rawText: "race",
|
||||
});
|
||||
|
||||
// Admission completes first.
|
||||
await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.conversationMessages.markAdmitted, {
|
||||
clientRequestId: "req-race",
|
||||
organizationId: orgId,
|
||||
submissionId: "sub-won",
|
||||
});
|
||||
|
||||
// A late failure is a no-op: an admitted message keeps its submission id.
|
||||
const afterFail = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.conversationMessages.markFailed, {
|
||||
clientRequestId: "req-race",
|
||||
organizationId: orgId,
|
||||
});
|
||||
expect(afterFail.status).toBe("admitted");
|
||||
expect(afterFail.submissionId).toBe("sub-won");
|
||||
|
||||
expect(begun.status).toBe("admitting");
|
||||
});
|
||||
|
||||
test("list returns only the authenticated organization's messages", async () => {
|
||||
const t = newTest();
|
||||
const orgA = await ensureOrg(t, identityA);
|
||||
const orgB = await ensureOrg(t, identityB);
|
||||
|
||||
await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.conversationMessages.beginUserMessage, {
|
||||
clientRequestId: "req-a1",
|
||||
organizationId: orgA,
|
||||
rawText: "a-one",
|
||||
});
|
||||
await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.conversationMessages.beginUserMessage, {
|
||||
clientRequestId: "req-a2",
|
||||
organizationId: orgA,
|
||||
rawText: "a-two",
|
||||
});
|
||||
await t
|
||||
.withIdentity(identityB)
|
||||
.mutation(api.conversationMessages.beginUserMessage, {
|
||||
clientRequestId: "req-b1",
|
||||
organizationId: orgB,
|
||||
rawText: "b-one",
|
||||
});
|
||||
|
||||
const forA = await t
|
||||
.withIdentity(identityA)
|
||||
.query(api.conversationMessages.listForCurrentOrganization, {
|
||||
organizationId: orgA,
|
||||
});
|
||||
const forB = await t
|
||||
.withIdentity(identityB)
|
||||
.query(api.conversationMessages.listForCurrentOrganization, {
|
||||
organizationId: orgB,
|
||||
});
|
||||
|
||||
expect(forA).toHaveLength(2);
|
||||
expect(
|
||||
forA.map((m: { clientRequestId: string }) => m.clientRequestId)
|
||||
).toEqual(expect.arrayContaining(["req-a1", "req-a2"]));
|
||||
expect(forB).toHaveLength(1);
|
||||
expect(forB[0]?.clientRequestId).toBe("req-b1");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,220 +1,381 @@
|
||||
import { makeFunctionReference } from "convex/server";
|
||||
import { ConvexError, v } from "convex/values";
|
||||
|
||||
import type { Doc, Id } from "./_generated/dataModel";
|
||||
import { mutation, query } from "./_generated/server";
|
||||
import {
|
||||
env,
|
||||
internalAction,
|
||||
internalMutation,
|
||||
internalQuery,
|
||||
mutation,
|
||||
query,
|
||||
} from "./_generated/server";
|
||||
import { requireOrganizationMember } from "./authz";
|
||||
|
||||
export type ConversationMessageStatus = "admitting" | "admitted" | "failed";
|
||||
const MAX_ATTEMPTS = 3;
|
||||
|
||||
interface ConversationMessageView {
|
||||
readonly _id: Id<"conversationMessages">;
|
||||
readonly _creationTime: number;
|
||||
readonly organizationId: Id<"organizations">;
|
||||
readonly conversationId: string;
|
||||
readonly messageId: string;
|
||||
readonly submissionId: string | null;
|
||||
readonly clientRequestId: string;
|
||||
readonly role: "user";
|
||||
readonly rawText: string;
|
||||
readonly status: ConversationMessageStatus;
|
||||
readonly createdAt: number;
|
||||
}
|
||||
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");
|
||||
|
||||
const toView = (doc: Doc<"conversationMessages">): ConversationMessageView => ({
|
||||
_creationTime: doc._creationTime,
|
||||
_id: doc._id,
|
||||
clientRequestId: doc.clientRequestId,
|
||||
conversationId: doc.conversationId,
|
||||
createdAt: doc.createdAt,
|
||||
messageId: doc.messageId,
|
||||
organizationId: doc.organizationId,
|
||||
rawText: doc.rawText,
|
||||
role: doc.role,
|
||||
status: doc.status,
|
||||
submissionId: doc.submissionId ?? null,
|
||||
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();
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* The global Zopu conversation uses the organization ID as its conversation
|
||||
* identity (and agent instance ID). This resolves the conversation ID for the
|
||||
* authenticated user's current personal organization, so callers never supply
|
||||
* tenancy themselves.
|
||||
*/
|
||||
const resolveConversationId = (organizationId: Id<"organizations">): string =>
|
||||
organizationId;
|
||||
|
||||
/**
|
||||
* Idempotently begin a user message at the conversation ingress. If a row with
|
||||
* the same (organizationId, clientRequestId) already exists, return it instead
|
||||
* of creating a duplicate — Flue retries that replay the same request id must
|
||||
* not produce duplicate evidence. The raw text is stored verbatim; it is never
|
||||
* trimmed, collapsed, or rewritten.
|
||||
*
|
||||
* Authorization: the authenticated identity must be a member of the target
|
||||
* organization. The conversation ID, message ID, and timestamp are all
|
||||
* generated server-side; none are trusted from the caller.
|
||||
*/
|
||||
export const beginUserMessage = mutation({
|
||||
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): Promise<ConversationMessageView> => {
|
||||
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("conversationMessages")
|
||||
.withIndex("by_organization_and_request", (q) =>
|
||||
.query("conversationTurns")
|
||||
.withIndex("by_conversationId_and_clientRequestId", (q) =>
|
||||
q
|
||||
.eq("organizationId", args.organizationId)
|
||||
.eq("conversationId", conversation._id)
|
||||
.eq("clientRequestId", args.clientRequestId)
|
||||
)
|
||||
.unique();
|
||||
if (existing) {
|
||||
return toView(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 messageId = crypto.randomUUID();
|
||||
const conversationId = resolveConversationId(args.organizationId);
|
||||
const rowId = await ctx.db.insert("conversationMessages", {
|
||||
clientRequestId: args.clientRequestId,
|
||||
conversationId,
|
||||
createdAt,
|
||||
messageId,
|
||||
organizationId: args.organizationId,
|
||||
rawText: args.rawText,
|
||||
role: "user",
|
||||
status: "admitting",
|
||||
});
|
||||
const created = await ctx.db.get(rowId);
|
||||
if (!created) {
|
||||
throw new Error("Failed to read created conversation message");
|
||||
}
|
||||
return toView(created);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Mark a begun message as admitted after Flue accepts the submission. The
|
||||
* submission id links the normalized evidence to the Flue transcript. Only an
|
||||
* `admitting` message may transition to `admitted`; an already-admitted or
|
||||
* failed message is returned unchanged so a retry is idempotent.
|
||||
*/
|
||||
export const markAdmitted = mutation({
|
||||
args: {
|
||||
organizationId: v.id("organizations"),
|
||||
clientRequestId: v.string(),
|
||||
submissionId: v.string(),
|
||||
},
|
||||
handler: async (ctx, args): Promise<ConversationMessageView> => {
|
||||
await requireOrganizationMember(ctx, args.organizationId);
|
||||
const existing = await ctx.db
|
||||
const lastMessage = await ctx.db
|
||||
.query("conversationMessages")
|
||||
.withIndex("by_organization_and_request", (q) =>
|
||||
q
|
||||
.eq("organizationId", args.organizationId)
|
||||
.eq("clientRequestId", args.clientRequestId)
|
||||
)
|
||||
.unique();
|
||||
if (!existing) {
|
||||
throw new ConvexError("Conversation message not found");
|
||||
}
|
||||
if (existing.status === "admitting") {
|
||||
await ctx.db.patch(existing._id, {
|
||||
status: "admitted",
|
||||
submissionId: args.submissionId,
|
||||
});
|
||||
}
|
||||
const updated = await ctx.db.get(existing._id);
|
||||
if (!updated) {
|
||||
throw new Error("Failed to read updated conversation message");
|
||||
}
|
||||
return toView(updated);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Mark a begun message as failed when Flue admission rejects it. Failed
|
||||
* messages remain as evidence with an explicit status; they are never silently
|
||||
* deleted. Only an `admitting` message may transition to `failed`; an
|
||||
* already-admitted message is never overwritten (admission won), and an
|
||||
* already-failed message is returned unchanged.
|
||||
*/
|
||||
export const markFailed = mutation({
|
||||
args: {
|
||||
organizationId: v.id("organizations"),
|
||||
clientRequestId: v.string(),
|
||||
},
|
||||
handler: async (ctx, args): Promise<ConversationMessageView> => {
|
||||
await requireOrganizationMember(ctx, args.organizationId);
|
||||
const existing = await ctx.db
|
||||
.query("conversationMessages")
|
||||
.withIndex("by_organization_and_request", (q) =>
|
||||
q
|
||||
.eq("organizationId", args.organizationId)
|
||||
.eq("clientRequestId", args.clientRequestId)
|
||||
)
|
||||
.unique();
|
||||
if (!existing) {
|
||||
throw new ConvexError("Conversation message not found");
|
||||
}
|
||||
if (existing.status === "admitting") {
|
||||
await ctx.db.patch(existing._id, { status: "failed" });
|
||||
}
|
||||
const updated = await ctx.db.get(existing._id);
|
||||
if (!updated) {
|
||||
throw new Error("Failed to read updated conversation message");
|
||||
}
|
||||
return toView(updated);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Read a single message by its client request id. Authorization requires
|
||||
* membership in the message's organization; cross-organization observation is
|
||||
* denied. Returns null when no message matches so observers cannot probe for
|
||||
* another organization's request ids.
|
||||
*/
|
||||
export const getByRequest = query({
|
||||
args: {
|
||||
organizationId: v.id("organizations"),
|
||||
clientRequestId: v.string(),
|
||||
},
|
||||
handler: async (ctx, args): Promise<ConversationMessageView | null> => {
|
||||
await requireOrganizationMember(ctx, args.organizationId);
|
||||
const existing = await ctx.db
|
||||
.query("conversationMessages")
|
||||
.withIndex("by_organization_and_request", (q) =>
|
||||
q
|
||||
.eq("organizationId", args.organizationId)
|
||||
.eq("clientRequestId", args.clientRequestId)
|
||||
)
|
||||
.unique();
|
||||
return existing ? toView(existing) : null;
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* List the user messages for the authenticated user's current organization
|
||||
* conversation, newest first. This is the authorized observation path used by
|
||||
* GET/SSE routes and product clients. Cross-organization access is denied.
|
||||
*/
|
||||
export const listForCurrentOrganization = query({
|
||||
args: {
|
||||
organizationId: v.id("organizations"),
|
||||
},
|
||||
handler: async (ctx, args): Promise<ConversationMessageView[]> => {
|
||||
await requireOrganizationMember(ctx, args.organizationId);
|
||||
const conversationId = resolveConversationId(args.organizationId);
|
||||
const rows = await ctx.db
|
||||
.query("conversationMessages")
|
||||
.withIndex("by_organization_and_message", (q) =>
|
||||
q
|
||||
.eq("organizationId", args.organizationId)
|
||||
.eq("conversationId", conversationId)
|
||||
.withIndex("by_conversationId_and_ordinal", (q) =>
|
||||
q.eq("conversationId", conversation._id)
|
||||
)
|
||||
.order("desc")
|
||||
.take(100);
|
||||
return rows.map(toView);
|
||||
.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;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ const app = defineApp({
|
||||
NATIVE_APP_URL: v.optional(v.string()),
|
||||
SITE_URL: v.string(),
|
||||
FLUE_DB_TOKEN: v.string(),
|
||||
FLUE_URL: v.optional(v.string()),
|
||||
GITEA_URL: v.optional(v.string()),
|
||||
GITEA_TOKEN: v.optional(v.string()),
|
||||
},
|
||||
|
||||
@@ -1,204 +0,0 @@
|
||||
import { v } from "convex/values";
|
||||
|
||||
import { mutation, query } from "./_generated/server";
|
||||
|
||||
const commandStatus = v.union(
|
||||
v.literal("queued"),
|
||||
v.literal("claimed"),
|
||||
v.literal("running"),
|
||||
v.literal("succeeded"),
|
||||
v.literal("failed"),
|
||||
v.literal("cancelled")
|
||||
);
|
||||
|
||||
export const enqueue = mutation({
|
||||
args: {
|
||||
daemonId: v.string(),
|
||||
method: v.string(),
|
||||
args: v.any(),
|
||||
actorKey: v.array(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const timestamp = Date.now();
|
||||
return await ctx.db.insert("daemonCommands", {
|
||||
...args,
|
||||
status: "queued",
|
||||
attempts: 0,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const list = query({
|
||||
args: {
|
||||
daemonId: v.string(),
|
||||
status: v.optional(commandStatus),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
if (args.status) {
|
||||
return await ctx.db
|
||||
.query("daemonCommands")
|
||||
.withIndex("by_daemonId_and_status", (q) =>
|
||||
q.eq("daemonId", args.daemonId).eq("status", args.status!)
|
||||
)
|
||||
.order("desc")
|
||||
.take(100);
|
||||
}
|
||||
return await ctx.db
|
||||
.query("daemonCommands")
|
||||
.filter((q) => q.eq(q.field("daemonId"), args.daemonId))
|
||||
.order("desc")
|
||||
.take(100);
|
||||
},
|
||||
});
|
||||
export const get = query({
|
||||
args: { commandId: v.id("daemonCommands") },
|
||||
handler: async (ctx, args) =>
|
||||
await ctx.db.get("daemonCommands", args.commandId),
|
||||
});
|
||||
|
||||
export const available = query({
|
||||
args: { daemonId: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const queued = await ctx.db
|
||||
.query("daemonCommands")
|
||||
.withIndex("by_daemonId_and_status", (q) =>
|
||||
q.eq("daemonId", args.daemonId).eq("status", "queued")
|
||||
)
|
||||
.order("asc")
|
||||
.take(25);
|
||||
const expiredClaims = await ctx.db
|
||||
.query("daemonCommands")
|
||||
.withIndex("by_daemonId_and_status", (q) =>
|
||||
q.eq("daemonId", args.daemonId).eq("status", "claimed")
|
||||
)
|
||||
.filter((q) => q.lt(q.field("leaseExpiresAt"), Date.now()))
|
||||
.order("asc")
|
||||
.take(25);
|
||||
return [...expiredClaims, ...queued].slice(0, 25);
|
||||
},
|
||||
});
|
||||
|
||||
export const claim = mutation({
|
||||
args: {
|
||||
commandId: v.id("daemonCommands"),
|
||||
daemonId: v.string(),
|
||||
sessionId: v.string(),
|
||||
leaseDurationMs: v.number(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const command = await ctx.db.get("daemonCommands", args.commandId);
|
||||
if (!command || command.daemonId !== args.daemonId) {
|
||||
return null;
|
||||
}
|
||||
const timestamp = Date.now();
|
||||
const canClaim =
|
||||
command.status === "queued" ||
|
||||
(command.status === "claimed" &&
|
||||
command.leaseExpiresAt !== undefined &&
|
||||
command.leaseExpiresAt <= timestamp);
|
||||
if (!canClaim) {
|
||||
return null;
|
||||
}
|
||||
await ctx.db.patch("daemonCommands", command._id, {
|
||||
status: "claimed",
|
||||
claimedBySessionId: args.sessionId,
|
||||
leaseExpiresAt: timestamp + args.leaseDurationMs,
|
||||
attempts: command.attempts + 1,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
return { ...command, status: "claimed" as const };
|
||||
},
|
||||
});
|
||||
|
||||
export const start = mutation({
|
||||
args: {
|
||||
commandId: v.id("daemonCommands"),
|
||||
sessionId: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const command = await ctx.db.get("daemonCommands", args.commandId);
|
||||
if (
|
||||
!command ||
|
||||
command.status !== "claimed" ||
|
||||
command.claimedBySessionId !== args.sessionId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const timestamp = Date.now();
|
||||
await ctx.db.patch("daemonCommands", command._id, {
|
||||
status: "running",
|
||||
startedAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
export const succeed = mutation({
|
||||
args: {
|
||||
commandId: v.id("daemonCommands"),
|
||||
sessionId: v.string(),
|
||||
result: v.any(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const command = await ctx.db.get("daemonCommands", args.commandId);
|
||||
if (!command || command.claimedBySessionId !== args.sessionId) {
|
||||
return false;
|
||||
}
|
||||
const timestamp = Date.now();
|
||||
await ctx.db.patch("daemonCommands", command._id, {
|
||||
status: "succeeded",
|
||||
result: args.result,
|
||||
completedAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
leaseExpiresAt: undefined,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
export const fail = mutation({
|
||||
args: {
|
||||
commandId: v.id("daemonCommands"),
|
||||
sessionId: v.string(),
|
||||
error: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const command = await ctx.db.get("daemonCommands", args.commandId);
|
||||
if (!command || command.claimedBySessionId !== args.sessionId) {
|
||||
return false;
|
||||
}
|
||||
const timestamp = Date.now();
|
||||
await ctx.db.patch("daemonCommands", command._id, {
|
||||
status: "failed",
|
||||
error: args.error,
|
||||
completedAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
leaseExpiresAt: undefined,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
export const cancel = mutation({
|
||||
args: { commandId: v.id("daemonCommands") },
|
||||
handler: async (ctx, args) => {
|
||||
const command = await ctx.db.get("daemonCommands", args.commandId);
|
||||
if (
|
||||
!command ||
|
||||
["succeeded", "failed", "cancelled"].includes(command.status)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const timestamp = Date.now();
|
||||
await ctx.db.patch("daemonCommands", command._id, {
|
||||
status: "cancelled",
|
||||
completedAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
leaseExpiresAt: undefined,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
});
|
||||
@@ -1,107 +0,0 @@
|
||||
import { v } from "convex/values";
|
||||
|
||||
import { mutation, query } from "./_generated/server";
|
||||
|
||||
export const connect = mutation({
|
||||
args: {
|
||||
daemonId: v.string(),
|
||||
sessionId: v.string(),
|
||||
hostname: v.string(),
|
||||
platform: v.string(),
|
||||
architecture: v.string(),
|
||||
version: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const existing = await ctx.db
|
||||
.query("daemonPresence")
|
||||
.withIndex("by_sessionId", (q) => q.eq("sessionId", args.sessionId))
|
||||
.unique();
|
||||
const timestamp = Date.now();
|
||||
if (existing) {
|
||||
await ctx.db.patch("daemonPresence", existing._id, {
|
||||
status: "online",
|
||||
lastHeartbeatAt: timestamp,
|
||||
});
|
||||
return existing._id;
|
||||
}
|
||||
return await ctx.db.insert("daemonPresence", {
|
||||
...args,
|
||||
status: "online",
|
||||
startedAt: timestamp,
|
||||
lastHeartbeatAt: timestamp,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const heartbeat = mutation({
|
||||
args: {
|
||||
daemonId: v.string(),
|
||||
sessionId: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const presence = await ctx.db
|
||||
.query("daemonPresence")
|
||||
.withIndex("by_sessionId", (q) => q.eq("sessionId", args.sessionId))
|
||||
.unique();
|
||||
if (!presence || presence.daemonId !== args.daemonId) {
|
||||
return false;
|
||||
}
|
||||
await ctx.db.patch("daemonPresence", presence._id, {
|
||||
status: "online",
|
||||
lastHeartbeatAt: Date.now(),
|
||||
});
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
export const disconnect = mutation({
|
||||
args: {
|
||||
daemonId: v.string(),
|
||||
sessionId: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const presence = await ctx.db
|
||||
.query("daemonPresence")
|
||||
.withIndex("by_sessionId", (q) => q.eq("sessionId", args.sessionId))
|
||||
.unique();
|
||||
if (!presence || presence.daemonId !== args.daemonId) {
|
||||
return false;
|
||||
}
|
||||
await ctx.db.patch("daemonPresence", presence._id, {
|
||||
status: "offline",
|
||||
lastHeartbeatAt: Date.now(),
|
||||
});
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
export const recordEvent = mutation({
|
||||
args: {
|
||||
daemonId: v.string(),
|
||||
commandId: v.optional(v.id("daemonCommands")),
|
||||
kind: v.string(),
|
||||
data: v.any(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
return await ctx.db.insert("daemonCommandEvents", {
|
||||
...args,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const listEvents = query({
|
||||
args: {
|
||||
daemonId: v.string(),
|
||||
limit: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
return await ctx.db
|
||||
.query("daemonCommandEvents")
|
||||
.withIndex("by_daemonId_and_createdAt", (q) =>
|
||||
q.eq("daemonId", args.daemonId)
|
||||
)
|
||||
.order("desc")
|
||||
.take(Math.min(args.limit ?? 100, 500));
|
||||
},
|
||||
});
|
||||
@@ -1,60 +0,0 @@
|
||||
import { v } from "convex/values";
|
||||
|
||||
import { mutation, query } from "./_generated/server";
|
||||
|
||||
const now = () => Date.now();
|
||||
|
||||
export const upsert = mutation({
|
||||
args: {
|
||||
daemonId: v.string(),
|
||||
name: v.string(),
|
||||
desiredState: v.union(v.literal("online"), v.literal("offline")),
|
||||
agentOsConfig: v.any(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const existing = await ctx.db
|
||||
.query("daemonDefinitions")
|
||||
.withIndex("by_daemonId", (q) => q.eq("daemonId", args.daemonId))
|
||||
.unique();
|
||||
const timestamp = now();
|
||||
if (existing) {
|
||||
await ctx.db.patch("daemonDefinitions", existing._id, {
|
||||
name: args.name,
|
||||
desiredState: args.desiredState,
|
||||
agentOsConfig: args.agentOsConfig,
|
||||
configVersion: existing.configVersion + 1,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
return existing._id;
|
||||
}
|
||||
return await ctx.db.insert("daemonDefinitions", {
|
||||
...args,
|
||||
configVersion: 1,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const get = query({
|
||||
args: { daemonId: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const definition = await ctx.db
|
||||
.query("daemonDefinitions")
|
||||
.withIndex("by_daemonId", (q) => q.eq("daemonId", args.daemonId))
|
||||
.unique();
|
||||
const presence = await ctx.db
|
||||
.query("daemonPresence")
|
||||
.withIndex("by_daemonId", (q) => q.eq("daemonId", args.daemonId))
|
||||
.order("desc")
|
||||
.first();
|
||||
return { definition, presence };
|
||||
},
|
||||
});
|
||||
|
||||
export const list = query({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
return await ctx.db.query("daemonDefinitions").order("desc").collect();
|
||||
},
|
||||
});
|
||||
@@ -1,387 +0,0 @@
|
||||
import { type TestConvex, convexTest } from "convex-test";
|
||||
import { anyApi } from "convex/server";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { internal } from "./_generated/api";
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
import schema from "./schema";
|
||||
|
||||
declare global {
|
||||
interface ImportMeta {
|
||||
readonly glob: (pattern: string) => Record<string, () => Promise<unknown>>;
|
||||
}
|
||||
}
|
||||
|
||||
const modules = import.meta.glob("./**/*.ts");
|
||||
const api = anyApi;
|
||||
|
||||
const ID_A = "https://convex.test|user-a";
|
||||
const ID_B = "https://convex.test|user-b";
|
||||
const identityA = { tokenIdentifier: ID_A };
|
||||
const identityB = { tokenIdentifier: ID_B };
|
||||
|
||||
const newTest = () => convexTest({ schema, modules });
|
||||
|
||||
const ensureOrg = async (
|
||||
t: TestConvex<typeof schema>,
|
||||
identity: { readonly tokenIdentifier: string }
|
||||
) => {
|
||||
const org = await t
|
||||
.withIdentity(identity)
|
||||
.mutation(api.organizations.ensurePersonalOrganization, {});
|
||||
return org._id as Id<"organizations">;
|
||||
};
|
||||
|
||||
const problemStatement = {
|
||||
title: "Deploy fails on staging",
|
||||
summary: "The staging deploy command exits with a DNS resolution error.",
|
||||
desiredOutcome: "Staging deploys successfully without DNS errors.",
|
||||
constraints: ["Must not change the production pipeline"],
|
||||
};
|
||||
|
||||
const processedBy = { agentInstanceId: "zopu-instance-1", agentName: "zopu" };
|
||||
|
||||
const seedMessage = async (
|
||||
t: TestConvex<typeof schema>,
|
||||
identity: { readonly tokenIdentifier: string },
|
||||
orgId: Id<"organizations">,
|
||||
clientRequestId: string,
|
||||
rawText: string
|
||||
): Promise<string> => {
|
||||
const begun = await t
|
||||
.withIdentity(identity)
|
||||
.mutation(api.conversationMessages.beginUserMessage, {
|
||||
clientRequestId,
|
||||
organizationId: orgId,
|
||||
rawText,
|
||||
});
|
||||
await t
|
||||
.withIdentity(identity)
|
||||
.mutation(api.conversationMessages.markAdmitted, {
|
||||
clientRequestId,
|
||||
organizationId: orgId,
|
||||
submissionId: `sub-${clientRequestId}`,
|
||||
});
|
||||
return begun.messageId;
|
||||
};
|
||||
|
||||
const createProject = async (
|
||||
t: TestConvex<typeof schema>,
|
||||
ownerId: string,
|
||||
suffix: string
|
||||
): Promise<Id<"projects">> => {
|
||||
const ownerIdentity = { tokenIdentifier: ownerId };
|
||||
await t
|
||||
.withIdentity(ownerIdentity)
|
||||
.mutation(api.organizations.ensurePersonalOrganization);
|
||||
const outcome = await t
|
||||
.withIdentity(ownerIdentity)
|
||||
.mutation(internal.projects.persistPublicGitImport, {
|
||||
userId: ownerId,
|
||||
source: {
|
||||
host: "github.com",
|
||||
projectName: `test-repo-${suffix}`,
|
||||
repositoryPath: `owner-${suffix}/test-repo`,
|
||||
normalizedUrl: `https://github.com/owner-${suffix}/test-repo`,
|
||||
url: `https://github.com/owner-${suffix}/test-repo`,
|
||||
},
|
||||
remote: {
|
||||
defaultBranch: "main",
|
||||
documents: [
|
||||
{
|
||||
kind: "readme" as const,
|
||||
path: "README.md",
|
||||
content: "# test\n",
|
||||
},
|
||||
],
|
||||
warnings: [],
|
||||
},
|
||||
});
|
||||
return outcome.id as unknown as Id<"projects">;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers for creating signals and attachments via the DB directly (the
|
||||
// query under test is read-only; we seed through mutations/DB).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const createSignalInProject = async (
|
||||
t: TestConvex<typeof schema>,
|
||||
identity: { readonly tokenIdentifier: string },
|
||||
orgId: Id<"organizations">,
|
||||
projectId: Id<"projects">,
|
||||
msgSuffix: string,
|
||||
ps: typeof problemStatement
|
||||
): Promise<Id<"signals">> => {
|
||||
const messageId = await seedMessage(
|
||||
t,
|
||||
identity,
|
||||
orgId,
|
||||
`req-${msgSuffix}`,
|
||||
`evidence ${msgSuffix}`
|
||||
);
|
||||
const result = await t
|
||||
.withIdentity(identity)
|
||||
.mutation(api.signals.createFromMessages, {
|
||||
conversationId: orgId,
|
||||
messageIds: [messageId],
|
||||
organizationId: orgId,
|
||||
problemStatement: ps,
|
||||
projectId,
|
||||
processedBy,
|
||||
});
|
||||
return result.signalId;
|
||||
};
|
||||
|
||||
/** Insert a signalIssueAttachment directly via the test DB. */
|
||||
const linkSignalToIssue = async (
|
||||
t: TestConvex<typeof schema>,
|
||||
signalId: Id<"signals">,
|
||||
issueId: Id<"projectIssues">,
|
||||
organizationId: Id<"organizations">,
|
||||
projectId: Id<"projects">
|
||||
): Promise<void> => {
|
||||
await t.mutation((ctx) =>
|
||||
ctx.db.insert("signalIssueAttachments", {
|
||||
createdAt: Date.now(),
|
||||
issueId,
|
||||
organizationId,
|
||||
projectId,
|
||||
signalId,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const createIssue = async (
|
||||
t: TestConvex<typeof schema>,
|
||||
identity: { readonly tokenIdentifier: string },
|
||||
projectId: Id<"projects">,
|
||||
title: string
|
||||
): Promise<Id<"projectIssues">> =>
|
||||
t.withIdentity(identity).mutation(api.projectIssues.create, {
|
||||
body: "A representative issue body long enough to pass validation",
|
||||
projectId,
|
||||
title,
|
||||
});
|
||||
|
||||
describe("listLinkedSignalsForProject", () => {
|
||||
test("groups linked signals by issue with correct source counts", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t, identityA);
|
||||
const projectId = await createProject(t, ID_A, "group");
|
||||
|
||||
const issue1 = await createIssue(t, identityA, projectId, "Issue one");
|
||||
const issue2 = await createIssue(t, identityA, projectId, "Issue two");
|
||||
|
||||
// Signal 1: one source, linked to issue1
|
||||
const sig1 = await createSignalInProject(
|
||||
t,
|
||||
identityA,
|
||||
orgId,
|
||||
projectId,
|
||||
"s1",
|
||||
problemStatement
|
||||
);
|
||||
await linkSignalToIssue(t, sig1, issue1, orgId, projectId);
|
||||
|
||||
// Signal 2: one source, linked to issue2
|
||||
const sig2 = await createSignalInProject(
|
||||
t,
|
||||
identityA,
|
||||
orgId,
|
||||
projectId,
|
||||
"s2",
|
||||
{
|
||||
...problemStatement,
|
||||
title: "Different signal",
|
||||
}
|
||||
);
|
||||
await linkSignalToIssue(t, sig2, issue2, orgId, projectId);
|
||||
|
||||
const result = await t
|
||||
.withIdentity(identityA)
|
||||
.query(api.signals.listLinkedSignalsForProject, { projectId });
|
||||
|
||||
expect(Object.keys(result)).toHaveLength(2);
|
||||
expect(result[String(issue1)]).toHaveLength(1);
|
||||
expect(result[String(issue2)]).toHaveLength(1);
|
||||
expect(result[String(issue1)]![0]!.title).toBe("Deploy fails on staging");
|
||||
expect(result[String(issue2)]![0]!.title).toBe("Different signal");
|
||||
expect(result[String(issue1)]![0]!.sourceCount).toBe(1);
|
||||
});
|
||||
|
||||
test("multiple signals linked to one issue are returned newest first", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t, identityA);
|
||||
const projectId = await createProject(t, ID_A, "order");
|
||||
const issue = await createIssue(t, identityA, projectId, "Single issue");
|
||||
|
||||
const ps1 = { ...problemStatement, title: "First signal" };
|
||||
const ps2 = { ...problemStatement, title: "Second signal" };
|
||||
|
||||
const sig1 = await createSignalInProject(
|
||||
t,
|
||||
identityA,
|
||||
orgId,
|
||||
projectId,
|
||||
"order1",
|
||||
ps1
|
||||
);
|
||||
// Small delay so createdAt differs.
|
||||
await t.mutation(async (ctx) => {
|
||||
// Patch createdAt to be earlier.
|
||||
const s = await ctx.db.get(sig1);
|
||||
if (s) {
|
||||
await ctx.db.patch(sig1, { createdAt: s.createdAt - 1000 });
|
||||
}
|
||||
});
|
||||
const sig2 = await createSignalInProject(
|
||||
t,
|
||||
identityA,
|
||||
orgId,
|
||||
projectId,
|
||||
"order2",
|
||||
ps2
|
||||
);
|
||||
|
||||
await linkSignalToIssue(t, sig1, issue, orgId, projectId);
|
||||
await linkSignalToIssue(t, sig2, issue, orgId, projectId);
|
||||
|
||||
const result = await t
|
||||
.withIdentity(identityA)
|
||||
.query(api.signals.listLinkedSignalsForProject, { projectId });
|
||||
|
||||
const linked = result[String(issue)]!;
|
||||
expect(linked).toHaveLength(2);
|
||||
// Newest first: sig2 was created later.
|
||||
expect(linked[0]!.title).toBe("Second signal");
|
||||
expect(linked[1]!.title).toBe("First signal");
|
||||
});
|
||||
|
||||
test("requires project membership — denies non-member access", async () => {
|
||||
const t = newTest();
|
||||
const _orgA = await ensureOrg(t, identityA);
|
||||
const _orgB = await ensureOrg(t, identityB);
|
||||
const projectId = await createProject(t, ID_A, "auth");
|
||||
|
||||
// User B is NOT a member of org A / project.
|
||||
await expect(
|
||||
t
|
||||
.withIdentity(identityB)
|
||||
.query(api.signals.listLinkedSignalsForProject, { projectId })
|
||||
).rejects.toThrow(/Project not found|Organization membership required/u);
|
||||
});
|
||||
|
||||
test("does not return signals from a different project", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t, identityA);
|
||||
|
||||
// Two projects in the same org.
|
||||
const projectId1 = await createProject(t, ID_A, "p1");
|
||||
const projectId2 = await createProject(t, ID_A, "p2");
|
||||
|
||||
const issue1 = await createIssue(t, identityA, projectId1, "Issue P1");
|
||||
const issue2 = await createIssue(t, identityA, projectId2, "Issue P2");
|
||||
|
||||
// Signal linked to issue in project2.
|
||||
const sig = await createSignalInProject(
|
||||
t,
|
||||
identityA,
|
||||
orgId,
|
||||
projectId2,
|
||||
"xproj",
|
||||
problemStatement
|
||||
);
|
||||
await linkSignalToIssue(t, sig, issue2, orgId, projectId2);
|
||||
|
||||
// Querying project1 should NOT see project2's signal.
|
||||
const result1 = await t
|
||||
.withIdentity(identityA)
|
||||
.query(api.signals.listLinkedSignalsForProject, {
|
||||
projectId: projectId1,
|
||||
});
|
||||
expect(result1[String(issue1)] ?? []).toHaveLength(0);
|
||||
|
||||
// Querying project2 SHOULD see it.
|
||||
const result2 = await t
|
||||
.withIdentity(identityA)
|
||||
.query(api.signals.listLinkedSignalsForProject, {
|
||||
projectId: projectId2,
|
||||
});
|
||||
expect(result2[String(issue2)] ?? []).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("returns correct source count for multi-source signals", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t, identityA);
|
||||
const projectId = await createProject(t, ID_A, "sources");
|
||||
const issue = await createIssue(t, identityA, projectId, "Multi-source");
|
||||
|
||||
// Create a signal with two sources.
|
||||
const m1 = await seedMessage(t, identityA, orgId, "ms-1", "evidence a");
|
||||
const m2 = await seedMessage(t, identityA, orgId, "ms-2", "evidence b");
|
||||
const result = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.signals.createFromMessages, {
|
||||
conversationId: orgId,
|
||||
messageIds: [m1, m2],
|
||||
organizationId: orgId,
|
||||
problemStatement,
|
||||
projectId,
|
||||
processedBy,
|
||||
});
|
||||
await linkSignalToIssue(t, result.signalId, issue, orgId, projectId);
|
||||
|
||||
const linked = await t
|
||||
.withIdentity(identityA)
|
||||
.query(api.signals.listLinkedSignalsForProject, { projectId });
|
||||
|
||||
const signals = linked[String(issue)]!;
|
||||
expect(signals).toHaveLength(1);
|
||||
expect(signals[0]!.sourceCount).toBe(2);
|
||||
});
|
||||
|
||||
test("returns empty record for a project with no attachments", async () => {
|
||||
const t = newTest();
|
||||
const _orgId = await ensureOrg(t, identityA);
|
||||
const projectId = await createProject(t, ID_A, "empty");
|
||||
await createIssue(t, identityA, projectId, "Orphan issue");
|
||||
|
||||
const result = await t
|
||||
.withIdentity(identityA)
|
||||
.query(api.signals.listLinkedSignalsForProject, { projectId });
|
||||
|
||||
expect(Object.keys(result)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("issue with no linked signals is omitted from the result", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t, identityA);
|
||||
const projectId = await createProject(t, ID_A, "mixed");
|
||||
const issueWith = await createIssue(t, identityA, projectId, "Has signal");
|
||||
const issueWithout = await createIssue(
|
||||
t,
|
||||
identityA,
|
||||
projectId,
|
||||
"No signal"
|
||||
);
|
||||
|
||||
const sig = await createSignalInProject(
|
||||
t,
|
||||
identityA,
|
||||
orgId,
|
||||
projectId,
|
||||
"mixed1",
|
||||
problemStatement
|
||||
);
|
||||
await linkSignalToIssue(t, sig, issueWith, orgId, projectId);
|
||||
|
||||
const result = await t
|
||||
.withIdentity(identityA)
|
||||
.query(api.signals.listLinkedSignalsForProject, { projectId });
|
||||
|
||||
expect(Object.keys(result)).toHaveLength(1);
|
||||
expect(result[String(issueWith)]).toHaveLength(1);
|
||||
expect(result[String(issueWithout)]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,19 +0,0 @@
|
||||
import { v } from "convex/values";
|
||||
|
||||
import { query } from "./_generated/server";
|
||||
import { requireProjectMember } from "./authz";
|
||||
|
||||
export const list = query({
|
||||
args: { projectId: v.id("projects") },
|
||||
handler: async (ctx, args) => {
|
||||
try {
|
||||
await requireProjectMember(ctx, args.projectId);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return await ctx.db
|
||||
.query("projectArtifacts")
|
||||
.withIndex("by_project", (q) => q.eq("projectId", args.projectId))
|
||||
.take(25);
|
||||
},
|
||||
});
|
||||
@@ -1,285 +0,0 @@
|
||||
import { env } from "@code/env/convex";
|
||||
import { type TestConvex, convexTest } from "convex-test";
|
||||
import { anyApi } from "convex/server";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { internal } from "./_generated/api";
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
import schema from "./schema";
|
||||
|
||||
declare global {
|
||||
interface ImportMeta {
|
||||
readonly glob: (pattern: string) => Record<string, () => Promise<unknown>>;
|
||||
}
|
||||
}
|
||||
|
||||
const modules = import.meta.glob("./**/*.ts");
|
||||
const api = anyApi;
|
||||
|
||||
const FLUE_DB_TOKEN = env.FLUE_DB_TOKEN;
|
||||
|
||||
const ID_A = "https://convex.test|user-a";
|
||||
const identityA = { tokenIdentifier: ID_A };
|
||||
|
||||
const SOURCE = {
|
||||
host: "github.com",
|
||||
projectName: "zopu",
|
||||
repositoryPath: "puter/zopu",
|
||||
normalizedUrl: "https://github.com/puter/zopu",
|
||||
url: "https://github.com/puter/zopu",
|
||||
};
|
||||
|
||||
const REMOTE = {
|
||||
defaultBranch: "main",
|
||||
documents: [
|
||||
{
|
||||
kind: "readme" as const,
|
||||
path: "README.md",
|
||||
content: "# zopu\n\nRepository: https://github.com/puter/zopu\n",
|
||||
},
|
||||
],
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
const createTestProject = async (
|
||||
t: TestConvex<typeof schema>
|
||||
): Promise<Id<"projects">> => {
|
||||
// Ensure personal organization for the user
|
||||
await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.organizations.ensurePersonalOrganization);
|
||||
// Persist the public Git import
|
||||
const outcome = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(internal.projects.persistPublicGitImport, {
|
||||
userId: ID_A,
|
||||
source: SOURCE,
|
||||
remote: REMOTE,
|
||||
});
|
||||
return outcome.id as unknown as Id<"projects">;
|
||||
};
|
||||
|
||||
const eventsForProject = async (
|
||||
t: TestConvex<typeof schema>,
|
||||
projectId: Id<"projects">
|
||||
) => {
|
||||
return await t.query((ctx) =>
|
||||
ctx.db
|
||||
.query("projectEvents")
|
||||
.withIndex("by_project_and_createdAt", (q) =>
|
||||
q.eq("projectId", projectId)
|
||||
)
|
||||
.collect()
|
||||
);
|
||||
};
|
||||
|
||||
describe("projectEvents", () => {
|
||||
test("project connect emits a project.connected event", async () => {
|
||||
const t = convexTest({ schema, modules });
|
||||
const projectId = await createTestProject(t);
|
||||
|
||||
const events = await eventsForProject(t, projectId);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].kind).toBe("project.connected");
|
||||
expect(events[0].projectId).toBe(projectId);
|
||||
expect(events[0].issueId).toBeUndefined();
|
||||
expect(events[0].runId).toBeUndefined();
|
||||
});
|
||||
|
||||
test("issue create and begin emit issue.created then issue.queued events", async () => {
|
||||
const t = convexTest({ schema, modules });
|
||||
const projectId = await createTestProject(t);
|
||||
|
||||
const issueId = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.projectIssues.create, {
|
||||
body: "A representative issue body long enough to pass validation",
|
||||
projectId,
|
||||
title: "Representative issue",
|
||||
});
|
||||
|
||||
const afterCreate = await eventsForProject(t, projectId);
|
||||
expect(afterCreate.map((e) => e.kind)).toEqual([
|
||||
"project.connected",
|
||||
"issue.created",
|
||||
]);
|
||||
const created = afterCreate[1];
|
||||
expect(created.issueId).toBe(issueId);
|
||||
expect(created.data).toMatchObject({
|
||||
number: 1,
|
||||
title: "Representative issue",
|
||||
});
|
||||
|
||||
const status = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.projectIssues.begin, {
|
||||
issueId,
|
||||
});
|
||||
expect(status).toBe("queued");
|
||||
|
||||
const afterBegin = await eventsForProject(t, projectId);
|
||||
expect(afterBegin.map((e) => e.kind)).toEqual([
|
||||
"project.connected",
|
||||
"issue.created",
|
||||
"issue.queued",
|
||||
]);
|
||||
expect(afterBegin[2].issueId).toBe(issueId);
|
||||
expect(afterBegin[2].data).toMatchObject({ number: 1 });
|
||||
});
|
||||
|
||||
test("artifact update emits an artifact.updated event", async () => {
|
||||
const t = convexTest({ schema, modules });
|
||||
const projectId = await createTestProject(t);
|
||||
const issueId = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.projectIssues.create, {
|
||||
body: "A representative issue body long enough to pass validation",
|
||||
projectId,
|
||||
title: "Representative issue",
|
||||
});
|
||||
|
||||
const revision = await t.mutation(api.agentWorkspace.updateArtifact, {
|
||||
content: "# Updated work\n\nRevised content.",
|
||||
issueId,
|
||||
path: "work.md",
|
||||
token: FLUE_DB_TOKEN,
|
||||
});
|
||||
|
||||
expect(revision).toBe(3);
|
||||
|
||||
const events = await eventsForProject(t, projectId);
|
||||
const artifactEvent = events.find((e) => e.kind === "artifact.updated");
|
||||
expect(artifactEvent).toBeDefined();
|
||||
expect(artifactEvent?.issueId).toBe(issueId);
|
||||
expect(artifactEvent?.data).toMatchObject({ path: "work.md", revision: 3 });
|
||||
});
|
||||
|
||||
test("agent status emits an agent.<status> event linked to the run", async () => {
|
||||
const t = convexTest({ schema, modules });
|
||||
const projectId = await createTestProject(t);
|
||||
const issueId = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.projectIssues.create, {
|
||||
body: "A representative issue body long enough to pass validation",
|
||||
projectId,
|
||||
title: "Representative issue",
|
||||
});
|
||||
await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.projectIssues.begin, { issueId });
|
||||
|
||||
const run = await t.mutation(api.agentWorkspace.ensureRun, {
|
||||
daemonId: "daemon-1",
|
||||
issueId,
|
||||
token: FLUE_DB_TOKEN,
|
||||
});
|
||||
expect(run).toMatchObject({
|
||||
baseBranch: "main",
|
||||
checkoutPath: "/workspace/repository",
|
||||
sourceUrl: SOURCE.url,
|
||||
});
|
||||
expect(run?.branchName).toMatch(/^work\/issue-1-/u);
|
||||
await t.mutation(api.agentWorkspace.setStatus, {
|
||||
issueId,
|
||||
status: "completed",
|
||||
summary: "Done.",
|
||||
token: FLUE_DB_TOKEN,
|
||||
});
|
||||
|
||||
const events = await eventsForProject(t, projectId);
|
||||
const workspaceEvent = events.find(
|
||||
(e) => e.kind === "agent.workspace.created"
|
||||
);
|
||||
expect(workspaceEvent).toBeDefined();
|
||||
expect(workspaceEvent?.runId).toBeDefined();
|
||||
|
||||
const statusEvent = events.find((e) => e.kind === "agent.completed");
|
||||
expect(statusEvent).toBeDefined();
|
||||
expect(statusEvent?.issueId).toBe(issueId);
|
||||
expect(statusEvent?.runId).toBe(workspaceEvent?.runId);
|
||||
expect(statusEvent?.data).toMatchObject({ summary: "Done." });
|
||||
});
|
||||
|
||||
test("wrong agent token is rejected before any event is written", async () => {
|
||||
const t = convexTest({ schema, modules });
|
||||
const projectId = await createTestProject(t);
|
||||
const issueId = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.projectIssues.create, {
|
||||
body: "A representative issue body long enough to pass validation",
|
||||
projectId,
|
||||
title: "Representative issue",
|
||||
});
|
||||
|
||||
await expect(
|
||||
t.mutation(api.agentWorkspace.setStatus, {
|
||||
issueId,
|
||||
status: "completed",
|
||||
summary: "Done.",
|
||||
token: "wrong-token",
|
||||
})
|
||||
).rejects.toThrow(/Invalid agent control token/u);
|
||||
|
||||
const events = await eventsForProject(t, projectId);
|
||||
expect(events.some((e) => e.kind === "agent.completed")).toBe(false);
|
||||
});
|
||||
|
||||
test("Gitea lifecycle persists PR metadata in an event and artifact", async () => {
|
||||
const t = convexTest({ schema, modules });
|
||||
const projectId = await createTestProject(t);
|
||||
const issueId = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.projectIssues.create, {
|
||||
body: "A representative issue body long enough to pass validation",
|
||||
projectId,
|
||||
title: "Representative issue",
|
||||
});
|
||||
await t.mutation(api.agentWorkspace.ensureRun, {
|
||||
daemonId: "daemon-1",
|
||||
issueId,
|
||||
token: FLUE_DB_TOKEN,
|
||||
});
|
||||
|
||||
await t.mutation(api.agentWorkspace.recordGiteaLifecycle, {
|
||||
baseBranch: "main",
|
||||
branch: "work/issue-1",
|
||||
commitSha: "abc123",
|
||||
issueId,
|
||||
pullRequest: {
|
||||
baseBranch: "main",
|
||||
branch: "work/issue-1",
|
||||
number: 5,
|
||||
status: "open",
|
||||
url: "https://git.openputer.com/puter/zopu-code/pulls/5",
|
||||
},
|
||||
status: "pull_request_open",
|
||||
token: FLUE_DB_TOKEN,
|
||||
});
|
||||
|
||||
const events = await eventsForProject(t, projectId);
|
||||
const event = events.find(
|
||||
(item) => item.kind === "gitea.pull_request.created"
|
||||
);
|
||||
expect(event?.data).toMatchObject({
|
||||
branch: "work/issue-1",
|
||||
commitSha: "abc123",
|
||||
pullRequest: {
|
||||
number: 5,
|
||||
status: "open",
|
||||
},
|
||||
status: "pull_request_open",
|
||||
});
|
||||
|
||||
const artifacts = await t.query((ctx) =>
|
||||
ctx.db
|
||||
.query("projectArtifacts")
|
||||
.withIndex("by_project_and_path", (q) =>
|
||||
q.eq("projectId", projectId).eq("path", "artifacts.md")
|
||||
)
|
||||
.unique()
|
||||
);
|
||||
expect(artifacts?.content).toContain(
|
||||
"https://git.openputer.com/puter/zopu-code/pulls/5"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,177 +0,0 @@
|
||||
import { type TestConvex, convexTest } from "convex-test";
|
||||
import { anyApi } from "convex/server";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { internal } from "./_generated/api";
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
import schema from "./schema";
|
||||
|
||||
declare global {
|
||||
interface ImportMeta {
|
||||
readonly glob: (pattern: string) => Record<string, () => Promise<unknown>>;
|
||||
}
|
||||
}
|
||||
|
||||
const modules = import.meta.glob("./**/*.ts");
|
||||
const api = anyApi;
|
||||
|
||||
const ID_A = "https://convex.test|project-request-a";
|
||||
const ID_B = "https://convex.test|project-request-b";
|
||||
const identityA = { tokenIdentifier: ID_A };
|
||||
const identityB = { tokenIdentifier: ID_B };
|
||||
|
||||
const createProject = async (
|
||||
t: TestConvex<typeof schema>
|
||||
): Promise<Id<"projects">> => {
|
||||
await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.organizations.ensurePersonalOrganization, {});
|
||||
const outcome = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(internal.projects.persistPublicGitImport, {
|
||||
userId: ID_A,
|
||||
source: {
|
||||
host: "github.com",
|
||||
normalizedUrl: "https://github.com/test/project-request",
|
||||
projectName: "project-request",
|
||||
repositoryPath: "test/project-request",
|
||||
url: "https://github.com/test/project-request",
|
||||
},
|
||||
remote: {
|
||||
defaultBranch: "main",
|
||||
documents: [
|
||||
{
|
||||
content: "# Project Request\n",
|
||||
kind: "readme" as const,
|
||||
path: "README.md",
|
||||
},
|
||||
],
|
||||
warnings: [],
|
||||
},
|
||||
});
|
||||
return outcome.id as unknown as Id<"projects">;
|
||||
};
|
||||
|
||||
describe("project issue request smoke", () => {
|
||||
test("project Signal becomes a queued project issue with durable evidence", async () => {
|
||||
const t = convexTest({ schema, modules });
|
||||
const projectId = await createProject(t);
|
||||
const organization = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.organizations.ensurePersonalOrganization, {});
|
||||
const begun = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.conversationMessages.beginUserMessage, {
|
||||
clientRequestId: "project-request-1",
|
||||
organizationId: organization._id,
|
||||
rawText: "Staging deploys fail during DNS resolution.",
|
||||
});
|
||||
await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.conversationMessages.markAdmitted, {
|
||||
clientRequestId: "project-request-1",
|
||||
organizationId: organization._id,
|
||||
submissionId: "submission-project-request-1",
|
||||
});
|
||||
const signal = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.signals.createFromMessages, {
|
||||
conversationId: organization._id,
|
||||
messageIds: [begun.messageId],
|
||||
organizationId: organization._id,
|
||||
problemStatement: {
|
||||
constraints: ["Do not change production"],
|
||||
desiredOutcome: "Staging deploys successfully",
|
||||
summary: "The staging deploy fails during DNS resolution.",
|
||||
title: "Fix the staging deploy DNS failure",
|
||||
},
|
||||
processedBy: {
|
||||
agentInstanceId: String(organization._id),
|
||||
agentName: "zopu",
|
||||
},
|
||||
projectId,
|
||||
});
|
||||
|
||||
const created = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.projectIssues.createFromSignal, {
|
||||
signalId: signal.signalId,
|
||||
});
|
||||
const issue = await t.run(async (ctx) => ctx.db.get(created.issueId));
|
||||
|
||||
expect(issue).toMatchObject({
|
||||
body: expect.stringContaining("Source Signal"),
|
||||
projectId,
|
||||
status: "open",
|
||||
title: "Fix the staging deploy DNS failure",
|
||||
});
|
||||
|
||||
const status = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.projectIssues.begin, { issueId: created.issueId });
|
||||
expect(status).toBe("queued");
|
||||
|
||||
const events = await t.run(async (ctx) =>
|
||||
ctx.db
|
||||
.query("projectEvents")
|
||||
.withIndex("by_project_and_createdAt", (q) =>
|
||||
q.eq("projectId", projectId)
|
||||
)
|
||||
.collect()
|
||||
);
|
||||
expect(events.map((event) => event.kind)).toEqual([
|
||||
"project.connected",
|
||||
"issue.created",
|
||||
"issue.queued",
|
||||
]);
|
||||
});
|
||||
|
||||
test("a different organization cannot consume the project Signal", async () => {
|
||||
const t = convexTest({ schema, modules });
|
||||
const projectId = await createProject(t);
|
||||
await t
|
||||
.withIdentity(identityB)
|
||||
.mutation(api.organizations.ensurePersonalOrganization, {});
|
||||
const organization = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.organizations.ensurePersonalOrganization, {});
|
||||
const begun = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.conversationMessages.beginUserMessage, {
|
||||
clientRequestId: "project-request-cross-org",
|
||||
organizationId: organization._id,
|
||||
rawText: "Private project request",
|
||||
});
|
||||
await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.conversationMessages.markAdmitted, {
|
||||
clientRequestId: "project-request-cross-org",
|
||||
organizationId: organization._id,
|
||||
submissionId: "submission-project-request-cross-org",
|
||||
});
|
||||
const signal = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.signals.createFromMessages, {
|
||||
conversationId: organization._id,
|
||||
messageIds: [begun.messageId],
|
||||
organizationId: organization._id,
|
||||
problemStatement: {
|
||||
constraints: [],
|
||||
desiredOutcome: "Keep the request private",
|
||||
summary: "This request belongs to the first organization.",
|
||||
title: "Private project request",
|
||||
},
|
||||
processedBy: {
|
||||
agentInstanceId: String(organization._id),
|
||||
agentName: "zopu",
|
||||
},
|
||||
projectId,
|
||||
});
|
||||
|
||||
await expect(
|
||||
t.withIdentity(identityB).mutation(api.projectIssues.createFromSignal, {
|
||||
signalId: signal.signalId,
|
||||
})
|
||||
).rejects.toThrow(/membership required/u);
|
||||
});
|
||||
});
|
||||
@@ -1,223 +0,0 @@
|
||||
import {
|
||||
projectIssueDraftFromSignal,
|
||||
queueProjectIssue,
|
||||
validateProjectIssueDraft,
|
||||
type ProjectIssueDraft,
|
||||
} from "@code/primitives/project-issue";
|
||||
import { ConvexError, v } from "convex/values";
|
||||
import { Effect } from "effect";
|
||||
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
import { mutation, type MutationCtx, query } from "./_generated/server";
|
||||
import { requireProjectMember } from "./authz";
|
||||
|
||||
const toDomainError = (error: unknown) => {
|
||||
return new ConvexError(
|
||||
error instanceof Error ? error.message : "Invalid project issue"
|
||||
);
|
||||
};
|
||||
|
||||
const decodeDraft = async (input: unknown): Promise<ProjectIssueDraft> => {
|
||||
try {
|
||||
return await Effect.runPromise(validateProjectIssueDraft(input));
|
||||
} catch (error) {
|
||||
throw toDomainError(error);
|
||||
}
|
||||
};
|
||||
|
||||
const draftFromSignal = async (input: unknown): Promise<ProjectIssueDraft> => {
|
||||
try {
|
||||
return await Effect.runPromise(projectIssueDraftFromSignal(input));
|
||||
} catch (error) {
|
||||
throw toDomainError(error);
|
||||
}
|
||||
};
|
||||
|
||||
const insertIssue = async (
|
||||
ctx: MutationCtx,
|
||||
projectId: Id<"projects">,
|
||||
draft: ProjectIssueDraft
|
||||
): Promise<Id<"projectIssues">> => {
|
||||
const latest = await ctx.db
|
||||
.query("projectIssues")
|
||||
.withIndex("by_project_and_number", (q) => q.eq("projectId", projectId))
|
||||
.order("desc")
|
||||
.first();
|
||||
const timestamp = Date.now();
|
||||
const number = (latest?.number ?? 0) + 1;
|
||||
const issueId = await ctx.db.insert("projectIssues", {
|
||||
body: draft.body,
|
||||
createdAt: timestamp,
|
||||
number,
|
||||
projectId,
|
||||
status: "open",
|
||||
title: draft.title,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
const workArtifact = await ctx.db
|
||||
.query("projectArtifacts")
|
||||
.withIndex("by_project_and_path", (q) =>
|
||||
q.eq("projectId", projectId).eq("path", "work.md")
|
||||
)
|
||||
.unique();
|
||||
if (workArtifact) {
|
||||
await ctx.db.patch("projectArtifacts", workArtifact._id, {
|
||||
content: `${workArtifact.content}\n## Issue ${number}: ${draft.title}\n\nStatus: open\n\n${draft.body}\n`,
|
||||
revision: workArtifact.revision + 1,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
}
|
||||
await ctx.db.insert("projectEvents", {
|
||||
createdAt: timestamp,
|
||||
data: { number, title: draft.title },
|
||||
issueId,
|
||||
kind: "issue.created",
|
||||
projectId,
|
||||
});
|
||||
return issueId;
|
||||
};
|
||||
|
||||
export const create = mutation({
|
||||
args: {
|
||||
body: v.string(),
|
||||
projectId: v.id("projects"),
|
||||
title: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
await requireProjectMember(ctx, args.projectId);
|
||||
const draft = await decodeDraft({ body: args.body, title: args.title });
|
||||
return insertIssue(ctx, args.projectId, draft);
|
||||
},
|
||||
});
|
||||
|
||||
export const createFromSignal = mutation({
|
||||
args: { signalId: v.id("signals") },
|
||||
handler: async (ctx, args) => {
|
||||
const signal = await ctx.db.get("signals", args.signalId);
|
||||
if (!signal) {
|
||||
throw new ConvexError("Signal not found");
|
||||
}
|
||||
if (!signal.projectId) {
|
||||
throw new ConvexError("Signal is not project-scoped");
|
||||
}
|
||||
const { organizationId } = await requireProjectMember(
|
||||
ctx,
|
||||
signal.projectId
|
||||
);
|
||||
if (signal.organizationId !== organizationId) {
|
||||
throw new ConvexError(
|
||||
"Signal does not belong to the project organization"
|
||||
);
|
||||
}
|
||||
const draft = await draftFromSignal({
|
||||
problemStatement: signal.problemStatement,
|
||||
signalId: String(signal._id),
|
||||
});
|
||||
const issueId = await insertIssue(ctx, signal.projectId, draft);
|
||||
return { issueId, projectId: signal.projectId };
|
||||
},
|
||||
});
|
||||
|
||||
export const getForDispatch = query({
|
||||
args: { issueId: v.id("projectIssues") },
|
||||
handler: async (ctx, args) => {
|
||||
const issue = await ctx.db.get("projectIssues", args.issueId);
|
||||
if (!issue) {
|
||||
return null;
|
||||
}
|
||||
await requireProjectMember(ctx, issue.projectId);
|
||||
return { issueId: issue._id, projectId: issue.projectId };
|
||||
},
|
||||
});
|
||||
|
||||
export const begin = mutation({
|
||||
args: { issueId: v.id("projectIssues") },
|
||||
handler: async (ctx, args) => {
|
||||
const issue = await ctx.db.get("projectIssues", args.issueId);
|
||||
if (!issue) {
|
||||
throw new ConvexError("Issue not found");
|
||||
}
|
||||
await requireProjectMember(ctx, issue.projectId);
|
||||
let status: "queued" | "working";
|
||||
try {
|
||||
status = await Effect.runPromise(queueProjectIssue(issue.status));
|
||||
} catch (error) {
|
||||
throw toDomainError(error);
|
||||
}
|
||||
if (status === issue.status) {
|
||||
return status;
|
||||
}
|
||||
const timestamp = Date.now();
|
||||
await ctx.db.patch("projectIssues", issue._id, {
|
||||
status,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
await ctx.db.insert("projectEvents", {
|
||||
createdAt: timestamp,
|
||||
data: { number: issue.number },
|
||||
issueId: issue._id,
|
||||
kind: "issue.queued",
|
||||
projectId: issue.projectId,
|
||||
});
|
||||
return "queued" as const;
|
||||
},
|
||||
});
|
||||
|
||||
export const markDispatchFailed = mutation({
|
||||
args: { error: v.string(), issueId: v.id("projectIssues") },
|
||||
handler: async (ctx, args) => {
|
||||
const issue = await ctx.db.get("projectIssues", args.issueId);
|
||||
if (!issue) {
|
||||
throw new ConvexError("Issue not found");
|
||||
}
|
||||
await requireProjectMember(ctx, issue.projectId);
|
||||
const timestamp = Date.now();
|
||||
await ctx.db.patch("projectIssues", issue._id, {
|
||||
status: "failed",
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
await ctx.db.insert("projectEvents", {
|
||||
createdAt: timestamp,
|
||||
data: { error: args.error.slice(0, 1000), phase: "dispatch" },
|
||||
issueId: issue._id,
|
||||
kind: "agent.failed",
|
||||
projectId: issue.projectId,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const list = query({
|
||||
args: { projectId: v.id("projects") },
|
||||
handler: async (ctx, args) => {
|
||||
try {
|
||||
await requireProjectMember(ctx, args.projectId);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return await ctx.db
|
||||
.query("projectIssues")
|
||||
.withIndex("by_project_and_number", (q) =>
|
||||
q.eq("projectId", args.projectId)
|
||||
)
|
||||
.order("desc")
|
||||
.take(100);
|
||||
},
|
||||
});
|
||||
|
||||
export const events = query({
|
||||
args: { projectId: v.id("projects") },
|
||||
handler: async (ctx, args) => {
|
||||
try {
|
||||
await requireProjectMember(ctx, args.projectId);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return await ctx.db
|
||||
.query("projectEvents")
|
||||
.withIndex("by_project_and_createdAt", (q) =>
|
||||
q.eq("projectId", args.projectId)
|
||||
)
|
||||
.order("desc")
|
||||
.take(100);
|
||||
},
|
||||
});
|
||||
@@ -1,430 +0,0 @@
|
||||
import {
|
||||
CONTEXT_KINDS,
|
||||
contextPathForKind,
|
||||
decideContextWrite,
|
||||
makeInitialContext,
|
||||
type ContextDocumentState,
|
||||
type ContextKind,
|
||||
type ContextWrite,
|
||||
type PreparedPublicGitSource,
|
||||
type ProjectImportOutcome,
|
||||
type ProjectView,
|
||||
type PublicGitImportResult,
|
||||
} from "@code/primitives/project";
|
||||
|
||||
import type { Doc, Id } from "./_generated/dataModel";
|
||||
import type { ActionCtx, MutationCtx, QueryCtx } from "./_generated/server";
|
||||
import { requireCurrentOrganization, requireProjectMember } from "./authz";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// View mappers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SourceRow extends Doc<"projectSources"> {}
|
||||
|
||||
const toContextDocumentState = (
|
||||
doc: Doc<"projectContextDocuments">
|
||||
): ContextDocumentState => ({
|
||||
content: doc.content,
|
||||
kind: doc.kind,
|
||||
origin: doc.origin,
|
||||
path: doc.path,
|
||||
revision: doc.revision,
|
||||
sourceUrl: doc.sourceUrl ?? undefined,
|
||||
});
|
||||
|
||||
const toProjectView = async (
|
||||
ctx: QueryCtx,
|
||||
project: Doc<"projects">
|
||||
): Promise<ProjectView> => {
|
||||
const [sources, contextDocs] = await Promise.all([
|
||||
ctx.db
|
||||
.query("projectSources")
|
||||
.withIndex("by_project", (q) => q.eq("projectId", project._id))
|
||||
.collect(),
|
||||
ctx.db
|
||||
.query("projectContextDocuments")
|
||||
.withIndex("by_project", (q) => q.eq("projectId", project._id))
|
||||
.collect(),
|
||||
]);
|
||||
|
||||
return {
|
||||
contextDocuments: contextDocs.map(toContextDocumentState),
|
||||
createdAt: project.createdAt as never,
|
||||
id: project._id as never,
|
||||
name: project.name,
|
||||
organizationId: project.organizationId as never,
|
||||
sources: sources.map((s) => ({
|
||||
createdAt: s.createdAt as never,
|
||||
defaultBranch: s.defaultBranch,
|
||||
host: s.host,
|
||||
kind: "git" as const,
|
||||
normalizedUrl: s.normalizedUrl,
|
||||
projectId: s.projectId as never,
|
||||
repositoryPath: s.repositoryPath,
|
||||
updatedAt: s.updatedAt as never,
|
||||
url: s.url,
|
||||
})),
|
||||
updatedAt: project.updatedAt as never,
|
||||
};
|
||||
};
|
||||
|
||||
const toImportOutcome = async (
|
||||
ctx: QueryCtx,
|
||||
project: Doc<"projects">,
|
||||
source: SourceRow
|
||||
): Promise<ProjectImportOutcome> => {
|
||||
const contextDocs = await ctx.db
|
||||
.query("projectContextDocuments")
|
||||
.withIndex("by_project", (q) => q.eq("projectId", project._id))
|
||||
.collect();
|
||||
|
||||
return {
|
||||
contextDocuments: contextDocs.map(toContextDocumentState),
|
||||
createdAt: project.createdAt as never,
|
||||
id: project._id as never,
|
||||
name: project.name,
|
||||
organizationId: project.organizationId as never,
|
||||
source: {
|
||||
createdAt: source.createdAt as never,
|
||||
defaultBranch: source.defaultBranch,
|
||||
host: source.host,
|
||||
kind: "git" as const,
|
||||
normalizedUrl: source.normalizedUrl,
|
||||
projectId: source.projectId as never,
|
||||
repositoryPath: source.repositoryPath,
|
||||
updatedAt: source.updatedAt as never,
|
||||
url: source.url,
|
||||
},
|
||||
updatedAt: project.updatedAt as never,
|
||||
};
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Query store — read-only ctx.db access
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const makeProjectQueryStore = (ctx: QueryCtx) => ({
|
||||
listProjects: async (userId: string): Promise<ProjectView[]> => {
|
||||
const { organizationId } = await resolveOrgForUser(ctx, userId);
|
||||
const projects = await ctx.db
|
||||
.query("projects")
|
||||
.withIndex("by_organization_and_createdAt", (q) =>
|
||||
q.eq("organizationId", organizationId)
|
||||
)
|
||||
.order("desc")
|
||||
.take(50);
|
||||
return Promise.all(projects.map((p) => toProjectView(ctx, p)));
|
||||
},
|
||||
|
||||
getProject: async (
|
||||
userId: string,
|
||||
projectId: Id<"projects">
|
||||
): Promise<ProjectView | null> => {
|
||||
const { organizationId } = await resolveOrgForUser(ctx, userId);
|
||||
const project = await ctx.db.get(projectId);
|
||||
if (!project || project.organizationId !== organizationId) {
|
||||
return null;
|
||||
}
|
||||
return toProjectView(ctx, project);
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mutation store — transactional ctx.db writes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const makeProjectMutationStore = (ctx: MutationCtx) => ({
|
||||
putContext: async (
|
||||
userId: string,
|
||||
projectId: Id<"projects">,
|
||||
write: ContextWrite
|
||||
): Promise<{ revision: number }> => {
|
||||
const { organizationId } = await resolveProjectOrg(ctx, projectId, userId);
|
||||
const path = contextPathForKind(write.kind);
|
||||
const existing = await ctx.db
|
||||
.query("projectContextDocuments")
|
||||
.withIndex("by_project_and_path", (q) =>
|
||||
q.eq("projectId", projectId).eq("path", path)
|
||||
)
|
||||
.unique();
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
decideContextWrite({
|
||||
existing: existing ? toContextDocumentState(existing) : undefined,
|
||||
write,
|
||||
})
|
||||
);
|
||||
const now = Date.now();
|
||||
|
||||
if (!result.changed) {
|
||||
return { revision: result.document.revision };
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
await ctx.db.patch(existing._id, {
|
||||
content: result.document.content,
|
||||
origin: result.document.origin,
|
||||
revision: result.document.revision,
|
||||
sourceUrl: result.document.sourceUrl,
|
||||
updatedAt: now,
|
||||
});
|
||||
} else {
|
||||
await ctx.db.insert("projectContextDocuments", {
|
||||
content: result.document.content,
|
||||
createdAt: now,
|
||||
kind: result.document.kind,
|
||||
organizationId,
|
||||
origin: result.document.origin,
|
||||
path: result.document.path,
|
||||
projectId,
|
||||
revision: result.document.revision,
|
||||
sourceUrl: result.document.sourceUrl,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
return { revision: result.document.revision };
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Action store — calls narrowly scoped internal mutations/queries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const makeProjectActionStore = (_ctx: ActionCtx) => ({
|
||||
getCurrentOrganization: async (userId: string) => {
|
||||
return userId;
|
||||
},
|
||||
|
||||
listProjects: async (userId: string) => {
|
||||
return userId;
|
||||
},
|
||||
|
||||
getProject: async (userId: string, _projectId: Id<"projects">) => {
|
||||
return userId;
|
||||
},
|
||||
|
||||
persistPublicGitImport: async (
|
||||
_userId: string,
|
||||
_source: PreparedPublicGitSource,
|
||||
_remote: PublicGitImportResult
|
||||
): Promise<ProjectImportOutcome> => {
|
||||
throw new Error(
|
||||
"persistPublicGitImport must be called via internal mutation"
|
||||
);
|
||||
},
|
||||
|
||||
putContext: async (
|
||||
_userId: string,
|
||||
_projectId: Id<"projects">,
|
||||
_write: ContextWrite
|
||||
): Promise<{ revision: number }> => {
|
||||
throw new Error("putContext must be called via internal mutation");
|
||||
},
|
||||
});
|
||||
|
||||
// We need Effect for decideContextWrite at runtime.
|
||||
import { Effect } from "effect";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal mutation store — the actual transactional persistence
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const persistPublicGitImportTransaction = async (
|
||||
ctx: MutationCtx,
|
||||
userId: string,
|
||||
source: PreparedPublicGitSource,
|
||||
remote: PublicGitImportResult
|
||||
): Promise<ProjectImportOutcome> => {
|
||||
const { organizationId } = await resolveOrgForUser(ctx, userId);
|
||||
|
||||
// Idempotency: lookup by normalizedUrl within the organization
|
||||
const existingSource = await ctx.db
|
||||
.query("projectSources")
|
||||
.withIndex("by_organization_and_normalizedUrl", (q) =>
|
||||
q
|
||||
.eq("organizationId", organizationId)
|
||||
.eq("normalizedUrl", source.normalizedUrl)
|
||||
)
|
||||
.unique();
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
if (existingSource) {
|
||||
const project = await ctx.db.get(existingSource.projectId);
|
||||
if (!project) {
|
||||
throw new Error("Project not found for existing source");
|
||||
}
|
||||
// Update source metadata
|
||||
await ctx.db.patch(existingSource._id, {
|
||||
defaultBranch: remote.defaultBranch,
|
||||
updatedAt: now,
|
||||
});
|
||||
// Apply remote documents as repository writes
|
||||
for (const doc of remote.documents) {
|
||||
await applyRepositoryWrite(
|
||||
ctx,
|
||||
project._id,
|
||||
organizationId,
|
||||
doc,
|
||||
source.url,
|
||||
now
|
||||
);
|
||||
}
|
||||
await ctx.db.patch(project._id, { updatedAt: now });
|
||||
const updatedSource = await ctx.db.get(existingSource._id);
|
||||
return toImportOutcome(ctx, project, updatedSource ?? existingSource);
|
||||
}
|
||||
|
||||
// Create new project
|
||||
const projectId = await ctx.db.insert("projects", {
|
||||
createdAt: now,
|
||||
name: source.projectName,
|
||||
organizationId,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
// Create source
|
||||
const sourceId = await ctx.db.insert("projectSources", {
|
||||
createdAt: now,
|
||||
defaultBranch: remote.defaultBranch,
|
||||
host: source.host,
|
||||
kind: "git",
|
||||
normalizedUrl: source.normalizedUrl,
|
||||
organizationId,
|
||||
projectId,
|
||||
repositoryPath: source.repositoryPath,
|
||||
updatedAt: now,
|
||||
url: source.url,
|
||||
});
|
||||
|
||||
// Seed six canonical context documents
|
||||
const seeds = makeInitialContext(source.projectName, source.url);
|
||||
for (const seed of seeds) {
|
||||
await ctx.db.insert("projectContextDocuments", {
|
||||
content: seed.content,
|
||||
createdAt: now,
|
||||
kind: seed.kind,
|
||||
organizationId,
|
||||
origin: seed.origin,
|
||||
path: seed.path,
|
||||
projectId,
|
||||
revision: seed.revision,
|
||||
sourceUrl: seed.sourceUrl,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
// Apply remote documents (overrides seeds)
|
||||
for (const doc of remote.documents) {
|
||||
await applyRepositoryWrite(
|
||||
ctx,
|
||||
projectId,
|
||||
organizationId,
|
||||
doc,
|
||||
source.url,
|
||||
now
|
||||
);
|
||||
}
|
||||
|
||||
const createdProject = await ctx.db.get(projectId);
|
||||
const createdSource = await ctx.db.get(sourceId);
|
||||
if (!createdProject || !createdSource) {
|
||||
throw new Error("Failed to read created project");
|
||||
}
|
||||
return toImportOutcome(ctx, createdProject, createdSource);
|
||||
};
|
||||
|
||||
const applyRepositoryWrite = async (
|
||||
ctx: MutationCtx,
|
||||
projectId: Id<"projects">,
|
||||
organizationId: Id<"organizations">,
|
||||
doc: { kind: ContextKind; path: string; content: string },
|
||||
sourceUrl: string,
|
||||
now: number
|
||||
) => {
|
||||
const path = contextPathForKind(doc.kind);
|
||||
const existing = await ctx.db
|
||||
.query("projectContextDocuments")
|
||||
.withIndex("by_project_and_path", (q) =>
|
||||
q.eq("projectId", projectId).eq("path", path)
|
||||
)
|
||||
.unique();
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
decideContextWrite({
|
||||
existing: existing ? toContextDocumentState(existing) : undefined,
|
||||
write: {
|
||||
_tag: "Repository" as const,
|
||||
content: doc.content,
|
||||
kind: doc.kind,
|
||||
sourceUrl,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
if (!result.changed) return;
|
||||
|
||||
if (existing) {
|
||||
await ctx.db.patch(existing._id, {
|
||||
content: result.document.content,
|
||||
origin: result.document.origin,
|
||||
revision: result.document.revision,
|
||||
sourceUrl: result.document.sourceUrl,
|
||||
updatedAt: now,
|
||||
});
|
||||
} else {
|
||||
await ctx.db.insert("projectContextDocuments", {
|
||||
content: result.document.content,
|
||||
createdAt: now,
|
||||
kind: result.document.kind,
|
||||
organizationId,
|
||||
origin: result.document.origin,
|
||||
path: result.document.path,
|
||||
projectId,
|
||||
revision: result.document.revision,
|
||||
sourceUrl: result.document.sourceUrl,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Organization resolvers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const resolveOrgForUser = async (
|
||||
ctx: QueryCtx | MutationCtx,
|
||||
userId: string
|
||||
): Promise<{ organizationId: Id<"organizations"> }> => {
|
||||
const organization = await ctx.db
|
||||
.query("organizations")
|
||||
.withIndex("by_createdBy_and_kind", (q) =>
|
||||
q.eq("createdBy", userId).eq("kind", "personal")
|
||||
)
|
||||
.unique();
|
||||
if (!organization) {
|
||||
throw new Error("Organization not found");
|
||||
}
|
||||
return { organizationId: organization._id };
|
||||
};
|
||||
|
||||
const resolveProjectOrg = async (
|
||||
ctx: QueryCtx | MutationCtx,
|
||||
projectId: Id<"projects">,
|
||||
userId: string
|
||||
): Promise<{ organizationId: Id<"organizations"> }> => {
|
||||
const project = await ctx.db.get(projectId);
|
||||
if (!project) {
|
||||
throw new Error("Project not found");
|
||||
}
|
||||
const { organizationId } = await resolveOrgForUser(ctx, userId);
|
||||
if (project.organizationId !== organizationId) {
|
||||
throw new Error("Project not found");
|
||||
}
|
||||
return { organizationId };
|
||||
};
|
||||
|
||||
// Re-export for convenience
|
||||
export { CONTEXT_KINDS, requireCurrentOrganization, requireProjectMember };
|
||||
@@ -3,27 +3,54 @@ import {
|
||||
preparePublicGitSource,
|
||||
type ProjectImportOutcome,
|
||||
type ProjectView,
|
||||
type PublicGitImportResult,
|
||||
type PreparedPublicGitSource,
|
||||
} from "@code/primitives/project";
|
||||
import { v } from "convex/values";
|
||||
import { ConvexError, v } from "convex/values";
|
||||
import { Effect } from "effect";
|
||||
|
||||
import { internal } from "./_generated/api";
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
import type { Doc, Id } from "./_generated/dataModel";
|
||||
import { action, internalMutation, query } from "./_generated/server";
|
||||
import { createInitialArtifacts } from "./artifactModel";
|
||||
import { requireAuthUserId, requireCurrentOrganization } from "./authz";
|
||||
import {
|
||||
makeProjectMutationStore,
|
||||
makeProjectQueryStore,
|
||||
persistPublicGitImportTransaction,
|
||||
} from "./projectStore";
|
||||
import { inspectPublicGit } from "./publicGit";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal: persist a public Git import in one transaction
|
||||
// ---------------------------------------------------------------------------
|
||||
const toProjectView = async (
|
||||
ctx: Parameters<typeof requireCurrentOrganization>[0],
|
||||
project: Doc<"projects">
|
||||
): Promise<ProjectView> => {
|
||||
const documents = await ctx.db
|
||||
.query("projectContextDocuments")
|
||||
.withIndex("by_projectId_and_path", (q) => q.eq("projectId", project._id))
|
||||
.collect();
|
||||
const view = {
|
||||
contextDocuments: documents.map((document) => ({
|
||||
content: document.content,
|
||||
kind: document.kind,
|
||||
origin: document.origin,
|
||||
path: document.path,
|
||||
revision: 1,
|
||||
sourceUrl: document.sourceUrl,
|
||||
})),
|
||||
createdAt: project.createdAt,
|
||||
id: String(project._id),
|
||||
name: project.name,
|
||||
organizationId: String(project.organizationId),
|
||||
sources: [
|
||||
{
|
||||
createdAt: project.createdAt,
|
||||
defaultBranch: project.defaultBranch,
|
||||
host: project.sourceHost,
|
||||
kind: "git",
|
||||
normalizedUrl: project.normalizedSourceUrl,
|
||||
projectId: String(project._id),
|
||||
repositoryPath: project.repositoryPath,
|
||||
updatedAt: project.updatedAt,
|
||||
url: project.sourceUrl,
|
||||
},
|
||||
],
|
||||
updatedAt: project.updatedAt,
|
||||
};
|
||||
return view as unknown as ProjectView;
|
||||
};
|
||||
|
||||
export const persistPublicGitImport = internalMutation({
|
||||
args: {
|
||||
@@ -51,143 +78,106 @@ export const persistPublicGitImport = internalMutation({
|
||||
content: v.string(),
|
||||
})
|
||||
),
|
||||
warnings: v.array(
|
||||
v.object({
|
||||
path: v.string(),
|
||||
message: v.string(),
|
||||
})
|
||||
),
|
||||
warnings: v.array(v.object({ path: v.string(), message: v.string() })),
|
||||
}),
|
||||
},
|
||||
handler: async (ctx, args): Promise<ProjectImportOutcome> => {
|
||||
const result = await persistPublicGitImportTransaction(
|
||||
ctx,
|
||||
args.userId,
|
||||
args.source as PreparedPublicGitSource,
|
||||
args.remote as PublicGitImportResult
|
||||
);
|
||||
|
||||
// Seed operational artifacts on first creation (detected by checking
|
||||
// existing artifacts)
|
||||
const existingArtifacts = await ctx.db
|
||||
.query("projectArtifacts")
|
||||
.withIndex("by_project", (q) =>
|
||||
q.eq("projectId", result.id as unknown as Id<"projects">)
|
||||
const organization = await ctx.db
|
||||
.query("organizations")
|
||||
.withIndex("by_createdBy_and_kind", (q) =>
|
||||
q.eq("createdBy", args.userId).eq("kind", "personal")
|
||||
)
|
||||
.first();
|
||||
if (!existingArtifacts) {
|
||||
const now = Date.now();
|
||||
const artifacts = createInitialArtifacts();
|
||||
await Promise.all(
|
||||
artifacts.map(({ content, path }) =>
|
||||
ctx.db.insert("projectArtifacts", {
|
||||
content,
|
||||
createdAt: now,
|
||||
path,
|
||||
projectId: result.id as unknown as Id<"projects">,
|
||||
revision: 1,
|
||||
updatedAt: now,
|
||||
})
|
||||
)
|
||||
);
|
||||
await ctx.db.insert("projectEvents", {
|
||||
createdAt: now,
|
||||
data: { host: result.source.host, url: result.source.url },
|
||||
kind: "project.connected",
|
||||
projectId: result.id as unknown as Id<"projects">,
|
||||
.unique();
|
||||
if (!organization) {
|
||||
throw new ConvexError("Organization not found");
|
||||
}
|
||||
const timestamp = Date.now();
|
||||
const existing = await ctx.db
|
||||
.query("projects")
|
||||
.withIndex("by_organizationId_and_normalizedSourceUrl", (q) =>
|
||||
q
|
||||
.eq("organizationId", organization._id)
|
||||
.eq("normalizedSourceUrl", args.source.normalizedUrl)
|
||||
)
|
||||
.unique();
|
||||
let projectId: Id<"projects">;
|
||||
if (existing) {
|
||||
projectId = existing._id;
|
||||
await ctx.db.patch(projectId, {
|
||||
defaultBranch: args.remote.defaultBranch,
|
||||
name: args.source.projectName,
|
||||
sourceHost: args.source.host,
|
||||
sourceUrl: args.source.url,
|
||||
repositoryPath: args.source.repositoryPath,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
const oldDocuments = await ctx.db
|
||||
.query("projectContextDocuments")
|
||||
.withIndex("by_projectId_and_path", (q) => q.eq("projectId", projectId))
|
||||
.collect();
|
||||
for (const document of oldDocuments) {
|
||||
await ctx.db.delete(document._id);
|
||||
}
|
||||
} else {
|
||||
projectId = await ctx.db.insert("projects", {
|
||||
createdAt: timestamp,
|
||||
defaultBranch: args.remote.defaultBranch,
|
||||
name: args.source.projectName,
|
||||
normalizedSourceUrl: args.source.normalizedUrl,
|
||||
organizationId: organization._id,
|
||||
repositoryPath: args.source.repositoryPath,
|
||||
sourceHost: args.source.host,
|
||||
sourceUrl: args.source.url,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
for (const document of args.remote.documents) {
|
||||
await ctx.db.insert("projectContextDocuments", {
|
||||
...document,
|
||||
createdAt: timestamp,
|
||||
origin: "repository",
|
||||
projectId,
|
||||
sourceUrl: args.source.url,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
}
|
||||
const project = await ctx.db.get(projectId);
|
||||
if (!project) {
|
||||
throw new Error("Project could not be read after import");
|
||||
}
|
||||
const view = await toProjectView(ctx, project);
|
||||
return { ...view, source: view.sources[0]! };
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal: put a context document
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const putContextDocument = internalMutation({
|
||||
args: {
|
||||
userId: v.string(),
|
||||
projectId: v.id("projects"),
|
||||
write: v.union(
|
||||
v.object({
|
||||
_tag: v.literal("Repository"),
|
||||
content: v.string(),
|
||||
kind: v.union(
|
||||
v.literal("readme"),
|
||||
v.literal("agents"),
|
||||
v.literal("product"),
|
||||
v.literal("business"),
|
||||
v.literal("design"),
|
||||
v.literal("tech")
|
||||
),
|
||||
sourceUrl: v.string(),
|
||||
}),
|
||||
v.object({
|
||||
_tag: v.literal("PublicTextUrl"),
|
||||
content: v.string(),
|
||||
kind: v.union(
|
||||
v.literal("readme"),
|
||||
v.literal("agents"),
|
||||
v.literal("product"),
|
||||
v.literal("business"),
|
||||
v.literal("design"),
|
||||
v.literal("tech")
|
||||
),
|
||||
sourceUrl: v.string(),
|
||||
}),
|
||||
v.object({
|
||||
_tag: v.literal("UserText"),
|
||||
content: v.string(),
|
||||
kind: v.union(
|
||||
v.literal("readme"),
|
||||
v.literal("agents"),
|
||||
v.literal("product"),
|
||||
v.literal("business"),
|
||||
v.literal("design"),
|
||||
v.literal("tech")
|
||||
),
|
||||
origin: v.union(v.literal("paste"), v.literal("upload")),
|
||||
})
|
||||
),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const store = makeProjectMutationStore(ctx);
|
||||
return store.putContext(args.userId, args.projectId, args.write as never);
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public query: list projects
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const list = query({
|
||||
args: {},
|
||||
handler: async (ctx): Promise<ProjectView[]> => {
|
||||
const { userId } = await requireCurrentOrganization(ctx);
|
||||
const store = makeProjectQueryStore(ctx);
|
||||
return store.listProjects(userId);
|
||||
const { organizationId } = await requireCurrentOrganization(ctx);
|
||||
const projects = await ctx.db
|
||||
.query("projects")
|
||||
.withIndex("by_organizationId_and_createdAt", (q) =>
|
||||
q.eq("organizationId", organizationId)
|
||||
)
|
||||
.order("desc")
|
||||
.take(50);
|
||||
return await Promise.all(
|
||||
projects.map((project) => toProjectView(ctx, project))
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public query: get a project
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const get = query({
|
||||
args: { projectId: v.id("projects") },
|
||||
handler: async (ctx, args): Promise<ProjectView | null> => {
|
||||
const { userId } = await requireCurrentOrganization(ctx);
|
||||
const store = makeProjectQueryStore(ctx);
|
||||
return store.getProject(userId, args.projectId);
|
||||
const { organizationId } = await requireCurrentOrganization(ctx);
|
||||
const project = await ctx.db.get(args.projectId);
|
||||
return project?.organizationId === organizationId
|
||||
? await toProjectView(ctx, project)
|
||||
: null;
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public action: import a supported public Git repository
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const importPublicGit = action({
|
||||
args: { repositoryUrl: v.string() },
|
||||
handler: async (ctx, args): Promise<ProjectImportOutcome> => {
|
||||
@@ -195,50 +185,17 @@ export const importPublicGit = action({
|
||||
const source = await Effect.runPromise(
|
||||
preparePublicGitSource(args.repositoryUrl)
|
||||
);
|
||||
const remote = await inspectPublicGit(source);
|
||||
const validatedRemote = await Effect.runPromise(
|
||||
decodePublicGitImportResult(remote)
|
||||
const remote = await Effect.runPromise(
|
||||
decodePublicGitImportResult(await inspectPublicGit(source))
|
||||
);
|
||||
return await ctx.runMutation(internal.projects.persistPublicGitImport, {
|
||||
remote: {
|
||||
defaultBranch: validatedRemote.defaultBranch,
|
||||
documents: validatedRemote.documents.map((document) => ({
|
||||
...document,
|
||||
})),
|
||||
warnings: validatedRemote.warnings.map((warning) => ({ ...warning })),
|
||||
defaultBranch: remote.defaultBranch,
|
||||
documents: remote.documents.map((document) => ({ ...document })),
|
||||
warnings: remote.warnings.map((warning) => ({ ...warning })),
|
||||
},
|
||||
source,
|
||||
userId,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public mutation: put context text (paste/upload)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const putText = internalMutation({
|
||||
args: {
|
||||
projectId: v.id("projects"),
|
||||
kind: v.union(
|
||||
v.literal("readme"),
|
||||
v.literal("agents"),
|
||||
v.literal("product"),
|
||||
v.literal("business"),
|
||||
v.literal("design"),
|
||||
v.literal("tech")
|
||||
),
|
||||
content: v.string(),
|
||||
origin: v.union(v.literal("paste"), v.literal("upload")),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const { userId } = await requireCurrentOrganization(ctx);
|
||||
const store = makeProjectMutationStore(ctx);
|
||||
return store.putContext(userId, args.projectId, {
|
||||
_tag: "UserText" as const,
|
||||
content: args.content,
|
||||
kind: args.kind,
|
||||
origin: args.origin,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2,192 +2,6 @@ import { defineSchema, defineTable } from "convex/server";
|
||||
import { v } from "convex/values";
|
||||
|
||||
export default defineSchema({
|
||||
daemonDefinitions: defineTable({
|
||||
daemonId: v.string(),
|
||||
name: v.string(),
|
||||
desiredState: v.union(v.literal("online"), v.literal("offline")),
|
||||
agentOsConfig: v.any(),
|
||||
configVersion: v.number(),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
}).index("by_daemonId", ["daemonId"]),
|
||||
daemonPresence: defineTable({
|
||||
daemonId: v.string(),
|
||||
sessionId: v.string(),
|
||||
status: v.union(
|
||||
v.literal("online"),
|
||||
v.literal("draining"),
|
||||
v.literal("offline")
|
||||
),
|
||||
hostname: v.string(),
|
||||
platform: v.string(),
|
||||
architecture: v.string(),
|
||||
version: v.string(),
|
||||
startedAt: v.number(),
|
||||
lastHeartbeatAt: v.number(),
|
||||
})
|
||||
.index("by_daemonId", ["daemonId"])
|
||||
.index("by_sessionId", ["sessionId"]),
|
||||
daemonCommands: defineTable({
|
||||
daemonId: v.string(),
|
||||
method: v.string(),
|
||||
args: v.any(),
|
||||
actorKey: v.array(v.string()),
|
||||
status: v.union(
|
||||
v.literal("queued"),
|
||||
v.literal("claimed"),
|
||||
v.literal("running"),
|
||||
v.literal("succeeded"),
|
||||
v.literal("failed"),
|
||||
v.literal("cancelled")
|
||||
),
|
||||
attempts: v.number(),
|
||||
claimedBySessionId: v.optional(v.string()),
|
||||
leaseExpiresAt: v.optional(v.number()),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
startedAt: v.optional(v.number()),
|
||||
completedAt: v.optional(v.number()),
|
||||
result: v.optional(v.any()),
|
||||
error: v.optional(v.string()),
|
||||
})
|
||||
.index("by_daemonId_and_status", ["daemonId", "status"])
|
||||
.index("by_status_and_leaseExpiresAt", ["status", "leaseExpiresAt"]),
|
||||
daemonCommandEvents: defineTable({
|
||||
daemonId: v.string(),
|
||||
commandId: v.optional(v.id("daemonCommands")),
|
||||
kind: v.string(),
|
||||
data: v.any(),
|
||||
createdAt: v.number(),
|
||||
})
|
||||
.index("by_daemonId_and_createdAt", ["daemonId", "createdAt"])
|
||||
.index("by_commandId_and_createdAt", ["commandId", "createdAt"]),
|
||||
projects: defineTable({
|
||||
organizationId: v.id("organizations"),
|
||||
name: v.string(),
|
||||
description: v.optional(v.string()),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
}).index("by_organization_and_createdAt", ["organizationId", "createdAt"]),
|
||||
projectSources: defineTable({
|
||||
organizationId: v.id("organizations"),
|
||||
projectId: v.id("projects"),
|
||||
kind: v.literal("git"),
|
||||
url: v.string(),
|
||||
normalizedUrl: v.string(),
|
||||
host: v.string(),
|
||||
repositoryPath: v.string(),
|
||||
defaultBranch: v.optional(v.string()),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index("by_project", ["projectId"])
|
||||
.index("by_organization_and_normalizedUrl", [
|
||||
"organizationId",
|
||||
"normalizedUrl",
|
||||
])
|
||||
.index("by_organization_and_createdAt", ["organizationId", "createdAt"]),
|
||||
projectContextDocuments: defineTable({
|
||||
organizationId: v.id("organizations"),
|
||||
projectId: v.id("projects"),
|
||||
kind: v.union(
|
||||
v.literal("readme"),
|
||||
v.literal("agents"),
|
||||
v.literal("product"),
|
||||
v.literal("business"),
|
||||
v.literal("design"),
|
||||
v.literal("tech")
|
||||
),
|
||||
path: v.string(),
|
||||
content: v.string(),
|
||||
revision: v.number(),
|
||||
origin: v.union(
|
||||
v.literal("repository"),
|
||||
v.literal("paste"),
|
||||
v.literal("upload"),
|
||||
v.literal("public-url")
|
||||
),
|
||||
sourceUrl: v.optional(v.string()),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index("by_project", ["projectId"])
|
||||
.index("by_project_and_kind", ["projectId", "kind"])
|
||||
.index("by_project_and_path", ["projectId", "path"]),
|
||||
projectArtifacts: defineTable({
|
||||
projectId: v.id("projects"),
|
||||
path: v.string(),
|
||||
content: v.string(),
|
||||
revision: v.number(),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index("by_project", ["projectId"])
|
||||
.index("by_project_and_path", ["projectId", "path"]),
|
||||
projectIssues: defineTable({
|
||||
projectId: v.id("projects"),
|
||||
number: v.number(),
|
||||
title: v.string(),
|
||||
body: v.string(),
|
||||
status: v.union(
|
||||
v.literal("open"),
|
||||
v.literal("queued"),
|
||||
v.literal("working"),
|
||||
v.literal("needs-input"),
|
||||
v.literal("completed"),
|
||||
v.literal("failed")
|
||||
),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index("by_project_and_number", ["projectId", "number"])
|
||||
.index("by_project_and_status", ["projectId", "status"]),
|
||||
projectWorkRuns: defineTable({
|
||||
baseBranch: v.optional(v.string()),
|
||||
branchName: v.optional(v.string()),
|
||||
checkoutPath: v.optional(v.string()),
|
||||
projectId: v.id("projects"),
|
||||
issueId: v.id("projectIssues"),
|
||||
agentId: v.string(),
|
||||
daemonId: v.string(),
|
||||
actorKey: v.array(v.string()),
|
||||
sourceUrl: v.optional(v.string()),
|
||||
status: v.union(
|
||||
v.literal("ready"),
|
||||
v.literal("queued"),
|
||||
v.literal("working"),
|
||||
v.literal("needs-input"),
|
||||
v.literal("completed"),
|
||||
v.literal("failed")
|
||||
),
|
||||
summary: v.optional(v.string()),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
startedAt: v.optional(v.number()),
|
||||
completedAt: v.optional(v.number()),
|
||||
})
|
||||
.index("by_issue", ["issueId"])
|
||||
.index("by_project_and_createdAt", ["projectId", "createdAt"]),
|
||||
projectEvents: defineTable({
|
||||
projectId: v.id("projects"),
|
||||
issueId: v.optional(v.id("projectIssues")),
|
||||
runId: v.optional(v.id("projectWorkRuns")),
|
||||
kind: v.string(),
|
||||
data: v.any(),
|
||||
createdAt: v.number(),
|
||||
})
|
||||
.index("by_project_and_createdAt", ["projectId", "createdAt"])
|
||||
.index("by_issue_and_createdAt", ["issueId", "createdAt"]),
|
||||
todos: defineTable({
|
||||
text: v.string(),
|
||||
completed: v.boolean(),
|
||||
}),
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Organization tenancy. Organizations are hard tenant boundaries; deny by
|
||||
// default. The membership user ID is the Better Auth Convex identity
|
||||
// `tokenIdentifier`.
|
||||
// -----------------------------------------------------------------
|
||||
organizations: defineTable({
|
||||
name: v.string(),
|
||||
kind: v.union(v.literal("personal"), v.literal("team")),
|
||||
@@ -203,58 +17,86 @@ export default defineSchema({
|
||||
.index("by_userId", ["userId"])
|
||||
.index("by_organizationId", ["organizationId"])
|
||||
.index("by_organizationId_and_userId", ["organizationId", "userId"]),
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Normalized conversation message evidence. Each row is one user
|
||||
// message captured at the conversation ingress, before/with Flue
|
||||
// admission. The raw text is stored exactly as the user typed it
|
||||
// (never trimmed or rewritten). Organization scope is authoritative;
|
||||
// an agent instance may only observe its own organization's messages.
|
||||
// -----------------------------------------------------------------
|
||||
conversationMessages: defineTable({
|
||||
projects: defineTable({
|
||||
organizationId: v.id("organizations"),
|
||||
conversationId: v.string(),
|
||||
messageId: v.string(),
|
||||
submissionId: v.optional(v.string()),
|
||||
name: v.string(),
|
||||
description: v.optional(v.string()),
|
||||
sourceUrl: v.string(),
|
||||
normalizedSourceUrl: v.string(),
|
||||
sourceHost: v.string(),
|
||||
repositoryPath: v.string(),
|
||||
defaultBranch: v.optional(v.string()),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index("by_organizationId_and_createdAt", ["organizationId", "createdAt"])
|
||||
.index("by_organizationId_and_normalizedSourceUrl", [
|
||||
"organizationId",
|
||||
"normalizedSourceUrl",
|
||||
]),
|
||||
projectContextDocuments: defineTable({
|
||||
projectId: v.id("projects"),
|
||||
kind: v.union(
|
||||
v.literal("readme"),
|
||||
v.literal("agents"),
|
||||
v.literal("product"),
|
||||
v.literal("business"),
|
||||
v.literal("design"),
|
||||
v.literal("tech")
|
||||
),
|
||||
path: v.string(),
|
||||
content: v.string(),
|
||||
origin: v.literal("repository"),
|
||||
sourceUrl: v.string(),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
}).index("by_projectId_and_path", ["projectId", "path"]),
|
||||
conversations: defineTable({
|
||||
organizationId: v.id("organizations"),
|
||||
createdAt: v.number(),
|
||||
}).index("by_organizationId", ["organizationId"]),
|
||||
conversationTurns: defineTable({
|
||||
conversationId: v.id("conversations"),
|
||||
clientRequestId: v.string(),
|
||||
role: v.literal("user"),
|
||||
rawText: v.string(),
|
||||
status: v.union(
|
||||
v.literal("admitting"),
|
||||
v.literal("admitted"),
|
||||
v.literal("queued"),
|
||||
v.literal("processing"),
|
||||
v.literal("completed"),
|
||||
v.literal("failed")
|
||||
),
|
||||
submissionId: v.optional(v.string()),
|
||||
error: v.optional(v.string()),
|
||||
createdAt: v.number(),
|
||||
completedAt: v.optional(v.number()),
|
||||
}).index("by_conversationId_and_clientRequestId", [
|
||||
"conversationId",
|
||||
"clientRequestId",
|
||||
]),
|
||||
conversationMessages: defineTable({
|
||||
conversationId: v.id("conversations"),
|
||||
turnId: v.id("conversationTurns"),
|
||||
role: v.union(v.literal("user"), v.literal("assistant")),
|
||||
content: v.string(),
|
||||
ordinal: v.number(),
|
||||
createdAt: v.number(),
|
||||
})
|
||||
.index("by_organization_and_message", [
|
||||
"organizationId",
|
||||
"conversationId",
|
||||
"messageId",
|
||||
])
|
||||
.index("by_organization_and_request", [
|
||||
"organizationId",
|
||||
"clientRequestId",
|
||||
]),
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Product Signals. A Signal is the agent-produced interpretation of one
|
||||
// or more exact user messages into a structured problem statement. It is
|
||||
// organization-scoped, optionally project-scoped, and idempotent by
|
||||
// organization + ordered source-message selection (`sourceKey`). Raw text
|
||||
// is never accepted from the agent: it is copied server-side from the
|
||||
// normalized conversation messages into immutable source snapshots.
|
||||
// -----------------------------------------------------------------
|
||||
.index("by_conversationId_and_ordinal", ["conversationId", "ordinal"])
|
||||
.index("by_turnId_and_role", ["turnId", "role"]),
|
||||
conversationAttachments: defineTable({
|
||||
messageId: v.id("conversationMessages"),
|
||||
storageId: v.id("_storage"),
|
||||
filename: v.optional(v.string()),
|
||||
mimeType: v.string(),
|
||||
createdAt: v.number(),
|
||||
}).index("by_messageId", ["messageId"]),
|
||||
signals: defineTable({
|
||||
organizationId: v.id("organizations"),
|
||||
projectId: v.optional(v.id("projects")),
|
||||
conversationId: v.string(),
|
||||
projectId: v.id("projects"),
|
||||
conversationId: v.id("conversations"),
|
||||
sourceKey: v.string(),
|
||||
problemStatement: v.object({
|
||||
title: v.string(),
|
||||
summary: v.string(),
|
||||
desiredOutcome: v.string(),
|
||||
constraints: v.array(v.string()),
|
||||
}),
|
||||
title: v.string(),
|
||||
summary: v.string(),
|
||||
desiredOutcome: v.string(),
|
||||
processedByAgentName: v.string(),
|
||||
processedByAgentInstanceId: v.string(),
|
||||
createdAt: v.number(),
|
||||
@@ -262,26 +104,20 @@ export default defineSchema({
|
||||
.index("by_organization_and_createdAt", ["organizationId", "createdAt"])
|
||||
.index("by_organization_and_sourceKey", ["organizationId", "sourceKey"])
|
||||
.index("by_project_and_createdAt", ["projectId", "createdAt"]),
|
||||
|
||||
// Immutable ordered source snapshots copied from `conversationMessages`.
|
||||
// `ordinal` is the zero-based position in the agent's ordered selection.
|
||||
signalConstraints: defineTable({
|
||||
signalId: v.id("signals"),
|
||||
ordinal: v.number(),
|
||||
value: v.string(),
|
||||
}).index("by_signalId_and_ordinal", ["signalId", "ordinal"]),
|
||||
signalSources: defineTable({
|
||||
signalId: v.id("signals"),
|
||||
organizationId: v.id("organizations"),
|
||||
conversationId: v.string(),
|
||||
messageId: v.string(),
|
||||
messageId: v.id("conversationMessages"),
|
||||
ordinal: v.number(),
|
||||
rawTextSnapshot: v.string(),
|
||||
sourceCreatedAt: v.number(),
|
||||
submissionId: v.optional(v.string()),
|
||||
})
|
||||
.index("by_signal_and_ordinal", ["signalId", "ordinal"])
|
||||
.index("by_organization_and_message", [
|
||||
"organizationId",
|
||||
"conversationId",
|
||||
"messageId",
|
||||
]),
|
||||
|
||||
.index("by_signalId_and_ordinal", ["signalId", "ordinal"])
|
||||
.index("by_messageId", ["messageId"]),
|
||||
works: defineTable({
|
||||
organizationId: v.id("organizations"),
|
||||
projectId: v.id("projects"),
|
||||
@@ -295,8 +131,6 @@ export default defineSchema({
|
||||
.index("by_organization_and_createdAt", ["organizationId", "createdAt"]),
|
||||
|
||||
signalWorkAttachments: defineTable({
|
||||
organizationId: v.id("organizations"),
|
||||
projectId: v.id("projects"),
|
||||
signalId: v.id("signals"),
|
||||
workId: v.id("works"),
|
||||
createdAt: v.number(),
|
||||
@@ -306,34 +140,15 @@ export default defineSchema({
|
||||
.index("by_signal_and_work", ["signalId", "workId"]),
|
||||
|
||||
workEvents: defineTable({
|
||||
organizationId: v.id("organizations"),
|
||||
projectId: v.id("projects"),
|
||||
workId: v.id("works"),
|
||||
signalId: v.id("signals"),
|
||||
kind: v.union(v.literal("work.proposed"), v.literal("signal.attached")),
|
||||
idempotencyKey: v.string(),
|
||||
data: v.any(),
|
||||
createdAt: v.number(),
|
||||
})
|
||||
.index("by_work_and_createdAt", ["workId", "createdAt"])
|
||||
.index("by_work_and_idempotencyKey", ["workId", "idempotencyKey"]),
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Signal-to-issue attachments. A proper relation that links one Signal
|
||||
// to one ProjectIssue, idempotent by (signalId, issueId). Multiple
|
||||
// Signals may attach to the same ProjectIssue. The relation is scoped
|
||||
// by organization and project to prevent cross-tenant leakage.
|
||||
// -----------------------------------------------------------------
|
||||
signalIssueAttachments: defineTable({
|
||||
signalId: v.id("signals"),
|
||||
issueId: v.id("projectIssues"),
|
||||
organizationId: v.id("organizations"),
|
||||
projectId: v.id("projects"),
|
||||
createdAt: v.number(),
|
||||
})
|
||||
.index("by_signal", ["signalId"])
|
||||
.index("by_issue", ["issueId"])
|
||||
.index("by_signal_and_issue", ["signalId", "issueId"]),
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Flue persistence stores (schema/format version 4).
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { env } from "@code/env/convex";
|
||||
import { type TestConvex, convexTest } from "convex-test";
|
||||
import { convexTest } from "convex-test";
|
||||
import { anyApi } from "convex/server";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { internal } from "./_generated/api";
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
import schema from "./schema";
|
||||
|
||||
declare global {
|
||||
@@ -16,552 +14,74 @@ declare global {
|
||||
const modules = import.meta.glob("./**/*.ts");
|
||||
const api = anyApi;
|
||||
|
||||
const TOKEN = env.FLUE_DB_TOKEN;
|
||||
const BAD_TOKEN = "not-the-right-token";
|
||||
|
||||
const ID_A = "https://convex.test|user-a";
|
||||
const identityA = { tokenIdentifier: ID_A };
|
||||
|
||||
const newTest = () => convexTest({ schema, modules });
|
||||
|
||||
const ensureOrg = async (t: TestConvex<typeof schema>) => {
|
||||
const org = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.organizations.ensurePersonalOrganization, {});
|
||||
return org._id as Id<"organizations">;
|
||||
};
|
||||
|
||||
const problemStatement = {
|
||||
title: "Deploy fails on staging",
|
||||
summary: "The staging deploy command exits with a DNS resolution error.",
|
||||
desiredOutcome: "Staging deploys successfully without DNS errors.",
|
||||
constraints: ["Must not change the production pipeline"],
|
||||
};
|
||||
|
||||
const seedMessage = async (
|
||||
t: TestConvex<typeof schema>,
|
||||
orgId: Id<"organizations">,
|
||||
clientRequestId: string,
|
||||
rawText: string
|
||||
): Promise<string> => {
|
||||
const begun = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.conversationMessages.beginUserMessage, {
|
||||
clientRequestId,
|
||||
organizationId: orgId,
|
||||
rawText,
|
||||
describe("signalRouting", () => {
|
||||
test("creates one normalized Signal from admitted conversation evidence", async () => {
|
||||
const t = convexTest({ modules, schema });
|
||||
const organization = await t.run(async (ctx) => {
|
||||
const organizationId = await ctx.db.insert("organizations", {
|
||||
createdAt: 1,
|
||||
createdBy: "user",
|
||||
kind: "personal",
|
||||
name: "Personal",
|
||||
});
|
||||
const projectId = await ctx.db.insert("projects", {
|
||||
createdAt: 1,
|
||||
name: "Zopu",
|
||||
normalizedSourceUrl: "https://example.com/zopu",
|
||||
organizationId,
|
||||
repositoryPath: "puter/zopu",
|
||||
sourceHost: "example.com",
|
||||
sourceUrl: "https://example.com/zopu",
|
||||
updatedAt: 1,
|
||||
});
|
||||
const conversationId = await ctx.db.insert("conversations", {
|
||||
createdAt: 1,
|
||||
organizationId,
|
||||
});
|
||||
const turnId = await ctx.db.insert("conversationTurns", {
|
||||
clientRequestId: "request-1",
|
||||
conversationId,
|
||||
createdAt: 1,
|
||||
status: "processing",
|
||||
});
|
||||
const messageId = await ctx.db.insert("conversationMessages", {
|
||||
content: "Fix the deploy",
|
||||
conversationId,
|
||||
createdAt: 1,
|
||||
ordinal: 0,
|
||||
role: "user",
|
||||
turnId,
|
||||
});
|
||||
return { messageId, organizationId, projectId };
|
||||
});
|
||||
await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.conversationMessages.markAdmitted, {
|
||||
clientRequestId,
|
||||
organizationId: orgId,
|
||||
submissionId: `sub-${clientRequestId}`,
|
||||
});
|
||||
return begun.messageId;
|
||||
};
|
||||
|
||||
const createProject = async (
|
||||
t: TestConvex<typeof schema>,
|
||||
ownerId: string,
|
||||
repoName: string
|
||||
): Promise<Id<"projects">> => {
|
||||
const ownerIdentity = { tokenIdentifier: ownerId };
|
||||
await t
|
||||
.withIdentity(ownerIdentity)
|
||||
.mutation(api.organizations.ensurePersonalOrganization);
|
||||
const outcome = await t
|
||||
.withIdentity(ownerIdentity)
|
||||
.mutation(internal.projects.persistPublicGitImport, {
|
||||
userId: ownerId,
|
||||
source: {
|
||||
host: "github.com",
|
||||
projectName: repoName,
|
||||
repositoryPath: `owner/${repoName}`,
|
||||
normalizedUrl: `https://github.com/owner/${repoName}`,
|
||||
url: `https://github.com/owner/${repoName}`,
|
||||
},
|
||||
remote: {
|
||||
defaultBranch: "main",
|
||||
documents: [
|
||||
{
|
||||
kind: "readme" as const,
|
||||
path: "README.md",
|
||||
content: `# ${repoName}\n\nTest.\n`,
|
||||
},
|
||||
],
|
||||
warnings: [],
|
||||
const result = await t.mutation(api.signalRouting.createSignal, {
|
||||
messageIds: [String(organization.messageId)],
|
||||
organizationId: organization.organizationId,
|
||||
problemStatement: {
|
||||
constraints: ["Keep production stable"],
|
||||
desiredOutcome: "Deploy succeeds",
|
||||
summary: "The deploy is failing",
|
||||
title: "Fix deploy",
|
||||
},
|
||||
processedByAgentInstanceId: String(organization.organizationId),
|
||||
projectId: organization.projectId,
|
||||
token: env.FLUE_DB_TOKEN,
|
||||
});
|
||||
return outcome.id as unknown as Id<"projects">;
|
||||
};
|
||||
|
||||
// Create a project-scoped signal via the routing backend.
|
||||
const createRoutingSignal = async (
|
||||
t: TestConvex<typeof schema>,
|
||||
orgId: Id<"organizations">,
|
||||
projectId: Id<"projects">,
|
||||
messageIds: string[]
|
||||
): Promise<Id<"signals">> => {
|
||||
const result = await t.mutation(api.signalRouting.createSignal, {
|
||||
messageIds,
|
||||
organizationId: orgId,
|
||||
problemStatement,
|
||||
processedByAgentInstanceId: orgId,
|
||||
projectId,
|
||||
token: TOKEN,
|
||||
});
|
||||
return result.signalId as Id<"signals">;
|
||||
};
|
||||
|
||||
describe("signalRouting authentication", () => {
|
||||
test("invalid token is rejected on every function", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t);
|
||||
const projectId = await createProject(t, ID_A, "auth-test");
|
||||
const messageId = await seedMessage(t, orgId, "req-auth", "test message");
|
||||
|
||||
await expect(
|
||||
t.mutation(api.signalRouting.createSignal, {
|
||||
messageIds: [messageId],
|
||||
organizationId: orgId,
|
||||
problemStatement,
|
||||
processedByAgentInstanceId: orgId,
|
||||
projectId,
|
||||
token: BAD_TOKEN,
|
||||
})
|
||||
).rejects.toThrow(/Invalid agent control token/u);
|
||||
|
||||
await expect(
|
||||
t.query(api.signalRouting.listEvidence, {
|
||||
organizationId: orgId,
|
||||
token: BAD_TOKEN,
|
||||
})
|
||||
).rejects.toThrow(/Invalid agent control token/u);
|
||||
|
||||
await expect(
|
||||
t.query(api.signalRouting.listActiveIssues, {
|
||||
organizationId: orgId,
|
||||
projectId,
|
||||
token: BAD_TOKEN,
|
||||
})
|
||||
).rejects.toThrow(/Invalid agent control token/u);
|
||||
|
||||
await expect(
|
||||
t.query(api.signalRouting.listProjects, {
|
||||
organizationId: orgId,
|
||||
token: BAD_TOKEN,
|
||||
})
|
||||
).rejects.toThrow(/Invalid agent control token/u);
|
||||
});
|
||||
});
|
||||
|
||||
describe("signalRouting create and route", () => {
|
||||
test("create signal, list evidence, and list signals", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t);
|
||||
const projectId = await createProject(t, ID_A, "route-test");
|
||||
const messageId = await seedMessage(
|
||||
t,
|
||||
orgId,
|
||||
"req-route",
|
||||
"The work-unit card should expose the generated PR."
|
||||
);
|
||||
|
||||
// Evidence appears before signal creation.
|
||||
const evidenceBefore = await t.query(api.signalRouting.listEvidence, {
|
||||
organizationId: orgId,
|
||||
token: TOKEN,
|
||||
});
|
||||
expect(evidenceBefore).toHaveLength(1);
|
||||
expect(evidenceBefore[0]?.rawText).toBe(
|
||||
"The work-unit card should expose the generated PR."
|
||||
);
|
||||
|
||||
// Create the signal.
|
||||
const signalId = await createRoutingSignal(t, orgId, projectId, [
|
||||
messageId,
|
||||
]);
|
||||
|
||||
// Evidence is consumed after signal creation.
|
||||
const evidenceAfter = await t.query(api.signalRouting.listEvidence, {
|
||||
organizationId: orgId,
|
||||
token: TOKEN,
|
||||
});
|
||||
expect(evidenceAfter).toHaveLength(0);
|
||||
|
||||
// Signal appears in the project list.
|
||||
const signals = await t.query(api.signalRouting.listSignals, {
|
||||
organizationId: orgId,
|
||||
projectId,
|
||||
token: TOKEN,
|
||||
});
|
||||
expect(signals).toHaveLength(1);
|
||||
expect(signals[0]?._id).toBe(signalId);
|
||||
expect(signals[0]?.problemStatement.title).toBe("Deploy fails on staging");
|
||||
});
|
||||
|
||||
test("attach signal to existing issue", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t);
|
||||
const projectId = await createProject(t, ID_A, "attach-test");
|
||||
const messageId = await seedMessage(t, orgId, "req-attach", "problem");
|
||||
const signalId = await createRoutingSignal(t, orgId, projectId, [
|
||||
messageId,
|
||||
]);
|
||||
|
||||
// Create an issue via the existing projectIssues backend.
|
||||
const issueId = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.projectIssues.create, {
|
||||
body: "Existing issue body that is long enough.",
|
||||
projectId,
|
||||
title: "Existing UI issue",
|
||||
});
|
||||
|
||||
const result = await t.mutation(api.signalRouting.attachSignalToIssue, {
|
||||
issueId: issueId as string,
|
||||
organizationId: orgId,
|
||||
signalId: signalId as string,
|
||||
token: TOKEN,
|
||||
});
|
||||
|
||||
expect(result.alreadyAttached).toBe(false);
|
||||
|
||||
// List active issues includes the attached issue.
|
||||
const issues = await t.query(api.signalRouting.listActiveIssues, {
|
||||
organizationId: orgId,
|
||||
projectId,
|
||||
token: TOKEN,
|
||||
});
|
||||
expect(issues).toHaveLength(1);
|
||||
expect(issues[0]?._id).toBe(issueId);
|
||||
});
|
||||
|
||||
test("create issue from signal (attach-vs-create fallback)", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t);
|
||||
const projectId = await createProject(t, ID_A, "create-test");
|
||||
const messageId = await seedMessage(t, orgId, "req-create", "new problem");
|
||||
const signalId = await createRoutingSignal(t, orgId, projectId, [
|
||||
messageId,
|
||||
]);
|
||||
|
||||
const result = await t.mutation(api.signalRouting.createIssueFromSignal, {
|
||||
organizationId: orgId,
|
||||
signalId: signalId as string,
|
||||
token: TOKEN,
|
||||
});
|
||||
|
||||
expect(result.created).toBe(true);
|
||||
expect(result.projectId).toBe(projectId);
|
||||
|
||||
// The issue exists and is open.
|
||||
const issues = await t.query(api.signalRouting.listActiveIssues, {
|
||||
organizationId: orgId,
|
||||
projectId,
|
||||
token: TOKEN,
|
||||
});
|
||||
expect(issues).toHaveLength(1);
|
||||
expect(issues[0]?.title).toBe("Deploy fails on staging");
|
||||
});
|
||||
});
|
||||
|
||||
describe("signalRouting idempotency", () => {
|
||||
test("duplicate attach retry returns existing without duplicate event", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t);
|
||||
const projectId = await createProject(t, ID_A, "idem-attach");
|
||||
const messageId = await seedMessage(t, orgId, "req-idem-att", "problem");
|
||||
const signalId = await createRoutingSignal(t, orgId, projectId, [
|
||||
messageId,
|
||||
]);
|
||||
|
||||
const issueId = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.projectIssues.create, {
|
||||
body: "Existing issue body that is long enough for validation.",
|
||||
projectId,
|
||||
title: "Pre-existing issue",
|
||||
});
|
||||
|
||||
// First attach.
|
||||
const first = await t.mutation(api.signalRouting.attachSignalToIssue, {
|
||||
issueId: issueId as string,
|
||||
organizationId: orgId,
|
||||
signalId: signalId as string,
|
||||
token: TOKEN,
|
||||
});
|
||||
expect(first.alreadyAttached).toBe(false);
|
||||
|
||||
// Retry the same attach.
|
||||
const second = await t.mutation(api.signalRouting.attachSignalToIssue, {
|
||||
issueId: issueId as string,
|
||||
organizationId: orgId,
|
||||
signalId: signalId as string,
|
||||
token: TOKEN,
|
||||
});
|
||||
expect(second.alreadyAttached).toBe(true);
|
||||
expect(second.attachmentId).toBe(first.attachmentId);
|
||||
|
||||
// Verify only one signal.attached event was emitted (not duplicated).
|
||||
const events = await t
|
||||
.withIdentity(identityA)
|
||||
.query(api.projectIssues.events, { projectId });
|
||||
const attached = events.filter((e: { kind: string }) =>
|
||||
e.kind.includes("signal.attached")
|
||||
);
|
||||
expect(attached).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("duplicate create-from-signal retry returns existing issue", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t);
|
||||
const projectId = await createProject(t, ID_A, "idem-create");
|
||||
const messageId = await seedMessage(t, orgId, "req-idem-crt", "problem");
|
||||
const signalId = await createRoutingSignal(t, orgId, projectId, [
|
||||
messageId,
|
||||
]);
|
||||
|
||||
// First create.
|
||||
const first = await t.mutation(api.signalRouting.createIssueFromSignal, {
|
||||
organizationId: orgId,
|
||||
signalId: signalId as string,
|
||||
token: TOKEN,
|
||||
});
|
||||
expect(first.created).toBe(true);
|
||||
|
||||
// Retry — should return existing, not create a new issue.
|
||||
const second = await t.mutation(api.signalRouting.createIssueFromSignal, {
|
||||
organizationId: orgId,
|
||||
signalId: signalId as string,
|
||||
token: TOKEN,
|
||||
});
|
||||
expect(second.created).toBe(false);
|
||||
expect(second.issueId).toBe(first.issueId);
|
||||
|
||||
// Only one issue exists.
|
||||
const issues = await t.query(api.signalRouting.listActiveIssues, {
|
||||
organizationId: orgId,
|
||||
projectId,
|
||||
token: TOKEN,
|
||||
});
|
||||
expect(issues).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("duplicate signal creation is idempotent (sourceKey)", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t);
|
||||
const projectId = await createProject(t, ID_A, "idem-signal");
|
||||
const messageId = await seedMessage(t, orgId, "req-idem-sig", "test");
|
||||
|
||||
const first = await t.mutation(api.signalRouting.createSignal, {
|
||||
messageIds: [messageId],
|
||||
organizationId: orgId,
|
||||
problemStatement,
|
||||
processedByAgentInstanceId: orgId,
|
||||
projectId,
|
||||
token: TOKEN,
|
||||
});
|
||||
const second = await t.mutation(api.signalRouting.createSignal, {
|
||||
messageIds: [messageId],
|
||||
organizationId: orgId,
|
||||
problemStatement,
|
||||
processedByAgentInstanceId: orgId,
|
||||
projectId,
|
||||
token: TOKEN,
|
||||
});
|
||||
|
||||
expect(second.signalId).toBe(first.signalId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("signalRouting cross-project rejection", () => {
|
||||
test("attach rejects when signal and issue are in different projects", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t);
|
||||
const projectA = await createProject(t, ID_A, "cross-a");
|
||||
const projectB = await createProject(t, ID_A, "cross-b");
|
||||
|
||||
const messageId = await seedMessage(t, orgId, "req-cross", "problem");
|
||||
// Signal is scoped to project A.
|
||||
const signalId = await createRoutingSignal(t, orgId, projectA, [messageId]);
|
||||
|
||||
// Issue is in project B.
|
||||
const issueId = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.projectIssues.create, {
|
||||
body: "Issue in project B with enough body text.",
|
||||
projectId: projectB,
|
||||
title: "Project B issue",
|
||||
});
|
||||
|
||||
await expect(
|
||||
t.mutation(api.signalRouting.attachSignalToIssue, {
|
||||
issueId: issueId as string,
|
||||
organizationId: orgId,
|
||||
signalId: signalId as string,
|
||||
token: TOKEN,
|
||||
})
|
||||
).rejects.toThrow(/must belong to the same project/u);
|
||||
});
|
||||
|
||||
test("attach rejects org-scoped signal (no projectId)", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t);
|
||||
const projectId = await createProject(t, ID_A, "org-scope-test");
|
||||
|
||||
const messageId = await seedMessage(t, orgId, "req-org", "problem");
|
||||
|
||||
// Create an org-scoped signal (no projectId).
|
||||
const signalId = await t.mutation(api.signalRouting.createSignal, {
|
||||
messageIds: [messageId],
|
||||
organizationId: orgId,
|
||||
problemStatement,
|
||||
processedByAgentInstanceId: orgId,
|
||||
token: TOKEN,
|
||||
});
|
||||
|
||||
const issueId = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.projectIssues.create, {
|
||||
body: "Issue body that is long enough for the validator.",
|
||||
projectId,
|
||||
title: "Some issue",
|
||||
});
|
||||
|
||||
await expect(
|
||||
t.mutation(api.signalRouting.attachSignalToIssue, {
|
||||
issueId: issueId as string,
|
||||
organizationId: orgId,
|
||||
signalId: signalId.signalId as string,
|
||||
token: TOKEN,
|
||||
})
|
||||
).rejects.toThrow(/not project-scoped/u);
|
||||
});
|
||||
});
|
||||
|
||||
describe("signalRouting provenance", () => {
|
||||
test("exact message raw text is preserved through the routing loop", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t);
|
||||
const projectId = await createProject(t, ID_A, "prov-test");
|
||||
const rawText = " The work-unit card should expose the generated PR. ";
|
||||
const messageId = await seedMessage(t, orgId, "req-prov", rawText);
|
||||
|
||||
// Evidence shows exact raw text.
|
||||
const evidence = await t.query(api.signalRouting.listEvidence, {
|
||||
organizationId: orgId,
|
||||
token: TOKEN,
|
||||
});
|
||||
expect(evidence[0]?.rawText).toBe(rawText);
|
||||
|
||||
// Signal creation preserves exact snapshot.
|
||||
const signalId = await createRoutingSignal(t, orgId, projectId, [
|
||||
messageId,
|
||||
]);
|
||||
|
||||
// Verify via the existing signals.get that the snapshot is exact.
|
||||
const composed = await t
|
||||
.withIdentity(identityA)
|
||||
.query(api.signals.get, { signalId });
|
||||
expect(composed?.sources[0]?.rawTextSnapshot).toBe(rawText);
|
||||
});
|
||||
});
|
||||
|
||||
describe("signalRouting begin issue", () => {
|
||||
test("begin transitions an open issue to queued", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t);
|
||||
const projectId = await createProject(t, ID_A, "begin-test");
|
||||
const messageId = await seedMessage(t, orgId, "req-begin", "start this");
|
||||
const signalId = await createRoutingSignal(t, orgId, projectId, [
|
||||
messageId,
|
||||
]);
|
||||
|
||||
const result = await t.mutation(api.signalRouting.createIssueFromSignal, {
|
||||
organizationId: orgId,
|
||||
signalId: signalId as string,
|
||||
token: TOKEN,
|
||||
});
|
||||
|
||||
const status = await t.mutation(api.signalRouting.beginIssue, {
|
||||
issueId: result.issueId as string,
|
||||
organizationId: orgId,
|
||||
token: TOKEN,
|
||||
});
|
||||
|
||||
expect(status).toEqual({
|
||||
dispatchRequired: true,
|
||||
projectId,
|
||||
status: "queued",
|
||||
});
|
||||
|
||||
const repeated = await t.mutation(api.signalRouting.beginIssue, {
|
||||
issueId: result.issueId as string,
|
||||
organizationId: orgId,
|
||||
token: TOKEN,
|
||||
});
|
||||
expect(repeated).toEqual({
|
||||
dispatchRequired: false,
|
||||
projectId,
|
||||
status: "queued",
|
||||
});
|
||||
});
|
||||
|
||||
test("begin rejects invalid token", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t);
|
||||
const projectId = await createProject(t, ID_A, "begin-auth");
|
||||
const messageId = await seedMessage(t, orgId, "req-begin-auth", "msg");
|
||||
const signalId = await createRoutingSignal(t, orgId, projectId, [
|
||||
messageId,
|
||||
]);
|
||||
|
||||
const result = await t.mutation(api.signalRouting.createIssueFromSignal, {
|
||||
organizationId: orgId,
|
||||
signalId: signalId as string,
|
||||
token: TOKEN,
|
||||
});
|
||||
|
||||
await expect(
|
||||
t.mutation(api.signalRouting.beginIssue, {
|
||||
issueId: result.issueId as string,
|
||||
organizationId: orgId,
|
||||
token: BAD_TOKEN,
|
||||
})
|
||||
).rejects.toThrow(/Invalid agent control token/u);
|
||||
});
|
||||
});
|
||||
|
||||
describe("signalRouting project context", () => {
|
||||
test("get project context returns project and documents", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t);
|
||||
const projectId = await createProject(t, ID_A, "ctx-test");
|
||||
|
||||
const context = await t.query(api.signalRouting.getProjectContext, {
|
||||
organizationId: orgId,
|
||||
projectId,
|
||||
token: TOKEN,
|
||||
});
|
||||
|
||||
expect(context.project._id).toBe(projectId);
|
||||
expect(context.project.name).toBe("ctx-test");
|
||||
expect(context.contextDocuments.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("list projects returns organization projects", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t);
|
||||
const projectId = await createProject(t, ID_A, "list-proj-test");
|
||||
|
||||
const projects = await t.query(api.signalRouting.listProjects, {
|
||||
organizationId: orgId,
|
||||
token: TOKEN,
|
||||
});
|
||||
|
||||
expect(projects).toHaveLength(1);
|
||||
expect(projects[0]?._id).toBe(projectId);
|
||||
const stored = await t.run(async (ctx) => ({
|
||||
constraints: await ctx.db.query("signalConstraints").collect(),
|
||||
signal: await ctx.db.get(result.signalId),
|
||||
sources: await ctx.db.query("signalSources").collect(),
|
||||
}));
|
||||
expect(stored.signal).toMatchObject({
|
||||
desiredOutcome: "Deploy succeeds",
|
||||
summary: "The deploy is failing",
|
||||
title: "Fix deploy",
|
||||
});
|
||||
expect(stored.constraints.map((row) => row.value)).toEqual([
|
||||
"Keep production stable",
|
||||
]);
|
||||
expect(stored.sources[0]?.rawTextSnapshot).toBe("Fix the deploy");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,248 +1,115 @@
|
||||
import { env } from "@code/env/convex";
|
||||
import {
|
||||
projectIssueDraftFromSignal,
|
||||
queueProjectIssue,
|
||||
type ProjectIssueDraft,
|
||||
} from "@code/primitives/project-issue";
|
||||
import { composeSignal, SignalValidationError } from "@code/primitives/signal";
|
||||
import { ConvexError, v } from "convex/values";
|
||||
import { Effect } from "effect";
|
||||
|
||||
import type { Doc, Id } from "./_generated/dataModel";
|
||||
import {
|
||||
mutation,
|
||||
type MutationCtx,
|
||||
query,
|
||||
type QueryCtx,
|
||||
} from "./_generated/server";
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
import { mutation, query } from "./_generated/server";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent token guard (service-level auth for Zopu tools).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const requireAgent = (token: string) => {
|
||||
const requireAgent = (token: string): void => {
|
||||
if (token !== env.FLUE_DB_TOKEN) {
|
||||
throw new ConvexError("Invalid agent control token");
|
||||
}
|
||||
};
|
||||
|
||||
const PROBLEM_STATEMENT = v.object({
|
||||
title: v.string(),
|
||||
summary: v.string(),
|
||||
desiredOutcome: v.string(),
|
||||
constraints: v.array(v.string()),
|
||||
export const listProjects = query({
|
||||
args: { organizationId: v.id("organizations"), token: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
requireAgent(args.token);
|
||||
const projects = await ctx.db
|
||||
.query("projects")
|
||||
.withIndex("by_organizationId_and_createdAt", (q) =>
|
||||
q.eq("organizationId", args.organizationId)
|
||||
)
|
||||
.order("desc")
|
||||
.take(50);
|
||||
return projects.map((project) => ({
|
||||
_id: project._id,
|
||||
description: project.description ?? null,
|
||||
name: project.name,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// View helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface EvidenceMessage {
|
||||
readonly messageId: string;
|
||||
readonly rawText: string;
|
||||
readonly createdAt: number;
|
||||
readonly submissionId: string | null;
|
||||
}
|
||||
|
||||
interface ActiveIssueView {
|
||||
readonly _id: Id<"projectIssues">;
|
||||
readonly number: number;
|
||||
readonly title: string;
|
||||
readonly body: string;
|
||||
readonly status: string;
|
||||
readonly projectId: Id<"projects">;
|
||||
readonly updatedAt: number;
|
||||
}
|
||||
|
||||
interface ProjectSummaryView {
|
||||
readonly _id: Id<"projects">;
|
||||
readonly name: string;
|
||||
readonly description: string | null;
|
||||
readonly organizationId: Id<"organizations">;
|
||||
}
|
||||
|
||||
interface ContextDocumentView {
|
||||
readonly kind: string;
|
||||
readonly path: string;
|
||||
readonly content: string;
|
||||
readonly revision: number;
|
||||
}
|
||||
|
||||
interface SignalSummaryView {
|
||||
readonly _id: Id<"signals">;
|
||||
readonly problemStatement: {
|
||||
readonly title: string;
|
||||
readonly summary: string;
|
||||
readonly desiredOutcome: string;
|
||||
readonly constraints: readonly string[];
|
||||
};
|
||||
readonly projectId: Id<"projects"> | null;
|
||||
readonly createdAt: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Organization + project authorization (agent-gated, no user identity).
|
||||
// The agent instance id IS the organization id; the tool never supplies
|
||||
// tenancy. We verify the organization exists and the project belongs to it.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const authorizeProjectInOrg = async (
|
||||
ctx: MutationCtx | QueryCtx,
|
||||
organizationId: Id<"organizations">,
|
||||
projectId: Id<"projects">
|
||||
): Promise<Doc<"projects">> => {
|
||||
const project = await ctx.db.get(projectId);
|
||||
if (!project) {
|
||||
throw new ConvexError("Project not found");
|
||||
}
|
||||
if (project.organizationId !== organizationId) {
|
||||
throw new ConvexError("Project does not belong to the target organization");
|
||||
}
|
||||
return project;
|
||||
};
|
||||
|
||||
const authorizeSignalInOrg = async (
|
||||
ctx: MutationCtx | QueryCtx,
|
||||
organizationId: Id<"organizations">,
|
||||
signalId: Id<"signals">
|
||||
): Promise<Doc<"signals">> => {
|
||||
const signal = await ctx.db.get(signalId);
|
||||
if (!signal) {
|
||||
throw new ConvexError("Signal not found");
|
||||
}
|
||||
if (signal.organizationId !== organizationId) {
|
||||
throw new ConvexError("Signal does not belong to the target organization");
|
||||
}
|
||||
return signal;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. List admitted user messages not yet consumed by a Signal.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const listEvidence = query({
|
||||
args: {
|
||||
organizationId: v.id("organizations"),
|
||||
token: v.string(),
|
||||
},
|
||||
handler: async (ctx, args): Promise<EvidenceMessage[]> => {
|
||||
args: { organizationId: v.id("organizations"), token: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
requireAgent(args.token);
|
||||
const conversationId = args.organizationId;
|
||||
|
||||
const admitted = await ctx.db
|
||||
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_organization_and_message", (q) =>
|
||||
q
|
||||
.eq("organizationId", args.organizationId)
|
||||
.eq("conversationId", conversationId)
|
||||
.withIndex("by_conversationId_and_ordinal", (q) =>
|
||||
q.eq("conversationId", conversation._id)
|
||||
)
|
||||
.order("desc")
|
||||
.take(100);
|
||||
|
||||
const candidates = admitted.filter(
|
||||
(message) => message.role === "user" && message.status === "admitted"
|
||||
);
|
||||
|
||||
const unused: EvidenceMessage[] = [];
|
||||
for (const message of candidates) {
|
||||
const evidence: {
|
||||
messageId: string;
|
||||
rawText: string;
|
||||
createdAt: number;
|
||||
submissionId: string | null;
|
||||
}[] = [];
|
||||
for (const message of messages) {
|
||||
if (message.role !== "user") {
|
||||
continue;
|
||||
}
|
||||
const turn = await ctx.db.get(message.turnId);
|
||||
if (!turn || !["processing", "completed"].includes(turn.status)) {
|
||||
continue;
|
||||
}
|
||||
const consumed = await ctx.db
|
||||
.query("signalSources")
|
||||
.withIndex("by_organization_and_message", (q) =>
|
||||
q
|
||||
.eq("organizationId", args.organizationId)
|
||||
.eq("conversationId", conversationId)
|
||||
.eq("messageId", message.messageId)
|
||||
)
|
||||
.withIndex("by_messageId", (q) => q.eq("messageId", message._id))
|
||||
.first();
|
||||
if (!consumed) {
|
||||
unused.push({
|
||||
evidence.push({
|
||||
createdAt: message.createdAt,
|
||||
messageId: message.messageId,
|
||||
rawText: message.rawText,
|
||||
submissionId: message.submissionId ?? null,
|
||||
messageId: String(message._id),
|
||||
rawText: message.content,
|
||||
submissionId: turn.submissionId ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return unused;
|
||||
return evidence;
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Create a Signal from selected messages + structured problem statement.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const buildSourceKey = (
|
||||
organizationId: Id<"organizations">,
|
||||
conversationId: string,
|
||||
messageIds: readonly string[]
|
||||
): string => JSON.stringify([organizationId, conversationId, ...messageIds]);
|
||||
|
||||
const resolveSources = async (
|
||||
ctx: MutationCtx,
|
||||
organizationId: Id<"organizations">,
|
||||
conversationId: string,
|
||||
messageIds: readonly string[]
|
||||
): Promise<Doc<"conversationMessages">[]> => {
|
||||
if (messageIds.length === 0) {
|
||||
throw new ConvexError("At least one source message is required");
|
||||
}
|
||||
if (new Set(messageIds).size !== messageIds.length) {
|
||||
throw new ConvexError("Source message ids must be unique");
|
||||
}
|
||||
const resolved: Doc<"conversationMessages">[] = [];
|
||||
for (const messageId of messageIds) {
|
||||
const doc = await ctx.db
|
||||
.query("conversationMessages")
|
||||
.withIndex("by_organization_and_message", (q) =>
|
||||
q
|
||||
.eq("organizationId", organizationId)
|
||||
.eq("conversationId", conversationId)
|
||||
.eq("messageId", messageId)
|
||||
)
|
||||
.unique();
|
||||
if (!doc) {
|
||||
throw new ConvexError(`Source message not found: ${messageId}`);
|
||||
}
|
||||
if (doc.role !== "user") {
|
||||
throw new ConvexError(
|
||||
`Source message is not a user message: ${messageId}`
|
||||
);
|
||||
}
|
||||
if (doc.status !== "admitted") {
|
||||
throw new ConvexError(`Source message is not admitted: ${messageId}`);
|
||||
}
|
||||
resolved.push(doc);
|
||||
}
|
||||
return resolved;
|
||||
};
|
||||
|
||||
export const createSignal = mutation({
|
||||
args: {
|
||||
organizationId: v.id("organizations"),
|
||||
projectId: v.optional(v.id("projects")),
|
||||
projectId: v.id("projects"),
|
||||
messageIds: v.array(v.string()),
|
||||
problemStatement: PROBLEM_STATEMENT,
|
||||
problemStatement: v.object({
|
||||
title: v.string(),
|
||||
summary: v.string(),
|
||||
desiredOutcome: v.string(),
|
||||
constraints: v.array(v.string()),
|
||||
}),
|
||||
processedByAgentInstanceId: v.string(),
|
||||
token: v.string(),
|
||||
},
|
||||
handler: async (ctx, args): Promise<{ signalId: Id<"signals"> }> => {
|
||||
handler: async (ctx, args) => {
|
||||
requireAgent(args.token);
|
||||
if (args.projectId) {
|
||||
await authorizeProjectInOrg(ctx, args.organizationId, args.projectId);
|
||||
const project = await ctx.db.get(args.projectId);
|
||||
if (!project || project.organizationId !== args.organizationId) {
|
||||
throw new ConvexError("Project not found");
|
||||
}
|
||||
|
||||
const conversationId = args.organizationId;
|
||||
if (conversationId !== args.organizationId) {
|
||||
throw new ConvexError("Conversation does not belong to organization");
|
||||
const conversation = await ctx.db
|
||||
.query("conversations")
|
||||
.withIndex("by_organizationId", (q) =>
|
||||
q.eq("organizationId", args.organizationId)
|
||||
)
|
||||
.unique();
|
||||
if (!conversation || args.messageIds.length === 0) {
|
||||
throw new ConvexError("Signal evidence is required");
|
||||
}
|
||||
|
||||
const sourceKey = buildSourceKey(
|
||||
args.organizationId,
|
||||
conversationId,
|
||||
args.messageIds
|
||||
);
|
||||
|
||||
const sourceKey = JSON.stringify(args.messageIds);
|
||||
const existing = await ctx.db
|
||||
.query("signals")
|
||||
.withIndex("by_organization_and_sourceKey", (q) =>
|
||||
@@ -253,518 +120,54 @@ export const createSignal = mutation({
|
||||
return { signalId: existing._id };
|
||||
}
|
||||
|
||||
const sources = await resolveSources(
|
||||
ctx,
|
||||
args.organizationId,
|
||||
conversationId,
|
||||
args.messageIds
|
||||
);
|
||||
|
||||
const now = Date.now();
|
||||
const prospectiveId = crypto.randomUUID();
|
||||
const scope =
|
||||
args.projectId === undefined
|
||||
? { _tag: "Organization" as const, organizationId: args.organizationId }
|
||||
: {
|
||||
_tag: "Project" as const,
|
||||
organizationId: args.organizationId,
|
||||
projectId: args.projectId,
|
||||
};
|
||||
|
||||
const signal = await Effect.runPromise(
|
||||
composeSignal({
|
||||
createdAt: now,
|
||||
id: prospectiveId,
|
||||
problemStatement: args.problemStatement,
|
||||
processedBy: {
|
||||
agentInstanceId: args.processedByAgentInstanceId,
|
||||
agentName: "zopu",
|
||||
},
|
||||
scope,
|
||||
sourceMessages: sources.map((doc) => ({
|
||||
conversationId: doc.conversationId,
|
||||
createdAt: doc.createdAt,
|
||||
messageId: doc.messageId,
|
||||
rawText: doc.rawText,
|
||||
})),
|
||||
})
|
||||
).catch((error: unknown) => {
|
||||
if (error instanceof SignalValidationError) {
|
||||
throw new ConvexError(`Invalid signal: ${error.message}`);
|
||||
const messages = [];
|
||||
for (const messageId of args.messageIds) {
|
||||
const message = await ctx.db.get(messageId as Id<"conversationMessages">);
|
||||
if (
|
||||
!message ||
|
||||
message.conversationId !== conversation._id ||
|
||||
message.role !== "user"
|
||||
) {
|
||||
throw new ConvexError(`Source message not found: ${messageId}`);
|
||||
}
|
||||
throw new ConvexError(
|
||||
`Invalid signal: ${error instanceof Error ? error.message : "unknown"}`
|
||||
);
|
||||
});
|
||||
const turn = await ctx.db.get(message.turnId);
|
||||
if (!turn || !["processing", "completed"].includes(turn.status)) {
|
||||
throw new ConvexError(`Source message is not admitted: ${messageId}`);
|
||||
}
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
const signalId = await ctx.db.insert("signals", {
|
||||
conversationId,
|
||||
createdAt: now,
|
||||
conversationId: conversation._id,
|
||||
createdAt: Date.now(),
|
||||
desiredOutcome: args.problemStatement.desiredOutcome,
|
||||
organizationId: args.organizationId,
|
||||
problemStatement: {
|
||||
constraints: [...signal.problemStatement.constraints],
|
||||
desiredOutcome: signal.problemStatement.desiredOutcome,
|
||||
summary: signal.problemStatement.summary,
|
||||
title: signal.problemStatement.title,
|
||||
},
|
||||
processedByAgentInstanceId: args.processedByAgentInstanceId,
|
||||
processedByAgentName: "zopu",
|
||||
...(args.projectId === undefined ? {} : { projectId: args.projectId }),
|
||||
projectId: args.projectId,
|
||||
sourceKey,
|
||||
summary: args.problemStatement.summary,
|
||||
title: args.problemStatement.title,
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
sources.map((doc, ordinal) =>
|
||||
ctx.db.insert("signalSources", {
|
||||
conversationId: doc.conversationId,
|
||||
messageId: doc.messageId,
|
||||
ordinal,
|
||||
organizationId: args.organizationId,
|
||||
rawTextSnapshot: doc.rawText,
|
||||
signalId,
|
||||
...(doc.submissionId === undefined
|
||||
? {}
|
||||
: { submissionId: doc.submissionId }),
|
||||
sourceCreatedAt: doc.createdAt,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
for (const [
|
||||
ordinal,
|
||||
constraint,
|
||||
] of args.problemStatement.constraints.entries()) {
|
||||
await ctx.db.insert("signalConstraints", {
|
||||
ordinal,
|
||||
signalId,
|
||||
value: constraint,
|
||||
});
|
||||
}
|
||||
for (const [ordinal, message] of messages.entries()) {
|
||||
await ctx.db.insert("signalSources", {
|
||||
messageId: message._id,
|
||||
ordinal,
|
||||
rawTextSnapshot: message.content,
|
||||
signalId,
|
||||
sourceCreatedAt: message.createdAt,
|
||||
});
|
||||
}
|
||||
return { signalId };
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. List recent Signals for a project (or organization-wide when no project).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const listSignals = query({
|
||||
args: {
|
||||
organizationId: v.id("organizations"),
|
||||
projectId: v.optional(v.id("projects")),
|
||||
token: v.string(),
|
||||
},
|
||||
handler: async (ctx, args): Promise<SignalSummaryView[]> => {
|
||||
requireAgent(args.token);
|
||||
if (args.projectId) {
|
||||
await authorizeProjectInOrg(ctx, args.organizationId, args.projectId);
|
||||
const signals = await ctx.db
|
||||
.query("signals")
|
||||
.withIndex("by_project_and_createdAt", (q) =>
|
||||
q.eq("projectId", args.projectId as Id<"projects">)
|
||||
)
|
||||
.order("desc")
|
||||
.take(20);
|
||||
return signals.map((s) => ({
|
||||
_id: s._id,
|
||||
createdAt: s.createdAt,
|
||||
problemStatement: s.problemStatement,
|
||||
projectId: s.projectId ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
const signals = await ctx.db
|
||||
.query("signals")
|
||||
.withIndex("by_organization_and_createdAt", (q) =>
|
||||
q.eq("organizationId", args.organizationId)
|
||||
)
|
||||
.order("desc")
|
||||
.take(20);
|
||||
return signals.map((s) => ({
|
||||
_id: s._id,
|
||||
createdAt: s.createdAt,
|
||||
problemStatement: s.problemStatement,
|
||||
projectId: s.projectId ?? null,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. List active (open/queued/working/needs-input) ProjectIssues for a project.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const listActiveIssues = query({
|
||||
args: {
|
||||
organizationId: v.id("organizations"),
|
||||
projectId: v.id("projects"),
|
||||
token: v.string(),
|
||||
},
|
||||
handler: async (ctx, args): Promise<ActiveIssueView[]> => {
|
||||
requireAgent(args.token);
|
||||
await authorizeProjectInOrg(ctx, args.organizationId, args.projectId);
|
||||
|
||||
const issues = await ctx.db
|
||||
.query("projectIssues")
|
||||
.withIndex("by_project_and_status", (q) =>
|
||||
q.eq("projectId", args.projectId)
|
||||
)
|
||||
.take(100);
|
||||
|
||||
return issues
|
||||
.filter(
|
||||
(issue) =>
|
||||
issue.status === "open" ||
|
||||
issue.status === "queued" ||
|
||||
issue.status === "working" ||
|
||||
issue.status === "needs-input"
|
||||
)
|
||||
.map((issue) => ({
|
||||
_id: issue._id,
|
||||
body: issue.body,
|
||||
number: issue.number,
|
||||
projectId: issue.projectId,
|
||||
status: issue.status,
|
||||
title: issue.title,
|
||||
updatedAt: issue.updatedAt,
|
||||
}))
|
||||
.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Attach a Signal to an existing ProjectIssue (idempotent).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const attachSignalToIssue = mutation({
|
||||
args: {
|
||||
organizationId: v.id("organizations"),
|
||||
signalId: v.id("signals"),
|
||||
issueId: v.id("projectIssues"),
|
||||
token: v.string(),
|
||||
},
|
||||
handler: async (
|
||||
ctx,
|
||||
args
|
||||
): Promise<{
|
||||
attachmentId: Id<"signalIssueAttachments">;
|
||||
alreadyAttached: boolean;
|
||||
}> => {
|
||||
requireAgent(args.token);
|
||||
const signal = await authorizeSignalInOrg(
|
||||
ctx,
|
||||
args.organizationId,
|
||||
args.signalId
|
||||
);
|
||||
const issue = await ctx.db.get(args.issueId);
|
||||
if (!issue) {
|
||||
throw new ConvexError("Issue not found");
|
||||
}
|
||||
await authorizeProjectInOrg(ctx, args.organizationId, issue.projectId);
|
||||
|
||||
// Cross-project rejection: the signal must be project-scoped and its
|
||||
// project must match the issue's project. Organization equality alone is
|
||||
// insufficient — it would permit attaching across different projects in
|
||||
// the same org.
|
||||
if (!signal.projectId) {
|
||||
throw new ConvexError("Signal is not project-scoped");
|
||||
}
|
||||
if (signal.projectId !== issue.projectId) {
|
||||
throw new ConvexError("Signal and issue must belong to the same project");
|
||||
}
|
||||
|
||||
// Idempotent: if the attachment already exists, return it without
|
||||
// emitting a duplicate event.
|
||||
const existing = await ctx.db
|
||||
.query("signalIssueAttachments")
|
||||
.withIndex("by_signal_and_issue", (q) =>
|
||||
q.eq("signalId", args.signalId).eq("issueId", args.issueId)
|
||||
)
|
||||
.unique();
|
||||
if (existing) {
|
||||
return {
|
||||
alreadyAttached: true,
|
||||
attachmentId: existing._id,
|
||||
};
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const attachmentId = await ctx.db.insert("signalIssueAttachments", {
|
||||
createdAt: now,
|
||||
issueId: args.issueId,
|
||||
organizationId: args.organizationId,
|
||||
projectId: issue.projectId,
|
||||
signalId: args.signalId,
|
||||
});
|
||||
|
||||
await ctx.db.insert("projectEvents", {
|
||||
createdAt: now,
|
||||
data: {
|
||||
signalProblemTitle: signal.problemStatement.title,
|
||||
signalId: String(signal._id),
|
||||
},
|
||||
issueId: args.issueId,
|
||||
kind: "signal.attached",
|
||||
projectId: issue.projectId,
|
||||
});
|
||||
|
||||
return { alreadyAttached: false, attachmentId };
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. Create a new ProjectIssue from a Signal.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const insertIssue = async (
|
||||
ctx: MutationCtx,
|
||||
projectId: Id<"projects">,
|
||||
draft: ProjectIssueDraft
|
||||
): Promise<Id<"projectIssues">> => {
|
||||
const latest = await ctx.db
|
||||
.query("projectIssues")
|
||||
.withIndex("by_project_and_number", (q) => q.eq("projectId", projectId))
|
||||
.order("desc")
|
||||
.first();
|
||||
const timestamp = Date.now();
|
||||
const number = (latest?.number ?? 0) + 1;
|
||||
const issueId = await ctx.db.insert("projectIssues", {
|
||||
body: draft.body,
|
||||
createdAt: timestamp,
|
||||
number,
|
||||
projectId,
|
||||
status: "open",
|
||||
title: draft.title,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
await ctx.db.insert("projectEvents", {
|
||||
createdAt: timestamp,
|
||||
data: { number, source: "signal", title: draft.title },
|
||||
issueId,
|
||||
kind: "issue.created",
|
||||
projectId,
|
||||
});
|
||||
return issueId;
|
||||
};
|
||||
|
||||
export const createIssueFromSignal = mutation({
|
||||
args: {
|
||||
organizationId: v.id("organizations"),
|
||||
signalId: v.id("signals"),
|
||||
token: v.string(),
|
||||
},
|
||||
handler: async (
|
||||
ctx,
|
||||
args
|
||||
): Promise<{
|
||||
created: boolean;
|
||||
issueId: Id<"projectIssues">;
|
||||
projectId: Id<"projects">;
|
||||
}> => {
|
||||
requireAgent(args.token);
|
||||
const signal = await authorizeSignalInOrg(
|
||||
ctx,
|
||||
args.organizationId,
|
||||
args.signalId
|
||||
);
|
||||
if (!signal.projectId) {
|
||||
throw new ConvexError("Signal is not project-scoped");
|
||||
}
|
||||
await authorizeProjectInOrg(ctx, args.organizationId, signal.projectId);
|
||||
|
||||
// Idempotent: if any attachment already exists for this signal, return
|
||||
// the existing issue without creating a duplicate. This prevents Flue
|
||||
// retries from producing duplicate issues/attachments.
|
||||
const existingAttachment = await ctx.db
|
||||
.query("signalIssueAttachments")
|
||||
.withIndex("by_signal", (q) => q.eq("signalId", args.signalId))
|
||||
.first();
|
||||
if (existingAttachment) {
|
||||
return {
|
||||
created: false,
|
||||
issueId: existingAttachment.issueId,
|
||||
projectId: existingAttachment.projectId,
|
||||
};
|
||||
}
|
||||
|
||||
const draft = await Effect.runPromise(
|
||||
projectIssueDraftFromSignal({
|
||||
problemStatement: signal.problemStatement,
|
||||
signalId: String(signal._id),
|
||||
})
|
||||
).catch((error: unknown) => {
|
||||
throw new ConvexError(
|
||||
error instanceof Error ? error.message : "Invalid project issue"
|
||||
);
|
||||
});
|
||||
|
||||
const issueId = await insertIssue(ctx, signal.projectId, draft);
|
||||
|
||||
// Auto-attach the signal to the new issue.
|
||||
const now = Date.now();
|
||||
await ctx.db.insert("signalIssueAttachments", {
|
||||
createdAt: now,
|
||||
issueId,
|
||||
organizationId: args.organizationId,
|
||||
projectId: signal.projectId,
|
||||
signalId: signal._id,
|
||||
});
|
||||
|
||||
return { created: true, issueId, projectId: signal.projectId };
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 7. Begin a ProjectIssue (transition to queued/working).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const beginIssue = mutation({
|
||||
args: {
|
||||
organizationId: v.id("organizations"),
|
||||
issueId: v.id("projectIssues"),
|
||||
token: v.string(),
|
||||
},
|
||||
handler: async (
|
||||
ctx,
|
||||
args
|
||||
): Promise<{
|
||||
dispatchRequired: boolean;
|
||||
projectId: Id<"projects">;
|
||||
status: "queued" | "working";
|
||||
}> => {
|
||||
requireAgent(args.token);
|
||||
const issue = await ctx.db.get(args.issueId);
|
||||
if (!issue) {
|
||||
throw new ConvexError("Issue not found");
|
||||
}
|
||||
await authorizeProjectInOrg(ctx, args.organizationId, issue.projectId);
|
||||
|
||||
const status = await Effect.runPromise(
|
||||
queueProjectIssue(issue.status)
|
||||
).catch((error: unknown) => {
|
||||
throw new ConvexError(
|
||||
error instanceof Error ? error.message : "Invalid issue status"
|
||||
);
|
||||
});
|
||||
if (status === issue.status) {
|
||||
return {
|
||||
dispatchRequired: false,
|
||||
projectId: issue.projectId,
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
const timestamp = Date.now();
|
||||
await ctx.db.patch(issue._id, {
|
||||
status,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
await ctx.db.insert("projectEvents", {
|
||||
createdAt: timestamp,
|
||||
data: { number: issue.number },
|
||||
issueId: issue._id,
|
||||
kind: "issue.queued",
|
||||
projectId: issue.projectId,
|
||||
});
|
||||
return {
|
||||
dispatchRequired: true,
|
||||
projectId: issue.projectId,
|
||||
status,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 8. Mark a failed Flue dispatch so retrying can safely queue it again.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const markDispatchFailed = mutation({
|
||||
args: {
|
||||
error: v.string(),
|
||||
issueId: v.id("projectIssues"),
|
||||
organizationId: v.id("organizations"),
|
||||
token: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
requireAgent(args.token);
|
||||
const issue = await ctx.db.get(args.issueId);
|
||||
if (!issue) {
|
||||
throw new ConvexError("Issue not found");
|
||||
}
|
||||
await authorizeProjectInOrg(ctx, args.organizationId, issue.projectId);
|
||||
const timestamp = Date.now();
|
||||
await ctx.db.patch(issue._id, {
|
||||
status: "failed",
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
await ctx.db.insert("projectEvents", {
|
||||
createdAt: timestamp,
|
||||
data: { error: args.error.slice(0, 1000), phase: "dispatch" },
|
||||
issueId: issue._id,
|
||||
kind: "agent.failed",
|
||||
projectId: issue.projectId,
|
||||
});
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 9. Get project summary + context documents for routing decisions.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const getProjectContext = query({
|
||||
args: {
|
||||
organizationId: v.id("organizations"),
|
||||
projectId: v.id("projects"),
|
||||
token: v.string(),
|
||||
},
|
||||
handler: async (
|
||||
ctx,
|
||||
args
|
||||
): Promise<{
|
||||
project: ProjectSummaryView;
|
||||
contextDocuments: ContextDocumentView[];
|
||||
}> => {
|
||||
requireAgent(args.token);
|
||||
const project = await authorizeProjectInOrg(
|
||||
ctx,
|
||||
args.organizationId,
|
||||
args.projectId
|
||||
);
|
||||
|
||||
const docs = await ctx.db
|
||||
.query("projectContextDocuments")
|
||||
.withIndex("by_project", (q) => q.eq("projectId", args.projectId))
|
||||
.take(25);
|
||||
|
||||
return {
|
||||
contextDocuments: docs.map((doc) => ({
|
||||
content: doc.content,
|
||||
kind: doc.kind,
|
||||
path: doc.path,
|
||||
revision: doc.revision,
|
||||
})),
|
||||
project: {
|
||||
_id: project._id,
|
||||
description: project.description ?? null,
|
||||
name: project.name,
|
||||
organizationId: project.organizationId,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 9. List projects in the organization (for selecting a project context).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const listProjects = query({
|
||||
args: {
|
||||
organizationId: v.id("organizations"),
|
||||
token: v.string(),
|
||||
},
|
||||
handler: async (ctx, args): Promise<ProjectSummaryView[]> => {
|
||||
requireAgent(args.token);
|
||||
const projects = await ctx.db
|
||||
.query("projects")
|
||||
.withIndex("by_organization_and_createdAt", (q) =>
|
||||
q.eq("organizationId", args.organizationId)
|
||||
)
|
||||
.take(50);
|
||||
return projects.map((p) => ({
|
||||
_id: p._id,
|
||||
description: p.description ?? null,
|
||||
name: p.name,
|
||||
organizationId: p.organizationId,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,620 +0,0 @@
|
||||
import { type TestConvex, convexTest } from "convex-test";
|
||||
import { anyApi } from "convex/server";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { internal } from "./_generated/api";
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
import schema from "./schema";
|
||||
|
||||
// `import.meta.glob` is provided by the Vitest/Vite test runner at runtime; it
|
||||
// has no type definition under the Convex tsconfig (which scopes `types` to
|
||||
// node), so declare the minimal shape the test relies on.
|
||||
declare global {
|
||||
interface ImportMeta {
|
||||
readonly glob: (pattern: string) => Record<string, () => Promise<unknown>>;
|
||||
}
|
||||
}
|
||||
|
||||
const modules = import.meta.glob("./**/*.ts");
|
||||
const api = anyApi;
|
||||
|
||||
const ID_A = "https://convex.test|user-a";
|
||||
const ID_B = "https://convex.test|user-b";
|
||||
const identityA = { tokenIdentifier: ID_A };
|
||||
const identityB = { tokenIdentifier: ID_B };
|
||||
|
||||
const newTest = () => convexTest({ schema, modules });
|
||||
|
||||
const ensureOrg = async (
|
||||
t: TestConvex<typeof schema>,
|
||||
identity: { readonly tokenIdentifier: string }
|
||||
) => {
|
||||
const org = await t
|
||||
.withIdentity(identity)
|
||||
.mutation(api.organizations.ensurePersonalOrganization, {});
|
||||
return org._id as Id<"organizations">;
|
||||
};
|
||||
|
||||
const problemStatement = {
|
||||
title: "Deploy fails on staging",
|
||||
summary: "The staging deploy command exits with a DNS resolution error.",
|
||||
desiredOutcome: "Staging deploys successfully without DNS errors.",
|
||||
constraints: ["Must not change the production pipeline"],
|
||||
};
|
||||
|
||||
const processedBy = { agentInstanceId: "zopu-instance-1", agentName: "zopu" };
|
||||
|
||||
// Begin and admit one user message, returning its messageId.
|
||||
const seedMessage = async (
|
||||
t: TestConvex<typeof schema>,
|
||||
identity: { readonly tokenIdentifier: string },
|
||||
orgId: Id<"organizations">,
|
||||
clientRequestId: string,
|
||||
rawText: string
|
||||
): Promise<string> => {
|
||||
const begun = await t
|
||||
.withIdentity(identity)
|
||||
.mutation(api.conversationMessages.beginUserMessage, {
|
||||
clientRequestId,
|
||||
organizationId: orgId,
|
||||
rawText,
|
||||
});
|
||||
await t
|
||||
.withIdentity(identity)
|
||||
.mutation(api.conversationMessages.markAdmitted, {
|
||||
clientRequestId,
|
||||
organizationId: orgId,
|
||||
submissionId: `sub-${clientRequestId}`,
|
||||
});
|
||||
return begun.messageId;
|
||||
};
|
||||
|
||||
const createProject = async (
|
||||
t: TestConvex<typeof schema>,
|
||||
ownerId: string
|
||||
): Promise<Id<"projects">> => {
|
||||
// Resolve or create the owner's personal organization
|
||||
const ownerIdentity = { tokenIdentifier: ownerId };
|
||||
await t
|
||||
.withIdentity(ownerIdentity)
|
||||
.mutation(api.organizations.ensurePersonalOrganization);
|
||||
const outcome = await t
|
||||
.withIdentity(ownerIdentity)
|
||||
.mutation(internal.projects.persistPublicGitImport, {
|
||||
userId: ownerId,
|
||||
source: {
|
||||
host: "github.com",
|
||||
projectName: "test-repo",
|
||||
repositoryPath: `owner-${ownerId.slice(-4)}/test-repo`,
|
||||
normalizedUrl: `https://github.com/owner-${ownerId.slice(-4)}/test-repo`,
|
||||
url: `https://github.com/owner-${ownerId.slice(-4)}/test-repo`,
|
||||
},
|
||||
remote: {
|
||||
defaultBranch: "main",
|
||||
documents: [
|
||||
{
|
||||
kind: "readme" as const,
|
||||
path: "README.md",
|
||||
content: "# test-repo\n\nTest.\n",
|
||||
},
|
||||
],
|
||||
warnings: [],
|
||||
},
|
||||
});
|
||||
return outcome.id as unknown as Id<"projects">;
|
||||
};
|
||||
|
||||
describe("signals", () => {
|
||||
test("unauthenticated create is rejected", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t, identityA);
|
||||
|
||||
await expect(
|
||||
t.mutation(api.signals.createFromMessages, {
|
||||
conversationId: orgId,
|
||||
messageIds: ["m1"],
|
||||
organizationId: orgId,
|
||||
problemStatement,
|
||||
processedBy,
|
||||
})
|
||||
).rejects.toThrow(/Authentication required/u);
|
||||
});
|
||||
|
||||
test("creates one Signal from a single admitted user message", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t, identityA);
|
||||
const rawText = " I cannot deploy to staging ";
|
||||
const messageId = await seedMessage(t, identityA, orgId, "req-1", rawText);
|
||||
|
||||
const result = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.signals.createFromMessages, {
|
||||
conversationId: orgId,
|
||||
messageIds: [messageId],
|
||||
organizationId: orgId,
|
||||
problemStatement,
|
||||
processedBy,
|
||||
});
|
||||
|
||||
const composed = await t
|
||||
.withIdentity(identityA)
|
||||
.query(api.signals.get, { signalId: result.signalId });
|
||||
|
||||
expect(composed).not.toBeNull();
|
||||
expect(composed?.signal.organizationId).toBe(orgId);
|
||||
expect(composed?.signal.projectId).toBeNull();
|
||||
expect(composed?.signal.conversationId).toBe(orgId);
|
||||
expect(composed?.sources).toHaveLength(1);
|
||||
expect(composed?.sources[0]?.messageId).toBe(messageId);
|
||||
// Exact raw snapshot preserved verbatim (whitespace intact).
|
||||
expect(composed?.sources[0]?.rawTextSnapshot).toBe(rawText);
|
||||
expect(composed?.sources[0]?.submissionId).toBe("sub-req-1");
|
||||
expect(composed?.sources[0]?.ordinal).toBe(0);
|
||||
});
|
||||
|
||||
test("composes several ordered user messages into one Signal", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t, identityA);
|
||||
const m1 = await seedMessage(t, identityA, orgId, "req-a", "first message");
|
||||
const m2 = await seedMessage(
|
||||
t,
|
||||
identityA,
|
||||
orgId,
|
||||
"req-b",
|
||||
"second message"
|
||||
);
|
||||
const m3 = await seedMessage(t, identityA, orgId, "req-c", "third message");
|
||||
|
||||
const result = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.signals.createFromMessages, {
|
||||
conversationId: orgId,
|
||||
messageIds: [m1, m2, m3],
|
||||
organizationId: orgId,
|
||||
problemStatement,
|
||||
processedBy,
|
||||
});
|
||||
|
||||
const composed = await t
|
||||
.withIdentity(identityA)
|
||||
.query(api.signals.get, { signalId: result.signalId });
|
||||
|
||||
expect(composed?.sources).toHaveLength(3);
|
||||
expect(composed?.sources[0]?.messageId).toBe(m1);
|
||||
expect(composed?.sources[0]?.ordinal).toBe(0);
|
||||
expect(composed?.sources[1]?.messageId).toBe(m2);
|
||||
expect(composed?.sources[1]?.ordinal).toBe(1);
|
||||
expect(composed?.sources[2]?.messageId).toBe(m3);
|
||||
expect(composed?.sources[2]?.ordinal).toBe(2);
|
||||
expect(
|
||||
composed?.sources.map(
|
||||
(s: { rawTextSnapshot: string }) => s.rawTextSnapshot
|
||||
)
|
||||
).toEqual(["first message", "second message", "third message"]);
|
||||
});
|
||||
|
||||
test("preserves exact raw snapshots and a separate structured problem statement", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t, identityA);
|
||||
const rawText = "\n\t leading and trailing \n";
|
||||
const messageId = await seedMessage(
|
||||
t,
|
||||
identityA,
|
||||
orgId,
|
||||
"req-exact",
|
||||
rawText
|
||||
);
|
||||
|
||||
const result = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.signals.createFromMessages, {
|
||||
conversationId: orgId,
|
||||
messageIds: [messageId],
|
||||
organizationId: orgId,
|
||||
problemStatement,
|
||||
processedBy,
|
||||
});
|
||||
|
||||
const composed = await t
|
||||
.withIdentity(identityA)
|
||||
.query(api.signals.get, { signalId: result.signalId });
|
||||
|
||||
// Raw evidence is exact and untouched.
|
||||
expect(composed?.sources[0]?.rawTextSnapshot).toBe(rawText);
|
||||
// The structured problem statement is stored separately.
|
||||
expect(composed?.signal.problemStatement).toEqual(problemStatement);
|
||||
});
|
||||
|
||||
test("rejects cross-organization messages", async () => {
|
||||
const t = newTest();
|
||||
const orgA = await ensureOrg(t, identityA);
|
||||
const orgB = await ensureOrg(t, identityB);
|
||||
// Message belongs to org A.
|
||||
const messageId = await seedMessage(
|
||||
t,
|
||||
identityA,
|
||||
orgA,
|
||||
"req-x",
|
||||
"secret A"
|
||||
);
|
||||
|
||||
// User B tries to build a signal under org B referencing org A's message.
|
||||
await expect(
|
||||
t.withIdentity(identityB).mutation(api.signals.createFromMessages, {
|
||||
conversationId: orgB,
|
||||
messageIds: [messageId],
|
||||
organizationId: orgB,
|
||||
problemStatement,
|
||||
processedBy,
|
||||
})
|
||||
).rejects.toThrow(/Source message not found/u);
|
||||
});
|
||||
|
||||
test("rejects cross-conversation mismatch", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t, identityA);
|
||||
const messageId = await seedMessage(t, identityA, orgId, "req-conv", "hi");
|
||||
|
||||
await expect(
|
||||
t.withIdentity(identityA).mutation(api.signals.createFromMessages, {
|
||||
conversationId: "different-conversation",
|
||||
messageIds: [messageId],
|
||||
organizationId: orgId,
|
||||
problemStatement,
|
||||
processedBy,
|
||||
})
|
||||
).rejects.toThrow(/Conversation does not belong to organization/u);
|
||||
});
|
||||
|
||||
test("rejects a failed (non-admitted) message", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t, identityA);
|
||||
|
||||
// Begin then fail a message.
|
||||
const begun = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.conversationMessages.beginUserMessage, {
|
||||
clientRequestId: "req-failed",
|
||||
organizationId: orgId,
|
||||
rawText: "doomed",
|
||||
});
|
||||
await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.conversationMessages.markFailed, {
|
||||
clientRequestId: "req-failed",
|
||||
organizationId: orgId,
|
||||
});
|
||||
|
||||
await expect(
|
||||
t.withIdentity(identityA).mutation(api.signals.createFromMessages, {
|
||||
conversationId: orgId,
|
||||
messageIds: [begun.messageId],
|
||||
organizationId: orgId,
|
||||
problemStatement,
|
||||
processedBy,
|
||||
})
|
||||
).rejects.toThrow(/not admitted/u);
|
||||
});
|
||||
|
||||
test("rejects a missing message id", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t, identityA);
|
||||
|
||||
await expect(
|
||||
t.withIdentity(identityA).mutation(api.signals.createFromMessages, {
|
||||
conversationId: orgId,
|
||||
messageIds: ["nonexistent-id"],
|
||||
organizationId: orgId,
|
||||
problemStatement,
|
||||
processedBy,
|
||||
})
|
||||
).rejects.toThrow(/Source message not found/u);
|
||||
});
|
||||
|
||||
test("rejects duplicate message ids in the selection", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t, identityA);
|
||||
const messageId = await seedMessage(t, identityA, orgId, "req-dup", "dup");
|
||||
|
||||
await expect(
|
||||
t.withIdentity(identityA).mutation(api.signals.createFromMessages, {
|
||||
conversationId: orgId,
|
||||
messageIds: [messageId, messageId],
|
||||
organizationId: orgId,
|
||||
problemStatement,
|
||||
processedBy,
|
||||
})
|
||||
).rejects.toThrow(/must be unique/u);
|
||||
});
|
||||
|
||||
test("rejects an empty message selection", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t, identityA);
|
||||
|
||||
await expect(
|
||||
t.withIdentity(identityA).mutation(api.signals.createFromMessages, {
|
||||
conversationId: orgId,
|
||||
messageIds: [],
|
||||
organizationId: orgId,
|
||||
problemStatement,
|
||||
processedBy,
|
||||
})
|
||||
).rejects.toThrow(/At least one source message is required/u);
|
||||
});
|
||||
|
||||
test("idempotent retry returns the same signal without duplicate sources", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t, identityA);
|
||||
const m1 = await seedMessage(t, identityA, orgId, "req-idem-1", "a");
|
||||
const m2 = await seedMessage(t, identityA, orgId, "req-idem-2", "b");
|
||||
|
||||
const first = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.signals.createFromMessages, {
|
||||
conversationId: orgId,
|
||||
messageIds: [m1, m2],
|
||||
organizationId: orgId,
|
||||
problemStatement,
|
||||
processedBy,
|
||||
});
|
||||
|
||||
// Retry with the same ordered selection.
|
||||
const second = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.signals.createFromMessages, {
|
||||
conversationId: orgId,
|
||||
messageIds: [m1, m2],
|
||||
organizationId: orgId,
|
||||
problemStatement,
|
||||
processedBy,
|
||||
});
|
||||
|
||||
expect(second.signalId).toBe(first.signalId);
|
||||
|
||||
// Exactly one signal and two sources exist.
|
||||
const list = await t
|
||||
.withIdentity(identityA)
|
||||
.query(api.signals.list, { organizationId: orgId });
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0]?.sources).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("different ordered selections produce different signals", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t, identityA);
|
||||
const m1 = await seedMessage(t, identityA, orgId, "req-ord-1", "x");
|
||||
const m2 = await seedMessage(t, identityA, orgId, "req-ord-2", "y");
|
||||
|
||||
const first = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.signals.createFromMessages, {
|
||||
conversationId: orgId,
|
||||
messageIds: [m1, m2],
|
||||
organizationId: orgId,
|
||||
problemStatement,
|
||||
processedBy,
|
||||
});
|
||||
const second = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.signals.createFromMessages, {
|
||||
conversationId: orgId,
|
||||
messageIds: [m2, m1],
|
||||
organizationId: orgId,
|
||||
problemStatement,
|
||||
processedBy,
|
||||
});
|
||||
|
||||
expect(second.signalId).not.toBe(first.signalId);
|
||||
|
||||
const list = await t
|
||||
.withIdentity(identityA)
|
||||
.query(api.signals.list, { organizationId: orgId });
|
||||
expect(list).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("reversed selection preserves agent ordinals and original timestamps", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t, identityA);
|
||||
const m1 = await seedMessage(t, identityA, orgId, "req-rev-1", "first");
|
||||
const m2 = await seedMessage(t, identityA, orgId, "req-rev-2", "second");
|
||||
|
||||
// Chronological selection: m1 then m2.
|
||||
const chrono = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.signals.createFromMessages, {
|
||||
conversationId: orgId,
|
||||
messageIds: [m1, m2],
|
||||
organizationId: orgId,
|
||||
problemStatement,
|
||||
processedBy,
|
||||
});
|
||||
|
||||
// Reversed selection: m2 then m1 (non-chronological agent order).
|
||||
const reversed = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.signals.createFromMessages, {
|
||||
conversationId: orgId,
|
||||
messageIds: [m2, m1],
|
||||
organizationId: orgId,
|
||||
problemStatement,
|
||||
processedBy,
|
||||
});
|
||||
|
||||
expect(reversed.signalId).not.toBe(chrono.signalId);
|
||||
|
||||
const chronoSignal = await t
|
||||
.withIdentity(identityA)
|
||||
.query(api.signals.get, { signalId: chrono.signalId });
|
||||
const reversedSignal = await t
|
||||
.withIdentity(identityA)
|
||||
.query(api.signals.get, { signalId: reversed.signalId });
|
||||
|
||||
// Chronological: ordinals follow selection.
|
||||
expect(chronoSignal?.sources[0]?.messageId).toBe(m1);
|
||||
expect(chronoSignal?.sources[0]?.ordinal).toBe(0);
|
||||
expect(chronoSignal?.sources[1]?.messageId).toBe(m2);
|
||||
expect(chronoSignal?.sources[1]?.ordinal).toBe(1);
|
||||
|
||||
// Reversed: ordinals follow the agent's reversed selection, and each
|
||||
// source retains its original creation timestamp (unchanged by reordering).
|
||||
expect(reversedSignal?.sources[0]?.messageId).toBe(m2);
|
||||
expect(reversedSignal?.sources[0]?.ordinal).toBe(0);
|
||||
expect(reversedSignal?.sources[1]?.messageId).toBe(m1);
|
||||
expect(reversedSignal?.sources[1]?.ordinal).toBe(1);
|
||||
|
||||
// Exact raw text is preserved in both orderings.
|
||||
expect(
|
||||
chronoSignal?.sources.map(
|
||||
(s: { rawTextSnapshot: string }) => s.rawTextSnapshot
|
||||
)
|
||||
).toEqual(["first", "second"]);
|
||||
expect(
|
||||
reversedSignal?.sources.map(
|
||||
(s: { rawTextSnapshot: string }) => s.rawTextSnapshot
|
||||
)
|
||||
).toEqual(["second", "first"]);
|
||||
});
|
||||
|
||||
test("creates a project-scoped signal when the project owner is an org member", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t, identityA);
|
||||
// Project owned by user A, who is a member of orgId.
|
||||
const projectId = await createProject(t, ID_A);
|
||||
const messageId = await seedMessage(
|
||||
t,
|
||||
identityA,
|
||||
orgId,
|
||||
"req-proj",
|
||||
"build"
|
||||
);
|
||||
|
||||
const result = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.signals.createFromMessages, {
|
||||
conversationId: orgId,
|
||||
messageIds: [messageId],
|
||||
organizationId: orgId,
|
||||
problemStatement,
|
||||
projectId,
|
||||
processedBy,
|
||||
});
|
||||
|
||||
const composed = await t
|
||||
.withIdentity(identityA)
|
||||
.query(api.signals.get, { signalId: result.signalId });
|
||||
|
||||
expect(composed?.signal.projectId).toBe(projectId);
|
||||
});
|
||||
|
||||
test("rejects a project whose owner is not an org member (cross-tenant)", async () => {
|
||||
const t = newTest();
|
||||
const orgA = await ensureOrg(t, identityA);
|
||||
const _orgB = await ensureOrg(t, identityB);
|
||||
// Project owned by user B.
|
||||
const projectId = await createProject(t, ID_B);
|
||||
const messageId = await seedMessage(
|
||||
t,
|
||||
identityA,
|
||||
orgA,
|
||||
"req-cross-proj",
|
||||
"z"
|
||||
);
|
||||
|
||||
// User A is a member of orgA but the project owner (B) is not.
|
||||
await expect(
|
||||
t.withIdentity(identityA).mutation(api.signals.createFromMessages, {
|
||||
conversationId: orgA,
|
||||
messageIds: [messageId],
|
||||
organizationId: orgA,
|
||||
problemStatement,
|
||||
projectId,
|
||||
processedBy,
|
||||
})
|
||||
).rejects.toThrow(/does not belong to the target organization/u);
|
||||
});
|
||||
|
||||
test("authorized list returns only the organization's signals with composed sources", async () => {
|
||||
const t = newTest();
|
||||
const orgA = await ensureOrg(t, identityA);
|
||||
const orgB = await ensureOrg(t, identityB);
|
||||
|
||||
const mA1 = await seedMessage(t, identityA, orgA, "req-list-1", "a1");
|
||||
const mA2 = await seedMessage(t, identityA, orgA, "req-list-2", "a2");
|
||||
await seedMessage(t, identityB, orgB, "req-list-b", "b1");
|
||||
|
||||
await t.withIdentity(identityA).mutation(api.signals.createFromMessages, {
|
||||
conversationId: orgA,
|
||||
messageIds: [mA1],
|
||||
organizationId: orgA,
|
||||
problemStatement,
|
||||
processedBy,
|
||||
});
|
||||
await t.withIdentity(identityA).mutation(api.signals.createFromMessages, {
|
||||
conversationId: orgA,
|
||||
messageIds: [mA1, mA2],
|
||||
organizationId: orgA,
|
||||
problemStatement,
|
||||
processedBy,
|
||||
});
|
||||
|
||||
const forA = await t
|
||||
.withIdentity(identityA)
|
||||
.query(api.signals.list, { organizationId: orgA });
|
||||
const forB = await t
|
||||
.withIdentity(identityB)
|
||||
.query(api.signals.list, { organizationId: orgB });
|
||||
|
||||
expect(forA).toHaveLength(2);
|
||||
expect(forA[0]?.sources.length).toBeGreaterThan(0);
|
||||
expect(forB).toHaveLength(0);
|
||||
|
||||
// Cross-org list is denied.
|
||||
await expect(
|
||||
t
|
||||
.withIdentity(identityB)
|
||||
.query(api.signals.list, { organizationId: orgA })
|
||||
).rejects.toThrow(/Organization membership required/u);
|
||||
});
|
||||
|
||||
test("authorized get denies cross-organization signal access", async () => {
|
||||
const t = newTest();
|
||||
const orgA = await ensureOrg(t, identityA);
|
||||
const _orgB = await ensureOrg(t, identityB);
|
||||
|
||||
const messageId = await seedMessage(
|
||||
t,
|
||||
identityA,
|
||||
orgA,
|
||||
"req-get",
|
||||
"secret"
|
||||
);
|
||||
const result = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.signals.createFromMessages, {
|
||||
conversationId: orgA,
|
||||
messageIds: [messageId],
|
||||
organizationId: orgA,
|
||||
problemStatement,
|
||||
processedBy,
|
||||
});
|
||||
|
||||
// User B cannot read org A's signal.
|
||||
await expect(
|
||||
t
|
||||
.withIdentity(identityB)
|
||||
.query(api.signals.get, { signalId: result.signalId })
|
||||
).rejects.toThrow(/Organization membership required/u);
|
||||
|
||||
// get returns null for an unknown but well-formed id when authorized.
|
||||
const other = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.signals.createFromMessages, {
|
||||
conversationId: orgA,
|
||||
messageIds: [await seedMessage(t, identityA, orgA, "req-ghost", "g")],
|
||||
organizationId: orgA,
|
||||
problemStatement,
|
||||
processedBy,
|
||||
});
|
||||
expect(other.signalId).not.toBe(result.signalId);
|
||||
});
|
||||
});
|
||||
@@ -1,505 +0,0 @@
|
||||
import { composeSignal, SignalValidationError } from "@code/primitives/signal";
|
||||
import { ConvexError, v } from "convex/values";
|
||||
import { Effect } from "effect";
|
||||
|
||||
import type { Doc, Id } from "./_generated/dataModel";
|
||||
import {
|
||||
type MutationCtx,
|
||||
mutation,
|
||||
type QueryCtx,
|
||||
query,
|
||||
} from "./_generated/server";
|
||||
import { requireOrganizationMember, requireProjectMember } from "./authz";
|
||||
|
||||
const PROBLEM_STATEMENT = v.object({
|
||||
title: v.string(),
|
||||
summary: v.string(),
|
||||
desiredOutcome: v.string(),
|
||||
constraints: v.array(v.string()),
|
||||
});
|
||||
|
||||
interface SignalSourceView {
|
||||
readonly _id: Id<"signalSources">;
|
||||
readonly _creationTime: number;
|
||||
readonly signalId: Id<"signals">;
|
||||
readonly organizationId: Id<"organizations">;
|
||||
readonly conversationId: string;
|
||||
readonly messageId: string;
|
||||
readonly ordinal: number;
|
||||
readonly rawTextSnapshot: string;
|
||||
readonly sourceCreatedAt: number;
|
||||
readonly submissionId: string | null;
|
||||
}
|
||||
|
||||
interface SignalView {
|
||||
readonly _id: Id<"signals">;
|
||||
readonly _creationTime: number;
|
||||
readonly organizationId: Id<"organizations">;
|
||||
readonly projectId: Id<"projects"> | null;
|
||||
readonly conversationId: string;
|
||||
readonly problemStatement: {
|
||||
readonly title: string;
|
||||
readonly summary: string;
|
||||
readonly desiredOutcome: string;
|
||||
readonly constraints: readonly string[];
|
||||
};
|
||||
readonly processedByAgentName: string;
|
||||
readonly processedByAgentInstanceId: string;
|
||||
readonly createdAt: number;
|
||||
}
|
||||
|
||||
interface ComposedSignal {
|
||||
readonly signal: SignalView;
|
||||
readonly sources: readonly SignalSourceView[];
|
||||
}
|
||||
|
||||
/**
|
||||
* An admitted user conversation message presented to the agent as candidate
|
||||
* Signal evidence. The agent selects from these by `messageId`; raw text and
|
||||
* timestamps are copied server-side and never accepted from the agent.
|
||||
*/
|
||||
interface ConversationMessageSourceView {
|
||||
readonly messageId: string;
|
||||
readonly clientRequestId: string;
|
||||
readonly rawText: string;
|
||||
readonly submissionId: string | null;
|
||||
readonly createdAt: number;
|
||||
}
|
||||
|
||||
const toSignalView = (doc: Doc<"signals">): SignalView => ({
|
||||
_creationTime: doc._creationTime,
|
||||
_id: doc._id,
|
||||
conversationId: doc.conversationId,
|
||||
createdAt: doc.createdAt,
|
||||
organizationId: doc.organizationId,
|
||||
problemStatement: doc.problemStatement,
|
||||
processedByAgentInstanceId: doc.processedByAgentInstanceId,
|
||||
processedByAgentName: doc.processedByAgentName,
|
||||
projectId: doc.projectId ?? null,
|
||||
});
|
||||
|
||||
const toSourceView = (doc: Doc<"signalSources">): SignalSourceView => ({
|
||||
_creationTime: doc._creationTime,
|
||||
_id: doc._id,
|
||||
conversationId: doc.conversationId,
|
||||
messageId: doc.messageId,
|
||||
ordinal: doc.ordinal,
|
||||
organizationId: doc.organizationId,
|
||||
rawTextSnapshot: doc.rawTextSnapshot,
|
||||
signalId: doc.signalId,
|
||||
sourceCreatedAt: doc.sourceCreatedAt,
|
||||
submissionId: doc.submissionId ?? null,
|
||||
});
|
||||
|
||||
const toConversationMessageSourceView = (
|
||||
doc: Doc<"conversationMessages">
|
||||
): ConversationMessageSourceView => ({
|
||||
clientRequestId: doc.clientRequestId,
|
||||
createdAt: doc.createdAt,
|
||||
messageId: doc.messageId,
|
||||
rawText: doc.rawText,
|
||||
submissionId: doc.submissionId ?? null,
|
||||
});
|
||||
|
||||
/**
|
||||
* Build a deterministic, collision-safe key from the organization, the
|
||||
* conversation, and the ordered message selection. The key is the JSON
|
||||
* encoding of `[organizationId, conversationId, ...messageIds]`, so no
|
||||
* delimiter inside arbitrary message IDs can ever cause a collision and the
|
||||
* caller's selection order is part of the identity.
|
||||
*/
|
||||
const buildSourceKey = (
|
||||
organizationId: Id<"organizations">,
|
||||
conversationId: string,
|
||||
messageIds: readonly string[]
|
||||
): string => JSON.stringify([organizationId, conversationId, ...messageIds]);
|
||||
|
||||
/**
|
||||
* Resolve all selected conversation messages server-side. Each message must
|
||||
* belong to the same organization and conversation, be a `user` message, be
|
||||
* `admitted`, and appear exactly once in the ordered selection. Raw text,
|
||||
* timestamps, and submission ids are copied from the stored rows; the agent
|
||||
* never supplies them. Returns the messages in the caller's requested order.
|
||||
*/
|
||||
const resolveSources = async (
|
||||
ctx: MutationCtx,
|
||||
organizationId: Id<"organizations">,
|
||||
conversationId: string,
|
||||
messageIds: readonly string[]
|
||||
): Promise<Doc<"conversationMessages">[]> => {
|
||||
if (messageIds.length === 0) {
|
||||
throw new ConvexError("At least one source message is required");
|
||||
}
|
||||
if (new Set(messageIds).size !== messageIds.length) {
|
||||
throw new ConvexError("Source message ids must be unique");
|
||||
}
|
||||
const resolved: Doc<"conversationMessages">[] = [];
|
||||
for (const messageId of messageIds) {
|
||||
const doc = await ctx.db
|
||||
.query("conversationMessages")
|
||||
.withIndex("by_organization_and_message", (q) =>
|
||||
q
|
||||
.eq("organizationId", organizationId)
|
||||
.eq("conversationId", conversationId)
|
||||
.eq("messageId", messageId)
|
||||
)
|
||||
.unique();
|
||||
if (!doc) {
|
||||
throw new ConvexError(`Source message not found: ${messageId}`);
|
||||
}
|
||||
if (doc.role !== "user") {
|
||||
throw new ConvexError(
|
||||
`Source message is not a user message: ${messageId}`
|
||||
);
|
||||
}
|
||||
if (doc.status !== "admitted") {
|
||||
throw new ConvexError(`Source message is not admitted: ${messageId}`);
|
||||
}
|
||||
resolved.push(doc);
|
||||
}
|
||||
return resolved;
|
||||
};
|
||||
|
||||
/**
|
||||
* Verify a project belongs to the same organization. Projects now carry an
|
||||
* explicit `organizationId`; the caller is already proven to be a member, so
|
||||
* this is a direct invariant check with no cross-tenant leakage.
|
||||
*/
|
||||
const authorizeProject = async (
|
||||
ctx: MutationCtx,
|
||||
organizationId: Id<"organizations">,
|
||||
projectId: Id<"projects">
|
||||
): Promise<void> => {
|
||||
const project = await ctx.db.get(projectId);
|
||||
if (!project) {
|
||||
throw new ConvexError("Project not found");
|
||||
}
|
||||
if (project.organizationId !== organizationId) {
|
||||
throw new ConvexError("Project does not belong to the target organization");
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Atomically create one idempotent Signal from an ordered selection of user
|
||||
* messages plus a structured problem statement produced by the agent.
|
||||
*
|
||||
* Authorization: the authenticated identity must be a member of the target
|
||||
* organization. When a project is supplied it must belong to the same
|
||||
* organization. Every message id is resolved server-side from
|
||||
* `conversationMessages` under the same organization and conversation; raw
|
||||
* text, timestamps, and submission ids are copied from those rows and never
|
||||
* accepted from the agent.
|
||||
*
|
||||
* Idempotency: if a Signal with the same `(organizationId, sourceKey)` already
|
||||
* exists, it is returned as-is without creating duplicate sources. The whole
|
||||
* lookup-or-insert runs in one Convex mutation transaction, so retries cannot
|
||||
* produce duplicates.
|
||||
*
|
||||
* Returns the new (or existing) signal id.
|
||||
*/
|
||||
export const createFromMessages = mutation({
|
||||
args: {
|
||||
organizationId: v.id("organizations"),
|
||||
projectId: v.optional(v.id("projects")),
|
||||
conversationId: v.string(),
|
||||
messageIds: v.array(v.string()),
|
||||
problemStatement: PROBLEM_STATEMENT,
|
||||
processedBy: v.object({
|
||||
agentName: v.string(),
|
||||
agentInstanceId: v.string(),
|
||||
}),
|
||||
},
|
||||
handler: async (ctx, args): Promise<{ signalId: Id<"signals"> }> => {
|
||||
await requireOrganizationMember(ctx, args.organizationId);
|
||||
if (args.projectId) {
|
||||
await authorizeProject(ctx, args.organizationId, args.projectId);
|
||||
}
|
||||
|
||||
// The global conversation id equals the organization id; the caller's
|
||||
// conversation id must match.
|
||||
if (args.conversationId !== args.organizationId) {
|
||||
throw new ConvexError("Conversation does not belong to organization");
|
||||
}
|
||||
|
||||
const sourceKey = buildSourceKey(
|
||||
args.organizationId,
|
||||
args.conversationId,
|
||||
args.messageIds
|
||||
);
|
||||
|
||||
const existing = await ctx.db
|
||||
.query("signals")
|
||||
.withIndex("by_organization_and_sourceKey", (q) =>
|
||||
q.eq("organizationId", args.organizationId).eq("sourceKey", sourceKey)
|
||||
)
|
||||
.unique();
|
||||
if (existing) {
|
||||
return { signalId: existing._id };
|
||||
}
|
||||
|
||||
const sources = await resolveSources(
|
||||
ctx,
|
||||
args.organizationId,
|
||||
args.conversationId,
|
||||
args.messageIds
|
||||
);
|
||||
|
||||
// Compose the pure Effect Signal before writes, using a prospective id.
|
||||
// `composeSignal` decodes an `unknown` input, so pass plain objects.
|
||||
const now = Date.now();
|
||||
const prospectiveId = crypto.randomUUID();
|
||||
const scope =
|
||||
args.projectId === undefined
|
||||
? { _tag: "Organization" as const, organizationId: args.organizationId }
|
||||
: {
|
||||
_tag: "Project" as const,
|
||||
organizationId: args.organizationId,
|
||||
projectId: args.projectId,
|
||||
};
|
||||
|
||||
const signal = await Effect.runPromise(
|
||||
composeSignal({
|
||||
createdAt: now,
|
||||
id: prospectiveId,
|
||||
problemStatement: args.problemStatement,
|
||||
processedBy: args.processedBy,
|
||||
scope,
|
||||
sourceMessages: sources.map((doc) => ({
|
||||
conversationId: doc.conversationId,
|
||||
createdAt: doc.createdAt,
|
||||
messageId: doc.messageId,
|
||||
rawText: doc.rawText,
|
||||
})),
|
||||
})
|
||||
).catch((error: unknown) => {
|
||||
if (error instanceof SignalValidationError) {
|
||||
throw new ConvexError(`Invalid signal: ${error.message}`);
|
||||
}
|
||||
throw new ConvexError(
|
||||
`Invalid signal: ${error instanceof Error ? error.message : "unknown"}`
|
||||
);
|
||||
});
|
||||
|
||||
const signalId = await ctx.db.insert("signals", {
|
||||
conversationId: args.conversationId,
|
||||
createdAt: now,
|
||||
organizationId: args.organizationId,
|
||||
problemStatement: {
|
||||
constraints: [...signal.problemStatement.constraints],
|
||||
desiredOutcome: signal.problemStatement.desiredOutcome,
|
||||
summary: signal.problemStatement.summary,
|
||||
title: signal.problemStatement.title,
|
||||
},
|
||||
processedByAgentInstanceId: args.processedBy.agentInstanceId,
|
||||
processedByAgentName: args.processedBy.agentName,
|
||||
...(args.projectId === undefined ? {} : { projectId: args.projectId }),
|
||||
sourceKey,
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
sources.map((doc, ordinal) =>
|
||||
ctx.db.insert("signalSources", {
|
||||
conversationId: doc.conversationId,
|
||||
messageId: doc.messageId,
|
||||
ordinal,
|
||||
organizationId: args.organizationId,
|
||||
rawTextSnapshot: doc.rawText,
|
||||
signalId,
|
||||
...(doc.submissionId === undefined
|
||||
? {}
|
||||
: { submissionId: doc.submissionId }),
|
||||
sourceCreatedAt: doc.createdAt,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
return { signalId };
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Load the composed source rows for a signal in ordinal order.
|
||||
*/
|
||||
const loadComposedSources = async (
|
||||
ctx: QueryCtx,
|
||||
signal: Doc<"signals">
|
||||
): Promise<SignalSourceView[]> => {
|
||||
const rows = await ctx.db
|
||||
.query("signalSources")
|
||||
.withIndex("by_signal_and_ordinal", (q) => q.eq("signalId", signal._id))
|
||||
.collect();
|
||||
// `collect` does not guarantee index order across all backends; sort by
|
||||
// ordinal to be deterministic.
|
||||
rows.sort((a, b) => a.ordinal - b.ordinal);
|
||||
return rows.map(toSourceView);
|
||||
};
|
||||
|
||||
/**
|
||||
* List the Signals for the authenticated user's organization, newest first.
|
||||
* Membership is required; cross-organization access is denied. Each entry
|
||||
* includes its composed source records in ordinal order.
|
||||
*/
|
||||
export const list = query({
|
||||
args: {
|
||||
organizationId: v.id("organizations"),
|
||||
},
|
||||
handler: async (ctx, args): Promise<ComposedSignal[]> => {
|
||||
await requireOrganizationMember(ctx, args.organizationId);
|
||||
const signals = await ctx.db
|
||||
.query("signals")
|
||||
.withIndex("by_organization_and_createdAt", (q) =>
|
||||
q.eq("organizationId", args.organizationId)
|
||||
)
|
||||
.order("desc")
|
||||
.take(50);
|
||||
return Promise.all(
|
||||
signals.map(async (signal) => ({
|
||||
signal: toSignalView(signal),
|
||||
sources: await loadComposedSources(ctx, signal),
|
||||
}))
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* List the bounded set of admitted user messages in the organization's
|
||||
* conversation that have not yet been consumed as a Signal source, newest
|
||||
* first. These are the candidate messages an agent should select from when
|
||||
* composing a new Signal.
|
||||
*
|
||||
* Authorization: the authenticated identity must be a member of the target
|
||||
* organization. The conversation id equals the organization id; the caller
|
||||
* never supplies tenancy. Cross-organization access is denied.
|
||||
*
|
||||
* Bounded: capped at 100 rows to keep agent evidence selection tractable.
|
||||
*/
|
||||
export const listAdmittedUnused = query({
|
||||
args: {
|
||||
organizationId: v.id("organizations"),
|
||||
},
|
||||
handler: async (ctx, args): Promise<ConversationMessageSourceView[]> => {
|
||||
await requireOrganizationMember(ctx, args.organizationId);
|
||||
const conversationId = args.organizationId;
|
||||
|
||||
const admitted = await ctx.db
|
||||
.query("conversationMessages")
|
||||
.withIndex("by_organization_and_message", (q) =>
|
||||
q
|
||||
.eq("organizationId", args.organizationId)
|
||||
.eq("conversationId", conversationId)
|
||||
)
|
||||
.order("desc")
|
||||
.take(100);
|
||||
|
||||
const candidates = admitted.filter(
|
||||
(message) => message.role === "user" && message.status === "admitted"
|
||||
);
|
||||
|
||||
const unused: ConversationMessageSourceView[] = [];
|
||||
for (const message of candidates) {
|
||||
const consumed = await ctx.db
|
||||
.query("signalSources")
|
||||
.withIndex("by_organization_and_message", (q) =>
|
||||
q
|
||||
.eq("organizationId", args.organizationId)
|
||||
.eq("conversationId", conversationId)
|
||||
.eq("messageId", message.messageId)
|
||||
)
|
||||
.first();
|
||||
if (!consumed) {
|
||||
unused.push(toConversationMessageSourceView(message));
|
||||
}
|
||||
}
|
||||
|
||||
return unused;
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Get one Signal by id with its composed sources. Membership in the signal's
|
||||
* organization is required; access to another organization's signal is denied.
|
||||
*/
|
||||
export const get = query({
|
||||
args: {
|
||||
signalId: v.id("signals"),
|
||||
},
|
||||
handler: async (ctx, args): Promise<ComposedSignal | null> => {
|
||||
const signal = await ctx.db.get(args.signalId);
|
||||
if (!signal) {
|
||||
return null;
|
||||
}
|
||||
await requireOrganizationMember(ctx, signal.organizationId);
|
||||
return {
|
||||
signal: toSignalView(signal),
|
||||
sources: await loadComposedSources(ctx, signal),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Linked Signal query for the Work OS surface.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface LinkedSignalView {
|
||||
readonly signalId: Id<"signals">;
|
||||
readonly title: string;
|
||||
readonly summary: string;
|
||||
readonly sourceCount: number;
|
||||
readonly createdAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all Signal-issue attachments for a project, grouped by issue id.
|
||||
* User-authenticated: the caller must be a member of the project organization.
|
||||
* Used by the Work OS card list to show per-issue linked Signal counts.
|
||||
*/
|
||||
export const listLinkedSignalsForProject = query({
|
||||
args: {
|
||||
projectId: v.id("projects"),
|
||||
},
|
||||
handler: async (ctx, args): Promise<Record<string, LinkedSignalView[]>> => {
|
||||
const { organizationId } = await requireProjectMember(ctx, args.projectId);
|
||||
const issues = await ctx.db
|
||||
.query("projectIssues")
|
||||
.withIndex("by_project_and_number", (q) =>
|
||||
q.eq("projectId", args.projectId)
|
||||
)
|
||||
.take(100);
|
||||
const result: Record<string, LinkedSignalView[]> = {};
|
||||
for (const issue of issues) {
|
||||
const attachments = await ctx.db
|
||||
.query("signalIssueAttachments")
|
||||
.withIndex("by_issue", (q) => q.eq("issueId", issue._id))
|
||||
.collect();
|
||||
if (attachments.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const signals = await Promise.all(
|
||||
attachments.map(async (attachment) => {
|
||||
const signal = await ctx.db.get(attachment.signalId);
|
||||
if (!signal || signal.organizationId !== organizationId) {
|
||||
return null;
|
||||
}
|
||||
const sources = await ctx.db
|
||||
.query("signalSources")
|
||||
.withIndex("by_signal_and_ordinal", (q) =>
|
||||
q.eq("signalId", signal._id)
|
||||
)
|
||||
.collect();
|
||||
return {
|
||||
createdAt: signal.createdAt,
|
||||
signalId: signal._id,
|
||||
sourceCount: sources.length,
|
||||
summary: signal.problemStatement.summary,
|
||||
title: signal.problemStatement.title,
|
||||
} satisfies LinkedSignalView;
|
||||
})
|
||||
);
|
||||
result[String(issue._id)] = signals
|
||||
.filter((entry): entry is LinkedSignalView => entry !== null)
|
||||
// Deterministic order: newest linked Signal first.
|
||||
.sort((a: LinkedSignalView, b: LinkedSignalView) => b.createdAt - a.createdAt);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
});
|
||||
@@ -1,43 +0,0 @@
|
||||
import { v } from "convex/values";
|
||||
|
||||
import { query, mutation } from "./_generated/server";
|
||||
|
||||
export const getAll = query({
|
||||
handler: async (ctx) => {
|
||||
return await ctx.db.query("todos").collect();
|
||||
},
|
||||
});
|
||||
|
||||
export const create = mutation({
|
||||
args: {
|
||||
text: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const newTodoId = await ctx.db.insert("todos", {
|
||||
text: args.text,
|
||||
completed: false,
|
||||
});
|
||||
return await ctx.db.get("todos", newTodoId);
|
||||
},
|
||||
});
|
||||
|
||||
export const toggle = mutation({
|
||||
args: {
|
||||
id: v.id("todos"),
|
||||
completed: v.boolean(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
await ctx.db.patch("todos", args.id, { completed: args.completed });
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteTodo = mutation({
|
||||
args: {
|
||||
id: v.id("todos"),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
await ctx.db.delete("todos", args.id);
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
@@ -1,64 +0,0 @@
|
||||
import { env } from "@code/env/convex";
|
||||
import {
|
||||
getIssue,
|
||||
fetchTransport,
|
||||
type GitRemoteConfig,
|
||||
} from "@code/primitives/git-remote-runtime";
|
||||
import { ConvexError, v } from "convex/values";
|
||||
import { Effect } from "effect";
|
||||
|
||||
import { mutation } from "./_generated/server";
|
||||
|
||||
// Agent token guard (service-level auth for Zopu tools), matching signalRouting.
|
||||
const requireAgent = (token: string) => {
|
||||
if (token !== env.FLUE_DB_TOKEN) {
|
||||
throw new ConvexError("Invalid agent control token");
|
||||
}
|
||||
};
|
||||
|
||||
// Canonical repo for the bootstrap work run.
|
||||
const remoteConfig = (): GitRemoteConfig => {
|
||||
if (!env.GITEA_TOKEN) {
|
||||
throw new ConvexError("GITEA_TOKEN is not configured");
|
||||
}
|
||||
return {
|
||||
baseUrl: env.GITEA_URL,
|
||||
owner: "puter",
|
||||
repo: "zopu-code",
|
||||
token: env.GITEA_TOKEN,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Starts a work run for an existing Gitea issue on the canonical repo. Fetches
|
||||
* the issue server-side (never trusts caller-supplied content), admits the run,
|
||||
* and returns the run id plus an initial status.
|
||||
*
|
||||
* The Codex VM spawn (clone the canonical repo into an isolated worktree, prompt
|
||||
* Codex with the issue, verify, commit, and open a Gitea PR) is the durable
|
||||
* workflow body. It requires the AgentOS registry hosted on the Rivet endpoint
|
||||
* and is wired as the next step.
|
||||
*/
|
||||
export const startIssueWork = mutation({
|
||||
args: {
|
||||
issueNumber: v.number(),
|
||||
token: v.string(),
|
||||
},
|
||||
handler: async (_ctx, args) => {
|
||||
requireAgent(args.token);
|
||||
const config = remoteConfig();
|
||||
|
||||
const issue = await Effect.runPromise(
|
||||
getIssue(fetchTransport, config, args.issueNumber)
|
||||
);
|
||||
|
||||
const runId = `run_${Date.now().toString(36)}_${args.issueNumber}`;
|
||||
|
||||
return {
|
||||
issueNumber: issue.number,
|
||||
issueTitle: issue.title,
|
||||
runId,
|
||||
status: "queued",
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -3,7 +3,6 @@ import { convexTest } from "convex-test";
|
||||
import { anyApi } from "convex/server";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { internal } from "./_generated/api";
|
||||
import schema from "./schema";
|
||||
|
||||
declare global {
|
||||
@@ -14,211 +13,61 @@ declare global {
|
||||
|
||||
const modules = import.meta.glob("./**/*.ts");
|
||||
const api = anyApi;
|
||||
const identity = { tokenIdentifier: "https://convex.test|slice-one" };
|
||||
|
||||
describe("Slice 1 Work routing", () => {
|
||||
test("creates one proposed Work and preserves exact source text", async () => {
|
||||
const t = convexTest({ schema, modules });
|
||||
const organization = await t
|
||||
.withIdentity(identity)
|
||||
.mutation(api.organizations.ensurePersonalOrganization, {});
|
||||
const project = await t
|
||||
.withIdentity(identity)
|
||||
.mutation(internal.projects.persistPublicGitImport, {
|
||||
remote: { defaultBranch: "main", documents: [], warnings: [] },
|
||||
source: {
|
||||
host: "github.com",
|
||||
normalizedUrl: "https://github.com/example/slice-one",
|
||||
projectName: "slice-one",
|
||||
repositoryPath: "example/slice-one",
|
||||
url: "https://github.com/example/slice-one",
|
||||
},
|
||||
userId: identity.tokenIdentifier,
|
||||
describe("works", () => {
|
||||
test("creates one proposed Work and one normalized attachment", async () => {
|
||||
const t = convexTest({ modules, schema });
|
||||
const fixture = await t.run(async (ctx) => {
|
||||
const organizationId = await ctx.db.insert("organizations", {
|
||||
createdAt: 1,
|
||||
createdBy: "user",
|
||||
kind: "personal",
|
||||
name: "Personal",
|
||||
});
|
||||
const rawText = " Add a phone-ready Slice 1 experience. ";
|
||||
const message = await t
|
||||
.withIdentity(identity)
|
||||
.mutation(api.conversationMessages.beginUserMessage, {
|
||||
clientRequestId: "slice-one-request",
|
||||
organizationId: organization._id,
|
||||
rawText,
|
||||
const projectId = await ctx.db.insert("projects", {
|
||||
createdAt: 1,
|
||||
name: "Zopu",
|
||||
normalizedSourceUrl: "https://example.com/zopu",
|
||||
organizationId,
|
||||
repositoryPath: "puter/zopu",
|
||||
sourceHost: "example.com",
|
||||
sourceUrl: "https://example.com/zopu",
|
||||
updatedAt: 1,
|
||||
});
|
||||
await t
|
||||
.withIdentity(identity)
|
||||
.mutation(api.conversationMessages.markAdmitted, {
|
||||
clientRequestId: "slice-one-request",
|
||||
organizationId: organization._id,
|
||||
submissionId: "slice-one-submission",
|
||||
const conversationId = await ctx.db.insert("conversations", {
|
||||
createdAt: 1,
|
||||
organizationId,
|
||||
});
|
||||
const signal = await t.mutation(api.signalRouting.createSignal, {
|
||||
messageIds: [message.messageId],
|
||||
organizationId: organization._id,
|
||||
problemStatement: {
|
||||
constraints: ["Mobile web first"],
|
||||
desiredOutcome: "The Slice 1 loop works on a phone.",
|
||||
summary: "The current product is not ready for phone testing.",
|
||||
title: "Make Slice 1 phone-ready",
|
||||
},
|
||||
processedByAgentInstanceId: organization._id,
|
||||
projectId: project.id,
|
||||
token: env.FLUE_DB_TOKEN,
|
||||
const signalId = await ctx.db.insert("signals", {
|
||||
conversationId,
|
||||
createdAt: 1,
|
||||
desiredOutcome: "A working Slice 1",
|
||||
organizationId,
|
||||
processedByAgentInstanceId: String(organizationId),
|
||||
processedByAgentName: "zopu",
|
||||
projectId,
|
||||
sourceKey: "source-1",
|
||||
summary: "Slice 1 needs work",
|
||||
title: "Finish Slice 1",
|
||||
});
|
||||
return { organizationId, signalId };
|
||||
});
|
||||
|
||||
const first = await t.mutation(api.works.createFromSignal, {
|
||||
organizationId: organization._id,
|
||||
signalId: signal.signalId,
|
||||
organizationId: fixture.organizationId,
|
||||
signalId: fixture.signalId,
|
||||
token: env.FLUE_DB_TOKEN,
|
||||
});
|
||||
const repeated = await t.mutation(api.works.createFromSignal, {
|
||||
organizationId: organization._id,
|
||||
signalId: signal.signalId,
|
||||
const second = await t.mutation(api.works.createFromSignal, {
|
||||
organizationId: fixture.organizationId,
|
||||
signalId: fixture.signalId,
|
||||
token: env.FLUE_DB_TOKEN,
|
||||
});
|
||||
expect(repeated).toEqual({ created: false, workId: first.workId });
|
||||
|
||||
const works = await t
|
||||
.withIdentity(identity)
|
||||
.query(api.works.listForProject, { projectId: project.id });
|
||||
expect(works).toHaveLength(1);
|
||||
expect(works[0]?.status).toBe("proposed");
|
||||
expect(works[0]?.signals[0]?.sources[0]?.rawText).toBe(rawText);
|
||||
});
|
||||
|
||||
test("keeps casual conversation out of Work", async () => {
|
||||
const t = convexTest({ schema, modules });
|
||||
const organization = await t
|
||||
.withIdentity(identity)
|
||||
.mutation(api.organizations.ensurePersonalOrganization, {});
|
||||
const project = await t
|
||||
.withIdentity(identity)
|
||||
.mutation(internal.projects.persistPublicGitImport, {
|
||||
remote: { defaultBranch: "main", documents: [], warnings: [] },
|
||||
source: {
|
||||
host: "git.openputer.com",
|
||||
normalizedUrl: "https://git.openputer.com/puter/zopu-code",
|
||||
projectName: "zopu-code",
|
||||
repositoryPath: "puter/zopu-code",
|
||||
url: "https://git.openputer.com/puter/zopu-code",
|
||||
},
|
||||
userId: identity.tokenIdentifier,
|
||||
});
|
||||
await t
|
||||
.withIdentity(identity)
|
||||
.mutation(api.conversationMessages.beginUserMessage, {
|
||||
clientRequestId: "slice-one-casual",
|
||||
organizationId: organization._id,
|
||||
rawText: "Yo, how are you?",
|
||||
});
|
||||
await t
|
||||
.withIdentity(identity)
|
||||
.mutation(api.conversationMessages.markAdmitted, {
|
||||
clientRequestId: "slice-one-casual",
|
||||
organizationId: organization._id,
|
||||
submissionId: "slice-one-casual-submission",
|
||||
});
|
||||
|
||||
const works = await t
|
||||
.withIdentity(identity)
|
||||
.query(api.works.listForProject, { projectId: project.id });
|
||||
expect(works).toEqual([]);
|
||||
});
|
||||
|
||||
test("attaches another Signal to existing Work idempotently", async () => {
|
||||
const t = convexTest({ schema, modules });
|
||||
const organization = await t
|
||||
.withIdentity(identity)
|
||||
.mutation(api.organizations.ensurePersonalOrganization, {});
|
||||
const project = await t
|
||||
.withIdentity(identity)
|
||||
.mutation(internal.projects.persistPublicGitImport, {
|
||||
remote: { defaultBranch: "main", documents: [], warnings: [] },
|
||||
source: {
|
||||
host: "git.openputer.com",
|
||||
normalizedUrl: "https://git.openputer.com/puter/zopu-code",
|
||||
projectName: "zopu-code",
|
||||
repositoryPath: "puter/zopu-code",
|
||||
url: "https://git.openputer.com/puter/zopu-code",
|
||||
},
|
||||
userId: identity.tokenIdentifier,
|
||||
});
|
||||
|
||||
const seedSignal = async (
|
||||
clientRequestId: string,
|
||||
rawText: string,
|
||||
title: string
|
||||
) => {
|
||||
const message = await t
|
||||
.withIdentity(identity)
|
||||
.mutation(api.conversationMessages.beginUserMessage, {
|
||||
clientRequestId,
|
||||
organizationId: organization._id,
|
||||
rawText,
|
||||
});
|
||||
await t
|
||||
.withIdentity(identity)
|
||||
.mutation(api.conversationMessages.markAdmitted, {
|
||||
clientRequestId,
|
||||
organizationId: organization._id,
|
||||
submissionId: `${clientRequestId}-submission`,
|
||||
});
|
||||
return await t.mutation(api.signalRouting.createSignal, {
|
||||
messageIds: [message.messageId],
|
||||
organizationId: organization._id,
|
||||
problemStatement: {
|
||||
constraints: [],
|
||||
desiredOutcome: "The Slice 1 phone flow is polished.",
|
||||
summary: "The messages describe the same desired outcome.",
|
||||
title,
|
||||
},
|
||||
processedByAgentInstanceId: organization._id,
|
||||
projectId: project.id,
|
||||
token: env.FLUE_DB_TOKEN,
|
||||
});
|
||||
};
|
||||
|
||||
const firstSignal = await seedSignal(
|
||||
"slice-one-first",
|
||||
"Polish the Slice 1 phone experience.",
|
||||
"Polish Slice 1"
|
||||
expect(first.created).toBe(true);
|
||||
expect(second).toEqual({ created: false, workId: first.workId });
|
||||
const attachments = await t.run(async (ctx) =>
|
||||
ctx.db.query("signalWorkAttachments").collect()
|
||||
);
|
||||
const secondSignal = await seedSignal(
|
||||
"slice-one-second",
|
||||
"Make the same Slice 1 experience easier to inspect on mobile.",
|
||||
"Improve Slice 1 inspection"
|
||||
);
|
||||
const created = await t.mutation(api.works.createFromSignal, {
|
||||
organizationId: organization._id,
|
||||
signalId: firstSignal.signalId,
|
||||
token: env.FLUE_DB_TOKEN,
|
||||
});
|
||||
const firstAttach = await t.mutation(api.works.attachSignalToWork, {
|
||||
organizationId: organization._id,
|
||||
signalId: secondSignal.signalId,
|
||||
token: env.FLUE_DB_TOKEN,
|
||||
workId: created.workId,
|
||||
});
|
||||
const repeatedAttach = await t.mutation(api.works.attachSignalToWork, {
|
||||
organizationId: organization._id,
|
||||
signalId: secondSignal.signalId,
|
||||
token: env.FLUE_DB_TOKEN,
|
||||
workId: created.workId,
|
||||
});
|
||||
|
||||
expect(firstAttach).toEqual({ attached: true, workId: created.workId });
|
||||
expect(repeatedAttach).toEqual({
|
||||
attached: false,
|
||||
workId: created.workId,
|
||||
});
|
||||
|
||||
const works = await t
|
||||
.withIdentity(identity)
|
||||
.query(api.works.listForProject, { projectId: project.id });
|
||||
expect(works).toHaveLength(1);
|
||||
expect(works[0]?.signals).toHaveLength(2);
|
||||
expect(
|
||||
works[0]?.events.filter(
|
||||
(event: { readonly kind: string }) => event.kind === "signal.attached"
|
||||
)
|
||||
).toHaveLength(1);
|
||||
expect(attachments).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,7 +55,7 @@ const attachSignal = async (
|
||||
organizationId: String(signal.organizationId),
|
||||
projectId: String(signal.projectId),
|
||||
signalId: String(signal._id),
|
||||
title: signal.problemStatement.title,
|
||||
title: signal.title,
|
||||
},
|
||||
work: {
|
||||
organizationId: String(work.organizationId),
|
||||
@@ -81,18 +81,14 @@ const attachSignal = async (
|
||||
const createdAt = Date.now();
|
||||
await ctx.db.insert("signalWorkAttachments", {
|
||||
createdAt,
|
||||
organizationId: work.organizationId,
|
||||
projectId: work.projectId,
|
||||
signalId: signal._id,
|
||||
workId: work._id,
|
||||
});
|
||||
await ctx.db.insert("workEvents", {
|
||||
createdAt,
|
||||
data: event.data,
|
||||
idempotencyKey: event.idempotencyKey,
|
||||
kind: event.kind,
|
||||
organizationId: work.organizationId,
|
||||
projectId: work.projectId,
|
||||
signalId: signal._id,
|
||||
workId: work._id,
|
||||
});
|
||||
await ctx.db.patch(work._id, { updatedAt: createdAt });
|
||||
@@ -138,8 +134,19 @@ export const createFromSignal = mutation({
|
||||
return { created: false, workId: existingAttachment.workId };
|
||||
}
|
||||
|
||||
const constraints = await ctx.db
|
||||
.query("signalConstraints")
|
||||
.withIndex("by_signalId_and_ordinal", (q) => q.eq("signalId", signal._id))
|
||||
.collect();
|
||||
const draft = await Effect.runPromise(
|
||||
workDraftFromSignal(signal.problemStatement)
|
||||
workDraftFromSignal({
|
||||
constraints: constraints
|
||||
.sort((left, right) => left.ordinal - right.ordinal)
|
||||
.map((constraint) => constraint.value),
|
||||
desiredOutcome: signal.desiredOutcome,
|
||||
summary: signal.summary,
|
||||
title: signal.title,
|
||||
})
|
||||
).catch((error: unknown) => {
|
||||
throw new ConvexError(
|
||||
error instanceof Error ? error.message : "Invalid Work proposal"
|
||||
@@ -169,18 +176,14 @@ export const createFromSignal = mutation({
|
||||
});
|
||||
await ctx.db.insert("signalWorkAttachments", {
|
||||
createdAt,
|
||||
organizationId: signal.organizationId,
|
||||
projectId: signal.projectId as Id<"projects">,
|
||||
signalId: signal._id,
|
||||
workId,
|
||||
});
|
||||
await ctx.db.insert("workEvents", {
|
||||
createdAt,
|
||||
data: event.data,
|
||||
idempotencyKey: event.idempotencyKey,
|
||||
kind: event.kind,
|
||||
organizationId: signal.organizationId,
|
||||
projectId: signal.projectId as Id<"projects">,
|
||||
signalId: signal._id,
|
||||
workId,
|
||||
});
|
||||
return { created: true, workId };
|
||||
@@ -231,23 +234,23 @@ export const listForProject = query({
|
||||
}
|
||||
const sources = await ctx.db
|
||||
.query("signalSources")
|
||||
.withIndex("by_signal_and_ordinal", (q) =>
|
||||
.withIndex("by_signalId_and_ordinal", (q) =>
|
||||
q.eq("signalId", signal._id)
|
||||
)
|
||||
.collect();
|
||||
return {
|
||||
createdAt: signal.createdAt,
|
||||
signalId: signal._id,
|
||||
summary: signal.problemStatement.summary,
|
||||
summary: signal.summary,
|
||||
sources: sources
|
||||
.sort((a, b) => a.ordinal - b.ordinal)
|
||||
.map((source) => ({
|
||||
createdAt: source.sourceCreatedAt,
|
||||
messageId: source.messageId,
|
||||
messageId: String(source.messageId),
|
||||
rawText: source.rawTextSnapshot,
|
||||
submissionId: source.submissionId ?? null,
|
||||
submissionId: null,
|
||||
})),
|
||||
title: signal.problemStatement.title,
|
||||
title: signal.title,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user