diff --git a/packages/backend/convex/_generated/api.d.ts b/packages/backend/convex/_generated/api.d.ts index 222ca6d..3296b47 100644 --- a/packages/backend/convex/_generated/api.d.ts +++ b/packages/backend/convex/_generated/api.d.ts @@ -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; diff --git a/packages/backend/convex/agentWorkspace.ts b/packages/backend/convex/agentWorkspace.ts index b3178d8..3a39e1b 100644 --- a/packages/backend/convex/agentWorkspace.ts +++ b/packages/backend/convex/agentWorkspace.ts @@ -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, diff --git a/packages/backend/convex/artifactModel.ts b/packages/backend/convex/artifactModel.ts index 5c4f2c8..7109d63 100644 --- a/packages/backend/convex/artifactModel.ts +++ b/packages/backend/convex/artifactModel.ts @@ -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", }, { diff --git a/packages/backend/convex/projectEvents.test.ts b/packages/backend/convex/projectEvents.test.ts new file mode 100644 index 0000000..e608961 --- /dev/null +++ b/packages/backend/convex/projectEvents.test.ts @@ -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 Promise>; + } +} + +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, + 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. 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); + }); +}); diff --git a/packages/backend/convex/projectIssues.ts b/packages/backend/convex/projectIssues.ts index a810b53..bf32afa 100644 --- a/packages/backend/convex/projectIssues.ts +++ b/packages/backend/convex/projectIssues.ts @@ -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, diff --git a/packages/backend/convex/projects.ts b/packages/backend/convex/projects.ts index 6ac59ab..5dbb273 100644 --- a/packages/backend/convex/projects.ts +++ b/packages/backend/convex/projects.ts @@ -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", diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts index da7b3f8..5d25127 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -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")),