mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-03 01:27:09 +01:00
Add company-scoped Muse native control, OAuth, wake receiver and recovery. Recheck current authority after waits and before effects or receipt replay. Preserve task-read scope, assignment expiry, independent holds, nonblocking reassignment and truthful cleanup provenance. Retain source trust on progress comments and verify current Muse V8 authority through real signed-JWT HTTP requests. Preserve the upstream trimmed ordinary authentication lookup. Refs #16448. Preserve captured canonical queue recovery and concurrent environment preparation while retaining the scoped Muse V8 exclusion. Normal migration 0354 and existing Muse SQL remain unchanged. Fresh current-head Greptile review is 5/5 with the project-write finding addressed. Required CI and independent technical review gate the merge; maintainer override is reserved for formal approval rules. Recheck current Muse project-write authority after all receipt/task/run/admission waits, before creation or replay. Real PostgreSQL regressions reproduce four late effects/replays on the old route; the repaired three-file treatment passes 354 cases with no skips, and the scoped server typecheck passes. Ordinary/Dot behavior is preserved. Co-Authored-By: Paperclip <noreply@paperclip.ing>
88 lines
7.3 KiB
TypeScript
88 lines
7.3 KiB
TypeScript
import { randomBytes, randomUUID } from "node:crypto";
|
|
import { and, eq } from "drizzle-orm";
|
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
|
import { agents, agentWakeupRequests, authUsers, companies, companyMemberships, createDb,
|
|
heartbeatRuns, issueComments, issues, museAgentBindings, museMailboxItems,
|
|
museRunnerAssignments, museRunnerOperations } from "@paperclipai/db";
|
|
import { startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
|
|
import { heartbeatService } from "../heartbeat.js";
|
|
import { instanceSettingsService } from "../instance-settings.js";
|
|
import { consumeMuseHistoryReceipt } from "../muse-assignment-follow-up.js";
|
|
import { queuedCommentIdsFromWakePayload } from "../issue-queued-comment-queue.js";
|
|
|
|
describe("Muse native comment follow-up", () => {
|
|
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
|
let db: ReturnType<typeof createDb>;
|
|
beforeAll(async () => {
|
|
temporary = await startEmbeddedPostgresTestDatabase("muse-native-follow-up-");
|
|
db = createDb(temporary.connectionString);
|
|
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", randomBytes(32).toString("base64"));
|
|
await instanceSettingsService(db).updateExperimental({ enableNativeRunner: true, enableMuse: true });
|
|
}, 30000);
|
|
afterAll(async () => { await temporary?.cleanup(); vi.unstubAllEnvs(); });
|
|
async function fixture() {
|
|
const companyId = randomUUID(), agentId = randomUUID(), userId = randomUUID(), runId = randomUUID(), bindingId = randomUUID();
|
|
await db.insert(authUsers).values({ id: userId, name: "Muse operator", email: `${userId}@example.test`, createdAt: new Date(), updatedAt: new Date() });
|
|
await db.insert(companies).values({ id: companyId, name: "Muse follow-up", issuePrefix: `MF${companyId.slice(0, 6)}`, defaultResponsibleUserId: userId });
|
|
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: userId, status: "active", membershipRole: "owner" });
|
|
await db.insert(agents).values({ id: agentId, companyId, name: "Muse", status: "active", adapterType: "paperclip_runner",
|
|
adapterConfig: { provider: "muse", museBindingId: bindingId, allowUnmeteredProvider: true, lifecycleMode: "per_turn" } });
|
|
await db.insert(museAgentBindings).values({ id: bindingId, companyId, agentId, operatorId: userId,
|
|
status: "ready", pairedAt: new Date(), receiverContactAt: new Date(), verifiedReplyAt: new Date() });
|
|
const [task] = await db.insert(issues).values({ companyId, title: "Active Muse task", status: "in_progress",
|
|
assigneeAgentId: agentId, responsibleUserId: userId }).returning();
|
|
const sessionId = randomUUID();
|
|
const [run] = await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running", invocationSource: "assignment",
|
|
runtimeMode: "native", nativeIssueId: task!.id, issueId: task!.id, nativeSessionId: sessionId,
|
|
responsibleUserId: userId, contextSnapshot: { issueId: task!.id } }).returning();
|
|
await db.update(issues).set({ executionRunId: runId, checkoutRunId: runId }).where(eq(issues.id, task!.id));
|
|
const [assignment] = await db.insert(museRunnerAssignments).values({ companyId, bindingId, bindingGeneration: 1, runId, agentId,
|
|
normalizedSessionId: sessionId, turnId: randomUUID(), controllerGeneration: 1, catalogDigest: "sha256:fixture",
|
|
status: "accepted", projection: {}, acceptBy: new Date(Date.now() + 60000), expiresAt: new Date(Date.now() + 60000),
|
|
claimedAt: new Date(), nativeAcceptedAt: new Date() }).returning();
|
|
const comments = await db.insert(issueComments).values([1, 2].map(index => ({ companyId, issueId: task!.id,
|
|
authorUserId: userId, body: `Follow-up ${index}` }))).returning();
|
|
const wake = (commentId: string) => heartbeatService(db).wakeup(agentId, { source: "assignment", triggerDetail: "system",
|
|
reason: "issue_commented", payload: { issueId: task!.id, commentId }, requestedByActorType: "user", requestedByActorId: userId,
|
|
contextSnapshot: { issueId: task!.id, wakeCommentId: commentId, wakeReason: "issue_commented" } });
|
|
async function history(commentIds: string[], failed = false) {
|
|
const [receipt] = await db.insert(museRunnerOperations).values({ companyId, assignmentId: assignment!.id,
|
|
requestId: randomUUID(), digest: "sha256:history", status: "settled",
|
|
command: { action: "tool", input: { name: "get_task_history", arguments: {} } },
|
|
outcome: { status: "completed", isError: failed, result: { comments: commentIds.map(id => ({ id })) } } }).returning();
|
|
return receipt!;
|
|
}
|
|
return { companyId, agentId, run: run!, assignment: assignment!, comments, wake, history };
|
|
}
|
|
it("queues unread comments on the accepted turn, and consumes only an exact successful history receipt", async () => {
|
|
const f = await fixture();
|
|
for (const comment of [f.comments[0]!, f.comments[0]!, f.comments[1]!]) expect((await f.wake(comment.id))?.id).toBe(f.run.id);
|
|
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.agentId, f.agentId))).toHaveLength(1);
|
|
let wakes = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.agentId, f.agentId));
|
|
expect(wakes).toHaveLength(1);
|
|
expect(wakes[0]).toMatchObject({ status: "deferred_issue_execution", runId: null, finishedAt: null });
|
|
expect(queuedCommentIdsFromWakePayload(wakes[0]!.payload)).toEqual(f.comments.map(comment => comment.id));
|
|
expect(await db.select().from(museMailboxItems).where(and(eq(museMailboxItems.assignmentId, f.assignment.id), eq(museMailboxItems.kind, "follow_up")))).toHaveLength(2);
|
|
// Reading references is not consumption; the deferred wake remains durable.
|
|
wakes = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.agentId, f.agentId));
|
|
expect(wakes[0]?.status).toBe("deferred_issue_execution");
|
|
const receipt = await f.history(f.comments.map(comment => comment.id));
|
|
await consumeMuseHistoryReceipt(db, receipt);
|
|
await consumeMuseHistoryReceipt(db, receipt);
|
|
expect((await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, wakes[0]!.id)))[0]).toMatchObject({ status: "coalesced", runId: f.run.id });
|
|
});
|
|
it.each(["partial", "failed", "read before admission", "terminal"] as const)("preserves unread input at the %s boundary", async scenario => {
|
|
const f = await fixture(), receipt = await f.history([f.comments[0]!.id], scenario === "failed");
|
|
if (scenario === "read before admission") await consumeMuseHistoryReceipt(db, receipt);
|
|
for (const comment of f.comments) await f.wake(comment.id);
|
|
if (scenario === "terminal") await db.update(heartbeatRuns).set({ status: "cancelled", finishedAt: new Date() }).where(eq(heartbeatRuns.id, f.run.id));
|
|
await consumeMuseHistoryReceipt(db, receipt);
|
|
const wakes = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.agentId, f.agentId));
|
|
const pending = wakes.find(wake => wake.status === "deferred_issue_execution");
|
|
expect(pending).toBeDefined();
|
|
expect(queuedCommentIdsFromWakePayload(pending!.payload)).toEqual(["failed", "terminal"].includes(scenario)
|
|
? f.comments.map(comment => comment.id) : [f.comments[1]!.id]);
|
|
if (scenario === "read before admission") expect(wakes.find(wake => wake.payload?.commentId === f.comments[0]!.id)?.status).toBe("coalesced");
|
|
});
|
|
});
|