feat: add tenant-scoped signals backend #2
2
packages/backend/convex/_generated/api.d.ts
vendored
2
packages/backend/convex/_generated/api.d.ts
vendored
@@ -18,6 +18,7 @@ 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";
|
||||
@@ -41,6 +42,7 @@ declare const fullApi: ApiFromModules<{
|
||||
fluePersistence: typeof fluePersistence;
|
||||
healthCheck: typeof healthCheck;
|
||||
http: typeof http;
|
||||
organizations: typeof organizations;
|
||||
privateData: typeof privateData;
|
||||
projectArtifacts: typeof projectArtifacts;
|
||||
projectIssues: typeof projectIssues;
|
||||
|
||||
@@ -73,7 +73,7 @@ export const ensureRun = mutation({
|
||||
status: "queued",
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
await ctx.db.insert("projectSignals", {
|
||||
await ctx.db.insert("projectEvents", {
|
||||
createdAt: timestamp,
|
||||
data: { actorKey, daemonId: args.daemonId },
|
||||
issueId: issue._id,
|
||||
@@ -116,7 +116,7 @@ export const updateArtifact = mutation({
|
||||
revision: artifact.revision + 1,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
await ctx.db.insert("projectSignals", {
|
||||
await ctx.db.insert("projectEvents", {
|
||||
createdAt: timestamp,
|
||||
data: { path: args.path, revision: artifact.revision + 1 },
|
||||
issueId: issue._id,
|
||||
@@ -162,7 +162,7 @@ export const setStatus = mutation({
|
||||
: {}),
|
||||
...(terminal ? { completedAt: timestamp } : {}),
|
||||
});
|
||||
await ctx.db.insert("projectSignals", {
|
||||
await ctx.db.insert("projectEvents", {
|
||||
createdAt: timestamp,
|
||||
data: { summary: args.summary?.slice(0, 1000) },
|
||||
issueId: issue._id,
|
||||
|
||||
@@ -79,7 +79,7 @@ export const createInitialArtifacts = (
|
||||
},
|
||||
{
|
||||
content:
|
||||
"# Signals\n\nThe agent manager emits `issue.queued`, `agent.started`, `agent.needs-input`, `agent.completed`, and `agent.failed`. Signals are durable project events and drive the web status.\n",
|
||||
"# 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",
|
||||
},
|
||||
{
|
||||
|
||||
219
packages/backend/convex/projectEvents.test.ts
Normal file
219
packages/backend/convex/projectEvents.test.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
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";
|
||||
|
||||
// `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;
|
||||
|
||||
// `agentWorkspace` gates agent-controlled mutations on `env.FLUE_DB_TOKEN`.
|
||||
// Vitest loads the repo `.env`, so resolve the real value from the env module
|
||||
// rather than hardcoding a constant that would mismatch the loaded secret.
|
||||
const FLUE_DB_TOKEN = env.FLUE_DB_TOKEN;
|
||||
|
||||
const ID_A = "https://convex.test|user-a";
|
||||
const identityA = { tokenIdentifier: ID_A };
|
||||
|
||||
const repository = {
|
||||
defaultBranch: "main",
|
||||
id: 12345,
|
||||
name: "zopu",
|
||||
owner: "puter",
|
||||
url: "https://github.com/puter/zopu",
|
||||
};
|
||||
|
||||
// Read every projectEvents row for a project via the same indexes the control
|
||||
// plane writes through, returning them oldest-first. Reads MUST go through the
|
||||
// same test instance that performed the writes; a fresh `convexTest()` is an
|
||||
// isolated in-memory backend with no shared state.
|
||||
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 t.mutation(internal.projects.storeGitHubProject, {
|
||||
ownerId: ID_A,
|
||||
repository,
|
||||
});
|
||||
|
||||
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();
|
||||
expect(events[0].data).toMatchObject({
|
||||
provider: "github",
|
||||
repository: repository.url,
|
||||
});
|
||||
});
|
||||
|
||||
test("issue create and begin emit issue.created then issue.queued events", async () => {
|
||||
const t = convexTest({ schema, modules });
|
||||
const projectId = await t.mutation(internal.projects.storeGitHubProject, {
|
||||
ownerId: ID_A,
|
||||
repository,
|
||||
});
|
||||
|
||||
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 t.mutation(internal.projects.storeGitHubProject, {
|
||||
ownerId: ID_A,
|
||||
repository,
|
||||
});
|
||||
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 t.mutation(internal.projects.storeGitHubProject, {
|
||||
ownerId: ID_A,
|
||||
repository,
|
||||
});
|
||||
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 });
|
||||
|
||||
await t.mutation(api.agentWorkspace.ensureRun, {
|
||||
daemonId: "daemon-1",
|
||||
issueId,
|
||||
token: FLUE_DB_TOKEN,
|
||||
});
|
||||
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 t.mutation(internal.projects.storeGitHubProject, {
|
||||
ownerId: ID_A,
|
||||
repository,
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -67,7 +67,7 @@ export const create = mutation({
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
}
|
||||
await ctx.db.insert("projectSignals", {
|
||||
await ctx.db.insert("projectEvents", {
|
||||
createdAt: timestamp,
|
||||
data: { number, title },
|
||||
issueId,
|
||||
@@ -95,7 +95,7 @@ export const begin = mutation({
|
||||
status: "queued",
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
await ctx.db.insert("projectSignals", {
|
||||
await ctx.db.insert("projectEvents", {
|
||||
createdAt: timestamp,
|
||||
data: { number: issue.number },
|
||||
issueId: issue._id,
|
||||
@@ -120,7 +120,7 @@ export const markDispatchFailed = mutation({
|
||||
status: "failed",
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
await ctx.db.insert("projectSignals", {
|
||||
await ctx.db.insert("projectEvents", {
|
||||
createdAt: timestamp,
|
||||
data: { error: args.error.slice(0, 1000), phase: "dispatch" },
|
||||
issueId: issue._id,
|
||||
|
||||
@@ -144,7 +144,7 @@ export const storeGitHubProject = internalMutation({
|
||||
})
|
||||
)
|
||||
);
|
||||
await ctx.db.insert("projectSignals", {
|
||||
await ctx.db.insert("projectEvents", {
|
||||
createdAt: timestamp,
|
||||
data: { provider: "github", repository: args.repository.url },
|
||||
kind: "project.connected",
|
||||
|
||||
@@ -132,7 +132,7 @@ export default defineSchema({
|
||||
})
|
||||
.index("by_issue", ["issueId"])
|
||||
.index("by_project_and_createdAt", ["projectId", "createdAt"]),
|
||||
projectSignals: defineTable({
|
||||
projectEvents: defineTable({
|
||||
projectId: v.id("projects"),
|
||||
issueId: v.optional(v.id("projectIssues")),
|
||||
runId: v.optional(v.id("projectWorkRuns")),
|
||||
|
||||
Reference in New Issue
Block a user