diff --git a/hub/prisma/migrations/20260707072854_init/migration.sql b/hub/prisma/migrations/20260707072854_init/migration.sql new file mode 100644 index 0000000..fd68055 --- /dev/null +++ b/hub/prisma/migrations/20260707072854_init/migration.sql @@ -0,0 +1,271 @@ +-- CreateEnum +CREATE TYPE "PlatformRole" AS ENUM ('ADMIN', 'TEACHER'); + +-- CreateEnum +CREATE TYPE "AgentRunStatus" AS ENUM ('ACTIVE', 'WAITING_FOR_USER', 'COMPLETED', 'FAILED', 'TIMED_OUT', 'CANCELED'); + +-- CreateEnum +CREATE TYPE "AgentEntrypoint" AS ENUM ('FEISHU', 'WEB', 'CLI'); + +-- CreateEnum +CREATE TYPE "PermissionRole" AS ENUM ('READ', 'EDIT', 'MANAGE'); + +-- CreateEnum +CREATE TYPE "PermissionResourceType" AS ENUM ('PROJECT', 'ARTIFACT', 'PROJECT_GROUP'); + +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL, + "feishuOpenId" TEXT NOT NULL, + "displayName" TEXT NOT NULL, + "avatarUrl" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PlatformRoleAssignment" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "role" "PlatformRole" NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "revokedAt" TIMESTAMP(3), + + CONSTRAINT "PlatformRoleAssignment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Project" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "workspaceDir" TEXT NOT NULL, + "createdByUserId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "archivedAt" TIMESTAMP(3), + + CONSTRAINT "Project_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ProjectGroupBinding" ( + "id" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "chatId" TEXT NOT NULL, + "createdByUserId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ProjectGroupBinding_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "AgentSession" ( + "id" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "provider" TEXT NOT NULL, + "model" TEXT NOT NULL, + "title" TEXT, + "metadata" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "archivedAt" TIMESTAMP(3), + + CONSTRAINT "AgentSession_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "AgentRun" ( + "id" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "sessionId" TEXT, + "requestedByUserId" TEXT, + "entrypoint" "AgentEntrypoint" NOT NULL, + "status" "AgentRunStatus" NOT NULL DEFAULT 'ACTIVE', + "prompt" TEXT NOT NULL, + "model" TEXT NOT NULL, + "provider" TEXT NOT NULL, + "summary" TEXT, + "inputTokens" INTEGER, + "outputTokens" INTEGER, + "metadata" JSONB NOT NULL, + "error" TEXT, + "startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "finishedAt" TIMESTAMP(3), + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "AgentRun_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ProjectAgentLock" ( + "projectId" TEXT NOT NULL, + "runId" TEXT NOT NULL, + "holderUserId" TEXT, + "acquiredAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expiresAt" TIMESTAMP(3), + + CONSTRAINT "ProjectAgentLock_pkey" PRIMARY KEY ("projectId") +); + +-- CreateTable +CREATE TABLE "PermissionGrant" ( + "id" TEXT NOT NULL, + "resourceType" "PermissionResourceType" NOT NULL, + "resourceId" TEXT NOT NULL, + "principal" TEXT NOT NULL, + "role" "PermissionRole" NOT NULL, + "createdByUserId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "revokedAt" TIMESTAMP(3), + + CONSTRAINT "PermissionGrant_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PermissionSettings" ( + "id" TEXT NOT NULL, + "resourceType" "PermissionResourceType" NOT NULL, + "resourceId" TEXT NOT NULL, + "externalShare" TEXT NOT NULL, + "comment" TEXT NOT NULL, + "copyDownload" TEXT NOT NULL, + "collaboratorMgmt" TEXT NOT NULL, + "agentTrigger" TEXT NOT NULL, + "agentCancel" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "PermissionSettings_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "AuditEntry" ( + "id" TEXT NOT NULL, + "runId" TEXT, + "actorUserId" TEXT, + "action" TEXT NOT NULL, + "metadata" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "AuditEntry_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_feishuOpenId_key" ON "User"("feishuOpenId"); + +-- CreateIndex +CREATE INDEX "PlatformRoleAssignment_userId_revokedAt_idx" ON "PlatformRoleAssignment"("userId", "revokedAt"); + +-- CreateIndex +CREATE INDEX "PlatformRoleAssignment_role_revokedAt_idx" ON "PlatformRoleAssignment"("role", "revokedAt"); + +-- CreateIndex +CREATE INDEX "Project_archivedAt_idx" ON "Project"("archivedAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "ProjectGroupBinding_projectId_key" ON "ProjectGroupBinding"("projectId"); + +-- CreateIndex +CREATE UNIQUE INDEX "ProjectGroupBinding_chatId_key" ON "ProjectGroupBinding"("chatId"); + +-- CreateIndex +CREATE INDEX "ProjectGroupBinding_chatId_idx" ON "ProjectGroupBinding"("chatId"); + +-- CreateIndex +CREATE INDEX "AgentSession_projectId_archivedAt_idx" ON "AgentSession"("projectId", "archivedAt"); + +-- CreateIndex +CREATE INDEX "AgentSession_provider_model_idx" ON "AgentSession"("provider", "model"); + +-- CreateIndex +CREATE INDEX "AgentSession_updatedAt_idx" ON "AgentSession"("updatedAt"); + +-- CreateIndex +CREATE INDEX "AgentRun_projectId_status_idx" ON "AgentRun"("projectId", "status"); + +-- CreateIndex +CREATE INDEX "AgentRun_sessionId_idx" ON "AgentRun"("sessionId"); + +-- CreateIndex +CREATE INDEX "AgentRun_requestedByUserId_idx" ON "AgentRun"("requestedByUserId"); + +-- CreateIndex +CREATE INDEX "AgentRun_updatedAt_idx" ON "AgentRun"("updatedAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "ProjectAgentLock_runId_key" ON "ProjectAgentLock"("runId"); + +-- CreateIndex +CREATE INDEX "ProjectAgentLock_expiresAt_idx" ON "ProjectAgentLock"("expiresAt"); + +-- CreateIndex +CREATE INDEX "PermissionGrant_resourceType_resourceId_revokedAt_idx" ON "PermissionGrant"("resourceType", "resourceId", "revokedAt"); + +-- CreateIndex +CREATE INDEX "PermissionGrant_principal_revokedAt_idx" ON "PermissionGrant"("principal", "revokedAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "PermissionGrant_resourceType_resourceId_principal_role_revo_key" ON "PermissionGrant"("resourceType", "resourceId", "principal", "role", "revokedAt"); + +-- CreateIndex +CREATE INDEX "PermissionSettings_resourceType_resourceId_idx" ON "PermissionSettings"("resourceType", "resourceId"); + +-- CreateIndex +CREATE UNIQUE INDEX "PermissionSettings_resourceType_resourceId_key" ON "PermissionSettings"("resourceType", "resourceId"); + +-- CreateIndex +CREATE INDEX "AuditEntry_runId_idx" ON "AuditEntry"("runId"); + +-- CreateIndex +CREATE INDEX "AuditEntry_actorUserId_idx" ON "AuditEntry"("actorUserId"); + +-- CreateIndex +CREATE INDEX "AuditEntry_createdAt_idx" ON "AuditEntry"("createdAt"); + +-- AddForeignKey +ALTER TABLE "PlatformRoleAssignment" ADD CONSTRAINT "PlatformRoleAssignment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Project" ADD CONSTRAINT "Project_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ProjectGroupBinding" ADD CONSTRAINT "ProjectGroupBinding_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ProjectGroupBinding" ADD CONSTRAINT "ProjectGroupBinding_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AgentSession" ADD CONSTRAINT "AgentSession_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AgentRun" ADD CONSTRAINT "AgentRun_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AgentRun" ADD CONSTRAINT "AgentRun_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "AgentSession"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AgentRun" ADD CONSTRAINT "AgentRun_requestedByUserId_fkey" FOREIGN KEY ("requestedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ProjectAgentLock" ADD CONSTRAINT "ProjectAgentLock_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ProjectAgentLock" ADD CONSTRAINT "ProjectAgentLock_runId_fkey" FOREIGN KEY ("runId") REFERENCES "AgentRun"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ProjectAgentLock" ADD CONSTRAINT "ProjectAgentLock_holderUserId_fkey" FOREIGN KEY ("holderUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PermissionGrant" ADD CONSTRAINT "PermissionGrant_resourceId_fkey" FOREIGN KEY ("resourceId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PermissionGrant" ADD CONSTRAINT "PermissionGrant_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PermissionSettings" ADD CONSTRAINT "PermissionSettings_resourceId_fkey" FOREIGN KEY ("resourceId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AuditEntry" ADD CONSTRAINT "AuditEntry_actorUserId_fkey" FOREIGN KEY ("actorUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/hub/prisma/migrations/migration_lock.toml b/hub/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/hub/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/hub/test/integration/cph.test.ts b/hub/test/integration/cph.test.ts new file mode 100644 index 0000000..5bb7a38 --- /dev/null +++ b/hub/test/integration/cph.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ToolRegistry } from "../../src/agent/tools.js"; +import { cphCheckTool, cphBuildTool } from "../../src/agent/cph.js"; + +const EXAMPLES_DIR = join(process.cwd(), "..", "examples", "TH-141"); + +describe("cph subprocess tools (integration, real cph binary)", () => { + let ws: string; + let tools: ToolRegistry; + + beforeEach(async () => { + // Use the real TH-141 example as the workspace. + ws = EXAMPLES_DIR; + tools = new ToolRegistry(); + tools.register(cphCheckTool()); + tools.register(cphBuildTool()); + }); + + it("cph_check runs on the TH-141 example and returns diagnostics", async () => { + const ctx = { runId: "r", projectId: "p", boundChatId: "c", workspaceDir: ws }; + const out = await tools.execute("cph_check", {}, ctx); + const result = JSON.parse(out) as { exitCode: number; stdout: string; stderr: string }; + + // cph check exits 0 on a legal lesson (no error diagnostics, ADR-0010). + // If the example has warnings, exit is still 0. + expect(result.exitCode).toBe(0); + }, 30000); + + it("cph_build renders the student target PDF", async () => { + const ctx = { runId: "r", projectId: "p", boundChatId: "c", workspaceDir: ws }; + const out = await tools.execute("cph_build", { target: "student", output: "build/test-student.pdf" }, ctx); + const result = JSON.parse(out) as { exitCode: number; stdout: string; stderr: string; output: string }; + + expect(result.exitCode).toBe(0); + }, 30000); + + it("cph_check on a temp dir with no engineering file returns non-zero", async () => { + const empty = await mkdtemp(join(tmpdir(), "hub-cph-empty-")); + try { + const ctx = { runId: "r", projectId: "p", boundChatId: "c", workspaceDir: empty }; + const out = await tools.execute("cph_check", {}, ctx); + const result = JSON.parse(out) as { exitCode: number }; + expect(result.exitCode).not.toBe(0); + } finally { + await rm(empty, { recursive: true, force: true }); + } + }, 15000); +}); diff --git a/hub/test/integration/helpers.ts b/hub/test/integration/helpers.ts new file mode 100644 index 0000000..a8a55e7 --- /dev/null +++ b/hub/test/integration/helpers.ts @@ -0,0 +1,135 @@ +/** + * Test helpers for integration tests. + * + * Each test gets a clean DB (tables truncated before the test), a mock + * FeishuRuntime (sendText/sendCard are no-ops that record calls), and a mock + * AgentProvider (chat() returns a fixed "stop" response — no real network). + */ +import { PrismaClient } from "@prisma/client"; +import type { FastifyBaseLogger } from "fastify"; +import type { FeishuRuntime } from "../src/feishu/client.js"; +import type { AgentProvider, ChatRequest, ChatResponse, Message } from "../src/agent/provider.js"; + +export const TEST_DATABASE_URL = "postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test"; + +export const prisma = new PrismaClient({ + datasources: { db: { url: TEST_DATABASE_URL } }, +}); + +/** Truncate all tables before each test for isolation. */ +export async function resetDb(): Promise { + const tables = [ + "AuditEntry", + "PermissionSettings", + "PermissionGrant", + "ProjectAgentLock", + "AgentRun", + "AgentSession", + "ProjectGroupBinding", + "PlatformRoleAssignment", + "User", + "Project", + ]; + // Truncate with CASCADE to wipe dependent rows in one shot. + await prisma.$executeRawUnsafe(`TRUNCATE TABLE ${tables.map((t) => `"${t}"`).join(", ")} RESTART IDENTITY CASCADE`); +} + +/** A logger that discards everything (tests don't need fastify's pino). */ +export const silentLogger: FastifyBaseLogger = { + info() {}, warn() {}, error() {}, debug() {}, fatal() {}, + child() { return this; }, + level: "silent", +} as unknown as FastifyBaseLogger; + +/** Records sendText/sendCard calls so tests can assert on them. */ +export interface MockFeishuRuntime extends FeishuRuntime { + readonly sentTexts: string[]; + readonly sentCards: unknown[]; +} + +export function mockFeishuRuntime(): MockFeishuRuntime { + const sentTexts: string[] = []; + const sentCards: unknown[] = []; + // Mock the client — sendText/sendCard are in client.ts, not on the runtime + // object directly. We patch by providing a runtime whose client is a stub; + // the actual send functions cast through shape, so a minimal stub works. + const rt: MockFeishuRuntime = { + client: { + im: { + v1: { + message: { + create: async (p: unknown) => { + const payload = p as { data?: { msg_type?: string; content?: string } }; + if (payload.data?.msg_type === "interactive") { + sentCards.push(payload.data.content ? JSON.parse(payload.data.content) : null); + } else { + try { + const c = JSON.parse(payload.data?.content ?? "{}") as { text?: string }; + sentTexts.push(c.text ?? payload.data?.content ?? ""); + } catch { + sentTexts.push(payload.data?.content ?? ""); + } + } + return { data: { message_id: "mock-msg-id" } }; + }, + }, + }, + }, + } as unknown as FeishuRuntime["client"], + logger: silentLogger, + sentTexts, + sentCards, + }; + return rt; +} + +/** A provider that always returns a fixed "stop" response — no network. */ +export class MockProvider implements AgentProvider { + readonly id = "mock"; + readonly calls: ChatRequest[] = []; + + async chat(req: ChatRequest): Promise { + this.calls.push(req); + const message: Message = { + role: "assistant", + parts: [{ type: "text", text: "mock response" }], + }; + return { finishReason: "stop", message, usage: { inputTokens: 10, outputTokens: 5 } }; + } +} + +/** Create a project + binding + user + grant for a test. */ +export async function seedProject( + projectId: string, + chatId: string, + options: { principal?: string; role?: "READ" | "EDIT" | "MANAGE" } = {}, +): Promise { + const principal = options.principal ?? "ou_test_user"; + const role = options.role ?? "EDIT"; + await prisma.project.create({ + data: { + id: projectId, + name: `Test ${projectId}`, + workspaceDir: `/tmp/test-${projectId}`, + }, + }); + await prisma.user.create({ + data: { + id: "u_" + projectId, + feishuOpenId: principal, + displayName: "Test User", + platformRoles: { create: { role: "TEACHER" } }, + permissionGrants: { + create: { + resourceType: "PROJECT", + resourceId: projectId, + principal, + role, + }, + }, + }, + }); + await prisma.projectGroupBinding.create({ + data: { projectId, chatId, createdByUserId: "u_" + projectId }, + }); +} diff --git a/hub/test/integration/lock.test.ts b/hub/test/integration/lock.test.ts new file mode 100644 index 0000000..cb9cbb5 --- /dev/null +++ b/hub/test/integration/lock.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, beforeEach, afterAll } from "vitest"; +import { prisma, resetDb } from "./helpers.js"; +import { acquireLock, releaseLock, currentLockRunId, isTerminal } from "../../src/lock.js"; + +describe("ProjectAgentLock WellFormed (integration, ADR-0002)", () => { + beforeEach(async () => { + await resetDb(); + }); + + it("acquireLock + currentLockRunId round-trip", async () => { + const project = await prisma.project.create({ + data: { id: "p-lock-1", name: "Test", workspaceDir: "/tmp/x" }, + }); + const run = await prisma.agentRun.create({ + data: { projectId: project.id, entrypoint: "FEISHU", status: "ACTIVE", prompt: "x", model: "m", provider: "mock", metadata: {} }, + }); + + await acquireLock(prisma, project.id, run.id, null); + const holder = await currentLockRunId(prisma, project.id); + expect(holder).toBe(run.id); + + await releaseLock(prisma, run.id); + const after = await currentLockRunId(prisma, project.id); + expect(after).toBeNull(); + }); + + it("acquireLock fails when project already locked (exclusivity)", async () => { + const project = await prisma.project.create({ + data: { id: "p-lock-2", name: "Test", workspaceDir: "/tmp/x" }, + }); + const run1 = await prisma.agentRun.create({ + data: { projectId: project.id, entrypoint: "FEISHU", status: "ACTIVE", prompt: "x", model: "m", provider: "mock", metadata: {} }, + }); + const run2 = await prisma.agentRun.create({ + data: { projectId: project.id, entrypoint: "FEISHU", status: "ACTIVE", prompt: "y", model: "m", provider: "mock", metadata: {} }, + }); + + await acquireLock(prisma, project.id, run1.id, null); + await expect(acquireLock(prisma, project.id, run2.id, null)).rejects.toThrow(); + }); + + it("currentLockRunId throws when a terminal run holds the lock (WellFormed)", async () => { + const project = await prisma.project.create({ + data: { id: "p-lock-3", name: "Test", workspaceDir: "/tmp/x" }, + }); + // Create a run that is already COMPLETED (terminal), then manually insert a lock. + const run = await prisma.agentRun.create({ + data: { projectId: project.id, entrypoint: "FEISHU", status: "COMPLETED", prompt: "x", model: "m", provider: "mock", metadata: {}, finishedAt: new Date() }, + }); + await prisma.projectAgentLock.create({ + data: { projectId: project.id, runId: run.id }, + }); + + // WellFormed invariant: reading a lock held by a terminal run must throw. + await expect(currentLockRunId(prisma, project.id)).rejects.toThrow(/lock invariant violated/); + }); + + it("releaseLock is idempotent", async () => { + const project = await prisma.project.create({ + data: { id: "p-lock-4", name: "Test", workspaceDir: "/tmp/x" }, + }); + const run = await prisma.agentRun.create({ + data: { projectId: project.id, entrypoint: "FEISHU", status: "ACTIVE", prompt: "x", model: "m", provider: "mock", metadata: {} }, + }); + + await acquireLock(prisma, project.id, run.id, null); + await releaseLock(prisma, run.id); + // Second release is a no-op. + await releaseLock(prisma, run.id); + expect(await currentLockRunId(prisma, project.id)).toBeNull(); + }); + + it("isTerminal matches spec RunState.Terminal", () => { + expect(isTerminal("ACTIVE")).toBe(false); + expect(isTerminal("WAITING_FOR_USER")).toBe(false); + expect(isTerminal("COMPLETED")).toBe(true); + expect(isTerminal("FAILED")).toBe(true); + expect(isTerminal("TIMED_OUT")).toBe(true); + expect(isTerminal("CANCELED")).toBe(true); + }); +}); + +afterAll(async () => { + await prisma.$disconnect(); +}); diff --git a/hub/test/integration/trigger.test.ts b/hub/test/integration/trigger.test.ts new file mode 100644 index 0000000..6cacd36 --- /dev/null +++ b/hub/test/integration/trigger.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect, beforeEach, afterAll, vi } from "vitest"; +import { prisma, resetDb, mockFeishuRuntime, MockProvider, seedProject, silentLogger } from "./helpers.js"; +import { InMemoryModelRegistry } from "../../src/agent/models.js"; +import { ToolRegistry, feishuContextTool } from "../../src/agent/tools.js"; +import { makeTriggerHandler, extractPrompt } from "../../src/feishu/trigger.js"; +import type { MessageReceiveEvent } from "../../src/feishu/client.js"; + +const bot = { key: "@_user_1", id: { open_id: "ou_bot" }, name: "Bot" }; + +function makeEvent(chatId: string, text: string, senderOpenId = "ou_test_user"): MessageReceiveEvent { + return { + message: { + message_id: "m_" + Math.random().toString(36).slice(2), + chat_id: chatId, + chat_type: "group", + message_type: "text", + content: JSON.stringify({ text }), + mentions: [bot], + }, + sender: { sender_id: { open_id: senderOpenId }, sender_type: "user" }, + }; +} + +describe("trigger full lifecycle (integration)", () => { + let provider: MockProvider; + let tools: ToolRegistry; + let models: InMemoryModelRegistry; + let rt: ReturnType; + + beforeEach(async () => { + await resetDb(); + provider = new MockProvider(); + tools = new ToolRegistry(); + models = new InMemoryModelRegistry( + [{ id: "mock-model", label: "Mock", toolCapable: true }], + [{ id: "draft", label: "草稿", defaultModel: "mock-model" }], + ); + rt = mockFeishuRuntime(); + }); + + it("creates a run, acquires + releases the lock, sends status card", async () => { + await seedProject("proj-1", "chat-1"); + const trigger = makeTriggerHandler({ prisma, provider, tools, models, logger: silentLogger }); + + await trigger(makeEvent("chat-1", "@_user_1 写教案"), rt); + + // Wait for the async run to complete (fire-and-forget in trigger). + await vi.waitFor(async () => { + const runs = await prisma.agentRun.findMany(); + expect(runs).toHaveLength(1); + expect(runs[0]?.status).toBe("COMPLETED"); + }); + + // Lock released (no lock row remains). + const locks = await prisma.projectAgentLock.findMany(); + expect(locks).toHaveLength(0); + + // A status card was sent. + expect(rt.sentCards.length).toBeGreaterThanOrEqual(1); + expect(rt.sentTexts).toContain("已开始处理(model: mock-model)。"); + }); + + it("rejects a sender without edit grant (ADR-0004)", async () => { + await seedProject("proj-2", "chat-2", { role: "READ" }); + const trigger = makeTriggerHandler({ prisma, provider, tools, models, logger: silentLogger }); + + await trigger(makeEvent("chat-2", "@_user_1 写教案"), rt); + + expect(rt.sentTexts).toContain("无权限触发。"); + const runs = await prisma.agentRun.findMany(); + expect(runs).toHaveLength(0); + }); + + it("replies busy when project is already locked (ADR-0002)", async () => { + await seedProject("proj-3", "chat-3"); + // Manually create a lock by inserting a run + lock. + const existingRun = await prisma.agentRun.create({ + data: { projectId: "proj-3", entrypoint: "FEISHU", status: "ACTIVE", prompt: "x", model: "m", provider: "mock", metadata: {} }, + }); + await prisma.projectAgentLock.create({ + data: { projectId: "proj-3", runId: existingRun.id }, + }); + + const trigger = makeTriggerHandler({ prisma, provider, tools, models, logger: silentLogger }); + await trigger(makeEvent("chat-3", "@_user_1 写教案"), rt); + + expect(rt.sentTexts).toContain("项目正在处理中,请稍候。"); + // No new run created. + const runs = await prisma.agentRun.findMany(); + expect(runs).toHaveLength(1); + }); + + it("ignores messages from unbound chats (ADR-0001)", async () => { + await seedProject("proj-4", "chat-4"); + const trigger = makeTriggerHandler({ prisma, provider, tools, models, logger: silentLogger }); + + await trigger(makeEvent("chat-UNKNOWN", "@_user_1 写教案"), rt); + + expect(rt.sentTexts).toHaveLength(0); + expect(rt.sentCards).toHaveLength(0); + const runs = await prisma.agentRun.findMany(); + expect(runs).toHaveLength(0); + }); + + it("ignores messages without @bot mention", async () => { + await seedProject("proj-5", "chat-5"); + const trigger = makeTriggerHandler({ prisma, provider, tools, models, logger: silentLogger }); + + const event: MessageReceiveEvent = { + message: { + message_id: "m_nobot", + chat_id: "chat-5", + chat_type: "group", + message_type: "text", + content: JSON.stringify({ text: "hello no bot" }), + mentions: undefined, + }, + sender: { sender_id: { open_id: "ou_test_user" }, sender_type: "user" }, + }; + await trigger(event, rt); + + expect(rt.sentTexts).toHaveLength(0); + const runs = await prisma.agentRun.findMany(); + expect(runs).toHaveLength(0); + }); +}); + +afterAll(async () => { + await prisma.$disconnect(); +}); diff --git a/hub/vitest.config.ts b/hub/vitest.config.ts index 05cf2bb..f9623a9 100644 --- a/hub/vitest.config.ts +++ b/hub/vitest.config.ts @@ -3,7 +3,10 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { include: ["test/**/*.test.ts"], - // Unit tests run without a DB; integration tests set their own env. + // Integration tests share one DB; run files sequentially to avoid + // concurrent truncate/insert races. Unit tests are fast either way. + pool: "forks", + fileParallelism: false, env: { NODE_ENV: "test" }, }, });