Files
PaperClipAI/server/src/__tests__/plugin-orchestration-apis.test.ts
T
7a4767017e fix(issues): base comment-wake decisions on post-insert issue state (#10068)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work; issues get commented on by both humans and agents, and the
assignee is woken to act on new comments.
> - #10050 added human-attributed issue comments for chat gateway
plugins, with the host waking the issue's assignee the same way a board
user's comment does.
> - Greptile's review on #10050 flagged that the wakeup guard in
`plugin-host-services.ts` decides whether to wake the assignee using the
issue snapshot fetched *before* the comment was inserted.
> - If another request closes, cancels, unassigns, or reassigns the
issue in the window between that fetch and the wakeup call, the guard
still acts on the stale snapshot — it can wake an agent for a
now-terminal issue, or wake the old assignee instead of the new one.
> - The PR discussion noted the HTTP add-comment route
(`routes/issues.ts`) has the identical pattern outside its
reopen/auto-approval branches, and deferred a fix to a follow-up
covering both call sites — this PR is that follow-up.
> - The fix re-fetches the issue immediately before the wake decision in
both places, so the decision reflects the latest committed state instead
of a pre-insert snapshot.

## Linked Issues or Issue Description

Refs #10050

**Problem or motivation**

Both the plugin-comment wakeup guard (`plugin-host-services.ts`) and the
HTTP add-comment route's wakeup guard (`routes/issues.ts`, outside its
reopen/auto-approval branches) decide whether to wake the issue's
assignee using the issue state fetched before the comment was inserted.
A concurrent close/unassign/reassign landing in that window is invisible
to the guard, so it can enqueue a wakeup for a stale assignee or an
issue that is no longer open.

**Proposed solution**

Re-fetch the issue immediately before the wake decision in both call
sites, and base the assignee/status checks on that fresh read instead of
the earlier snapshot. This shrinks the race window to essentially
nothing (the fetch happens right before the fire-and-forget wakeup
call), and any residual window is already covered by the
heartbeat/checkout machinery re-validating issue status and assignee
ownership when a woken run actually starts.

**Alternatives considered**

Wrap the whole comment-insert + wake-decision sequence in a single
serializable transaction with row locking (rejected for this change —
much larger blast radius across two already-complex handlers for a
wakeup that is explicitly best-effort; the woken run's own re-validation
already makes a stale wake degrade to a no-op rather than incorrect
work). Leaving the plugin path fixed but not the HTTP route (rejected —
that was the exact gap the original PR discussion flagged as needing a
follow-up covering both call sites).

**Roadmap alignment**

Bug fix / hardening follow-up to #10050; no change to planned core
roadmap items.

## What Changed

- `server/src/services/plugin-host-services.ts`:
`issues.createComment`'s assignee-wakeup guard now re-fetches the issue
after the comment is inserted and bases the assignee/status checks on
that fresh read, instead of the snapshot fetched before the insert.
- `server/src/routes/issues.ts`: the `POST /issues/:id/comments` route's
wakeup guard (outside the reopen/auto-approval branches, which already
use post-mutation state) now does the same re-fetch before deciding
whether — and whom — to wake.
- Adds regression coverage for both:
- `server/src/__tests__/plugin-orchestration-apis.test.ts`: a new
embedded-Postgres test holds a row lock on the issue to
deterministically force the race (comment-insert's internal update
blocks until a concurrent transaction commits a cancellation), then
asserts no wakeup is enqueued.
- `server/src/__tests__/issue-comment-reopen-routes.test.ts`: two new
mocked-service tests assert the route skips the wakeup when the fresh
re-fetch shows the issue cancelled, and wakes the freshly reassigned
agent (not the pre-insert snapshot's assignee) when the fresh re-fetch
shows a different assignee.

## Verification

- `pnpm --filter @paperclipai/server typecheck` — clean.
- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/plugin-orchestration-apis.test.ts` — 13/13 (1 new).
- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/issue-comment-reopen-routes.test.ts` — 74/74 (2 new).
- `pnpm --filter @paperclipai/plugin-sdk exec vitest run
tests/host-client-factory.test.ts` — 14/14.
- Broader sweep of 38 `routes/issues.ts`-adjacent test files (447 tests)
— all passing, confirming the added re-fetch doesn't change behavior for
any existing
reopen/auto-approval/interrupt/scheduled-retry/dependency-wake scenario.

## Risks

Low risk. Both changes are additive guards around an existing
best-effort, fire-and-forget wakeup (failures already logged, not
thrown) — no change to the comment-write path itself, response shape, or
status codes. The HTTP route's fix only touches the plain (non-reopen,
non-auto-approval) wake-decision path; the reopen and auto-approval
branches already used post-mutation state for the reasons documented
inline and are unchanged. Adds one extra `SELECT` per comment on each
call site, negligible relative to the existing query volume in both
handlers.

## Model Used

Claude Sonnet 5 (`claude-sonnet-5`), extended thinking, tool use
enabled, via Claude Code.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I searched the GitHub PR list (open and recently closed) for
similar PRs; found no duplicate — this is a direct follow-up to the
review discussion on #10050
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links
- [x] My branch name describes the change and contains no internal
ticket id
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: anicca <annica@Michaels-Mac-Studio.local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 14:18:15 -07:00

1288 lines
48 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { and, eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
activityLog,
agentWakeupRequests,
agents,
approvals,
assets,
companies,
companyMemberships,
costEvents,
createDb,
executionWorkspaces,
heartbeatRunEvents,
heartbeatRuns,
issueAttachments,
issueComments,
issueRelations,
issueThreadInteractions,
issues,
pluginManagedResources,
plugins,
projects,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { buildHostServices } from "../services/plugin-host-services.js";
import { heartbeatService } from "../services/heartbeat.js";
import { drainHeartbeatRunsToQuiescence } from "./helpers/drain-heartbeat-runs.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
function createEventBusStub() {
return {
forPlugin() {
return {
emit: async () => {},
subscribe: () => {},
};
},
} as any;
}
function issuePrefix(id: string) {
return `T${id.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
}
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres plugin orchestration API tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describeEmbeddedPostgres("plugin orchestration APIs", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
const tempRoots: string[] = [];
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-plugin-orchestration-");
db = createDb(tempDb.connectionString);
}, 20_000);
function isHeartbeatRunDependentFkError(error: unknown) {
const message = error instanceof Error ? `${error.message} ${String(error.cause ?? "")}` : String(error);
return (
message.includes("heartbeat_run_events_run_id_heartbeat_runs_id_fk")
|| message.includes("activity_log_run_id_heartbeat_runs_id_fk")
);
}
// A real (fire-and-forget) heartbeat run may still be executing in the
// background when a test's own createComment-triggered wakeup completes —
// retry deletion so that race doesn't fail cleanup with an FK violation.
async function deleteHeartbeatRunsWithDependents() {
for (let attempt = 0; attempt < 5; attempt += 1) {
await db.delete(heartbeatRunEvents);
await db.delete(activityLog);
try {
await db.delete(heartbeatRuns);
return;
} catch (error) {
if (!isHeartbeatRunDependentFkError(error) || attempt === 4) throw error;
await new Promise((resolve) => setTimeout(resolve, 25));
}
}
}
afterEach(async () => {
await Promise.all(tempRoots.map((root) => fs.rm(root, { recursive: true, force: true })));
tempRoots.length = 0;
// Await every in-flight background heartbeat run to quiescence before the
// deletes below. A createComment-triggered wakeup dispatches its run
// fire-and-forget (void heartbeat.wakeup(...)), so a run or wakeup can still
// write heartbeat_runs and issues rows when teardown starts and would race
// the deletes. The heartbeat service tracks in-flight run and wakeup
// promises in module state shared across service instances, so a fresh
// instance here drains the runs the per-test host services dispatched.
await drainHeartbeatRunsToQuiescence(db, heartbeatService(db));
await db.delete(costEvents);
await deleteHeartbeatRunsWithDependents();
await db.delete(agentWakeupRequests);
await db.delete(issueRelations);
await db.delete(issueComments);
await db.delete(issueThreadInteractions);
await db.delete(issueAttachments);
await db.delete(assets);
await db.delete(approvals);
await db.delete(issues);
await db.delete(executionWorkspaces);
await db.delete(pluginManagedResources);
await db.delete(projects);
await db.delete(plugins);
await db.delete(companyMemberships);
await db.delete(agents);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
async function seedCompanyAndAgent() {
const companyId = randomUUID();
const agentId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: issuePrefix(companyId),
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "Engineer",
role: "engineer",
status: "idle",
adapterType: "process",
adapterConfig: { command: "true" },
runtimeConfig: {},
permissions: {},
});
return { companyId, agentId };
}
async function makeLocalRoot() {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-plugin-host-folder-"));
tempRoots.push(root);
return root;
}
it("returns plugin-safe execution workspace metadata scoped to the company", async () => {
const { companyId } = await seedCompanyAndAgent();
const otherCompanyId = randomUUID();
const projectId = randomUUID();
const workspaceId = randomUUID();
await db.insert(companies).values({
id: otherCompanyId,
name: "Other",
issuePrefix: issuePrefix(otherCompanyId),
requireBoardApprovalForNewAgents: false,
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Workspaces",
status: "in_progress",
});
await db.insert(executionWorkspaces).values({
id: workspaceId,
companyId,
projectId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Feature workspace",
status: "active",
cwd: "/tmp/paperclip-feature",
repoUrl: "https://example.com/paperclip.git",
baseRef: "main",
branchName: "feature/workspace",
providerType: "git_worktree",
providerRef: "/tmp/paperclip-feature",
metadata: {
providerMetadata: { sandboxId: "sandbox-1" },
workspaceRealizationRequest: { hiddenInternal: true },
},
});
const services = buildHostServices(db, "plugin-record-id", "paperclip.workspace", createEventBusStub());
await expect(services.executionWorkspaces.get({ workspaceId, companyId })).resolves.toMatchObject({
id: workspaceId,
companyId,
projectId,
projectWorkspaceId: null,
path: "/tmp/paperclip-feature",
cwd: "/tmp/paperclip-feature",
repoUrl: "https://example.com/paperclip.git",
baseRef: "main",
branchName: "feature/workspace",
providerType: "git_worktree",
providerMetadata: { sandboxId: "sandbox-1" },
});
await expect(services.executionWorkspaces.get({ workspaceId, companyId: otherCompanyId })).resolves.toBeNull();
});
it("creates plugin-origin issues with full orchestration fields and audit activity", async () => {
const { companyId, agentId } = await seedCompanyAndAgent();
const blockerIssueId = randomUUID();
const originRunId = randomUUID();
await db.insert(heartbeatRuns).values({
id: originRunId,
companyId,
agentId,
status: "running",
invocationSource: "assignment",
contextSnapshot: { issueId: blockerIssueId },
});
await db.insert(issues).values({
id: blockerIssueId,
companyId,
title: "Blocker",
status: "todo",
priority: "medium",
identifier: `${issuePrefix(companyId)}-blocker`,
});
const services = buildHostServices(db, "plugin-record-id", "paperclip.missions", createEventBusStub());
const issue = await services.issues.create({
companyId,
title: "Plugin child issue",
status: "todo",
assigneeAgentId: agentId,
billingCode: "mission:alpha",
originId: "mission-alpha",
blockedByIssueIds: [blockerIssueId],
actorAgentId: agentId,
actorRunId: originRunId,
});
const [stored] = await db.select().from(issues).where(eq(issues.id, issue.id));
expect(stored?.originKind).toBe("plugin:paperclip.missions");
expect(stored?.originId).toBe("mission-alpha");
expect(stored?.billingCode).toBe("mission:alpha");
expect(stored?.assigneeAgentId).toBe(agentId);
expect(stored?.createdByAgentId).toBe(agentId);
expect(stored?.originRunId).toBe(originRunId);
const [relation] = await db
.select()
.from(issueRelations)
.where(and(eq(issueRelations.issueId, blockerIssueId), eq(issueRelations.relatedIssueId, issue.id)));
expect(relation?.type).toBe("blocks");
const activities = await db
.select()
.from(activityLog)
.where(and(eq(activityLog.entityType, "issue"), eq(activityLog.entityId, issue.id)));
expect(activities).toEqual(
expect.arrayContaining([
expect.objectContaining({
actorType: "plugin",
actorId: "plugin-record-id",
action: "issue.created",
agentId,
details: expect.objectContaining({
sourcePluginId: "plugin-record-id",
sourcePluginKey: "paperclip.missions",
initiatingActorType: "agent",
initiatingActorId: agentId,
initiatingRunId: originRunId,
}),
}),
]),
);
});
it("enforces plugin origin namespaces", async () => {
const { companyId } = await seedCompanyAndAgent();
const services = buildHostServices(db, "plugin-record-id", "paperclip.missions", createEventBusStub());
const featureIssue = await services.issues.create({
companyId,
title: "Feature issue",
originKind: "plugin:paperclip.missions:feature",
originId: "mission-alpha:feature-1",
});
expect(featureIssue.originKind).toBe("plugin:paperclip.missions:feature");
await expect(
services.issues.create({
companyId,
title: "Spoofed issue",
originKind: "plugin:other.plugin:feature",
}),
).rejects.toThrow("Plugin may only use originKind values under plugin:paperclip.missions");
await expect(
services.issues.update({
issueId: featureIssue.id,
companyId,
patch: { originKind: "plugin:other.plugin:feature" },
}),
).rejects.toThrow("Plugin may only use originKind values under plugin:paperclip.missions");
});
it("creates plugin operation issues with the generic operation origin", async () => {
const { companyId } = await seedCompanyAndAgent();
const services = buildHostServices(db, "plugin-record-id", "paperclip.missions", createEventBusStub());
const issue = await services.issues.create({
companyId,
title: "Background operation",
surfaceVisibility: "plugin_operation",
originId: "mission-alpha:operation-1",
});
expect(issue.originKind).toBe("plugin:paperclip.missions:operation");
expect(issue.originId).toBe("mission-alpha:operation-1");
});
it("lets bootstrap-style actions initialize required local folders from an empty root", async () => {
const { companyId } = await seedCompanyAndAgent();
const pluginId = randomUUID();
await db.insert(plugins).values({
id: pluginId,
pluginKey: "paperclipai.plugin-llm-wiki",
packageName: "@paperclipai/plugin-llm-wiki",
version: "0.1.0",
manifestJson: {
id: "paperclipai.plugin-llm-wiki",
apiVersion: 1,
version: "0.1.0",
displayName: "LLM Wiki",
description: "Local-file LLM Wiki plugin",
author: "Paperclip",
categories: ["automation"],
capabilities: ["local.folders"],
entrypoints: { worker: "./dist/worker.js" },
localFolders: [
{
folderKey: "wiki-root",
displayName: "Wiki root",
access: "readWrite",
requiredDirectories: ["raw", "wiki", "wiki/concepts", ".paperclip"],
requiredFiles: ["WIKI.md", "AGENTS.md"],
},
],
},
status: "ready",
});
const root = await makeLocalRoot();
const services = buildHostServices(
db,
pluginId,
"paperclipai.plugin-llm-wiki",
createEventBusStub(),
undefined,
{
manifest: {
id: "paperclipai.plugin-llm-wiki",
apiVersion: 1,
version: "0.1.0",
displayName: "LLM Wiki",
description: "Local-file LLM Wiki plugin",
author: "Paperclip",
categories: ["automation"],
capabilities: ["local.folders"],
entrypoints: { worker: "./dist/worker.js" },
localFolders: [
{
folderKey: "wiki-root",
displayName: "Wiki root",
access: "readWrite",
requiredDirectories: ["raw", "wiki", "wiki/concepts", ".paperclip"],
requiredFiles: ["WIKI.md", "AGENTS.md"],
},
],
},
},
);
const configured = await services.localFolders.configure({
companyId,
folderKey: "wiki-root",
path: root,
access: "readWrite",
requiredDirectories: ["raw", "wiki", "wiki/concepts", ".paperclip"],
requiredFiles: ["WIKI.md", "AGENTS.md"],
});
expect(configured.healthy).toBe(false);
expect(configured.missingDirectories).toEqual([]);
expect(configured.missingFiles).toEqual(["WIKI.md", "AGENTS.md"]);
await fs.rm(path.join(root, "raw"), { recursive: true, force: true });
await fs.rm(path.join(root, "wiki"), { recursive: true, force: true });
await expect(services.localFolders.readText({ companyId, folderKey: "wiki-root", relativePath: "WIKI.md" }))
.rejects.toThrow("Local folder is not healthy");
await services.localFolders.writeTextAtomic({
companyId,
folderKey: "wiki-root",
relativePath: "WIKI.md",
contents: "# Wiki\n",
});
await services.localFolders.writeTextAtomic({
companyId,
folderKey: "wiki-root",
relativePath: "AGENTS.md",
contents: "# Agents\n",
});
const finalStatus = await services.localFolders.status({ companyId, folderKey: "wiki-root" });
expect(finalStatus.healthy).toBe(true);
await expect(fs.stat(path.join(root, "raw"))).resolves.toMatchObject({});
await expect(fs.stat(path.join(root, "wiki/concepts"))).resolves.toMatchObject({});
await expect(fs.readFile(path.join(root, "WIKI.md"), "utf8")).resolves.toBe("# Wiki\n");
});
it("rejects worker local-folder access for undeclared manifest keys", async () => {
const { companyId } = await seedCompanyAndAgent();
const pluginId = randomUUID();
await db.insert(plugins).values({
id: pluginId,
pluginKey: "paperclip.local-folders",
packageName: "@paperclip/plugin-local-folders",
version: "0.1.0",
manifestJson: {
id: "paperclip.local-folders",
apiVersion: 1,
version: "0.1.0",
displayName: "Local Folders",
description: "Local folder fixture",
author: "Paperclip",
categories: ["automation"],
capabilities: ["local.folders"],
entrypoints: { worker: "./dist/worker.js" },
localFolders: [
{
folderKey: "content-root",
displayName: "Content root",
access: "readWrite",
},
],
},
status: "ready",
});
const services = buildHostServices(
db,
pluginId,
"paperclip.local-folders",
createEventBusStub(),
undefined,
{
manifest: {
id: "paperclip.local-folders",
apiVersion: 1,
version: "0.1.0",
displayName: "Local Folders",
description: "Local folder fixture",
author: "Paperclip",
categories: ["automation"],
capabilities: ["local.folders"],
entrypoints: { worker: "./dist/worker.js" },
localFolders: [
{
folderKey: "content-root",
displayName: "Content root",
access: "readWrite",
},
],
},
},
);
await expect(services.localFolders.configure({
companyId,
folderKey: "ssh",
path: "/tmp",
access: "read",
})).rejects.toThrow("Local folder key is not declared");
await expect(services.localFolders.status({ companyId, folderKey: "ssh" }))
.rejects.toThrow("Local folder key is not declared");
await expect(services.localFolders.readText({ companyId, folderKey: "ssh", relativePath: "id_rsa" }))
.rejects.toThrow("Local folder key is not declared");
await expect(services.localFolders.writeTextAtomic({
companyId,
folderKey: "ssh",
relativePath: "id_rsa",
contents: "secret",
})).rejects.toThrow("Local folder key is not declared");
});
it("resolves plugin-managed projects by stable key without overwriting user edits", async () => {
const { companyId } = await seedCompanyAndAgent();
const pluginId = randomUUID();
await db.insert(plugins).values({
id: pluginId,
pluginKey: "paperclip.missions",
packageName: "@paperclip/plugin-missions",
version: "0.1.0",
apiVersion: 1,
categories: ["automation"],
status: "ready",
manifestJson: {
id: "paperclip.missions",
apiVersion: 1,
version: "0.1.0",
displayName: "Missions",
description: "Mission orchestration",
author: "Paperclip",
categories: ["automation"],
capabilities: ["projects.managed"],
entrypoints: { worker: "./dist/worker.js" },
projects: [{
projectKey: "operations",
displayName: "Mission Operations",
description: "Plugin operation inspection area",
status: "in_progress",
color: "#14b8a6",
settings: { surface: "operations" },
}],
},
});
const services = buildHostServices(db, pluginId, "paperclip.missions", createEventBusStub());
const missing = await services.projects.getManaged({ companyId, projectKey: "operations" });
expect(missing.status).toBe("missing");
expect(missing.projectId).toBeNull();
await expect(
db
.select()
.from(pluginManagedResources)
.where(and(
eq(pluginManagedResources.companyId, companyId),
eq(pluginManagedResources.pluginId, pluginId),
eq(pluginManagedResources.resourceKind, "project"),
eq(pluginManagedResources.resourceKey, "operations"),
)),
).resolves.toHaveLength(0);
const created = await services.projects.reconcileManaged({ companyId, projectKey: "operations" });
expect(created.status).toBe("created");
expect(created.projectId).toEqual(expect.any(String));
expect(created.project?.managedByPlugin).toMatchObject({
pluginId,
pluginKey: "paperclip.missions",
pluginDisplayName: "Missions",
resourceKind: "project",
resourceKey: "operations",
});
await db
.update(projects)
.set({ name: "Renamed by operator", description: "User-owned text", updatedAt: new Date() })
.where(eq(projects.id, created.projectId!));
await db
.update(plugins)
.set({
manifestJson: {
id: "paperclip.missions",
apiVersion: 1,
version: "0.2.0",
displayName: "Missions",
description: "Mission orchestration",
author: "Paperclip",
categories: ["automation"],
capabilities: ["projects.managed"],
entrypoints: { worker: "./dist/worker.js" },
projects: [{
projectKey: "operations",
displayName: "Upgraded Default Name",
description: "Upgraded default description",
status: "planned",
color: "#f97316",
settings: { surface: "operations", upgraded: true },
}],
},
updatedAt: new Date(),
})
.where(eq(plugins.id, pluginId));
const resolved = await services.projects.reconcileManaged({ companyId, projectKey: "operations" });
expect(resolved.status).toBe("resolved");
expect(resolved.projectId).toBe(created.projectId);
expect(resolved.project?.name).toBe("Renamed by operator");
expect(resolved.project?.description).toBe("User-owned text");
expect(resolved.project?.managedByPlugin?.defaultsJson).toMatchObject({
displayName: "Upgraded Default Name",
settings: { upgraded: true },
});
});
it("asserts checkout ownership for run-scoped plugin actions", async () => {
const { companyId, agentId } = await seedCompanyAndAgent();
const issueId = randomUUID();
const runId = randomUUID();
await db.insert(heartbeatRuns).values({
id: runId,
companyId,
agentId,
status: "running",
invocationSource: "assignment",
contextSnapshot: { issueId },
});
await db.insert(issues).values({
id: issueId,
companyId,
title: "Checked out issue",
status: "in_progress",
priority: "medium",
assigneeAgentId: agentId,
checkoutRunId: runId,
executionRunId: runId,
});
const services = buildHostServices(db, "plugin-record-id", "paperclip.missions", createEventBusStub());
await expect(
services.issues.assertCheckoutOwner({
issueId,
companyId,
actorAgentId: agentId,
actorRunId: runId,
}),
).resolves.toMatchObject({
issueId,
status: "in_progress",
assigneeAgentId: agentId,
checkoutRunId: runId,
});
});
it("refuses plugin wakeups for issues with unresolved blockers", async () => {
const { companyId, agentId } = await seedCompanyAndAgent();
const blockerIssueId = randomUUID();
const blockedIssueId = randomUUID();
await db.insert(issues).values([
{
id: blockerIssueId,
companyId,
title: "Unresolved blocker",
status: "todo",
priority: "medium",
},
{
id: blockedIssueId,
companyId,
title: "Blocked issue",
status: "todo",
priority: "medium",
assigneeAgentId: agentId,
},
]);
await db.insert(issueRelations).values({
companyId,
issueId: blockerIssueId,
relatedIssueId: blockedIssueId,
type: "blocks",
});
const services = buildHostServices(db, "plugin-record-id", "paperclip.missions", createEventBusStub());
await expect(
services.issues.requestWakeup({
issueId: blockedIssueId,
companyId,
reason: "mission_advance",
}),
).rejects.toThrow("Issue is blocked by unresolved blockers");
});
it("narrows orchestration cost summaries by subtree and billing code", async () => {
const { companyId, agentId } = await seedCompanyAndAgent();
const rootIssueId = randomUUID();
const childIssueId = randomUUID();
const unrelatedIssueId = randomUUID();
await db.insert(issues).values([
{
id: rootIssueId,
companyId,
title: "Root mission",
status: "todo",
priority: "medium",
billingCode: "mission:alpha",
},
{
id: childIssueId,
companyId,
parentId: rootIssueId,
title: "Child mission",
status: "todo",
priority: "medium",
billingCode: "mission:alpha",
},
{
id: unrelatedIssueId,
companyId,
title: "Different mission",
status: "todo",
priority: "medium",
billingCode: "mission:alpha",
},
]);
await db.insert(costEvents).values([
{
companyId,
agentId,
issueId: rootIssueId,
billingCode: "mission:alpha",
provider: "test",
model: "unit",
inputTokens: 10,
cachedInputTokens: 1,
outputTokens: 2,
costCents: 100,
occurredAt: new Date(),
},
{
companyId,
agentId,
issueId: childIssueId,
billingCode: "mission:alpha",
provider: "test",
model: "unit",
inputTokens: 20,
cachedInputTokens: 2,
outputTokens: 4,
costCents: 200,
occurredAt: new Date(),
},
{
companyId,
agentId,
issueId: childIssueId,
billingCode: "mission:beta",
provider: "test",
model: "unit",
inputTokens: 30,
cachedInputTokens: 3,
outputTokens: 6,
costCents: 300,
occurredAt: new Date(),
},
{
companyId,
agentId,
issueId: unrelatedIssueId,
billingCode: "mission:alpha",
provider: "test",
model: "unit",
inputTokens: 40,
cachedInputTokens: 4,
outputTokens: 8,
costCents: 400,
occurredAt: new Date(),
},
]);
const services = buildHostServices(db, "plugin-record-id", "paperclip.missions", createEventBusStub());
const summary = await services.issues.getOrchestrationSummary({
companyId,
issueId: rootIssueId,
includeSubtree: true,
});
expect(new Set(summary.subtreeIssueIds)).toEqual(new Set([rootIssueId, childIssueId]));
expect(summary.costs).toMatchObject({
billingCode: "mission:alpha",
costCents: 300,
inputTokens: 30,
cachedInputTokens: 3,
outputTokens: 6,
});
});
it("rejects a human-attributed plugin comment when actorUserId is not an active company member", async () => {
const { companyId, agentId } = await seedCompanyAndAgent();
const issueId = randomUUID();
await db.insert(issues).values({
id: issueId,
companyId,
title: "Needs human input",
status: "in_review",
priority: "medium",
assigneeAgentId: agentId,
});
const services = buildHostServices(db, "plugin-record-id", "paperclip.gateway", createEventBusStub());
await expect(
services.issues.createComment({
issueId,
companyId,
body: "Here's my answer",
actorUserId: randomUUID(),
}),
).rejects.toThrow("is not an active human member of this company");
await expect(db.select().from(agentWakeupRequests)).resolves.toHaveLength(0);
});
it("rejects a human-attributed plugin comment when actorUserId is a viewer-role (read-only) member", async () => {
// LOOA-648: a viewer is a real active member but is read-only in the web
// app (routes/authz.ts "Viewer access is read-only"). Attributing a comment
// to them is a write the paired user could not make interactively.
const { companyId, agentId } = await seedCompanyAndAgent();
const viewerUserId = randomUUID();
await db.insert(companyMemberships).values({
companyId, principalType: "user", principalId: viewerUserId, status: "active", membershipRole: "viewer",
});
const issueId = randomUUID();
await db.insert(issues).values({
id: issueId, companyId, title: "Needs human input", status: "in_review", priority: "medium", assigneeAgentId: agentId,
});
const services = buildHostServices(db, "plugin-record-id", "paperclip.gateway", createEventBusStub());
await expect(
services.issues.createComment({ issueId, companyId, body: "Here's my answer", actorUserId: viewerUserId }),
).rejects.toThrow("viewer (read-only) access");
// No comment persisted, no assignee woken.
await expect(db.select().from(agentWakeupRequests)).resolves.toHaveLength(0);
const comments = await services.issues.listComments({ issueId, companyId });
expect(comments).toHaveLength(0);
});
it("creates a human-attributed plugin comment and wakes the issue's assignee", async () => {
const { companyId, agentId } = await seedCompanyAndAgent();
const humanUserId = randomUUID();
const issueId = randomUUID();
await db.insert(companyMemberships).values({
companyId,
principalType: "user",
principalId: humanUserId,
status: "active",
membershipRole: "owner",
});
await db.insert(issues).values({
id: issueId,
companyId,
title: "Needs human input",
status: "in_review",
priority: "medium",
assigneeAgentId: agentId,
});
// Cap concurrency at 1 and pre-seed a running run so enqueueWakeup's
// queued-run bookkeeping is exercised without startNextQueuedRunForAgent
// going on to actually claim/execute the new run — that path spins up
// real environment/adapter orchestration this test has no business
// depending on (and which races the test's own db teardown).
await db.update(agents).set({ runtimeConfig: { heartbeat: { maxConcurrentRuns: 1 } } }).where(eq(agents.id, agentId));
await db.insert(heartbeatRuns).values({
companyId,
agentId,
status: "running",
invocationSource: "assignment",
contextSnapshot: {},
});
const services = buildHostServices(
db,
"plugin-record-id",
"paperclip.gateway",
createEventBusStub(),
undefined,
{ heartbeatRuntimeEnv: {} },
);
const comment = await services.issues.createComment({
issueId,
companyId,
body: "Here's my answer",
actorUserId: humanUserId,
});
expect(comment).toMatchObject({
authorType: "user",
authorUserId: humanUserId,
authorAgentId: null,
body: "Here's my answer",
});
const [wakeupRequest] = await db
.select()
.from(agentWakeupRequests)
.where(and(eq(agentWakeupRequests.companyId, companyId), eq(agentWakeupRequests.agentId, agentId)));
expect(wakeupRequest).toMatchObject({
reason: "issue_commented",
requestedByActorType: "user",
requestedByActorId: humanUserId,
});
expect(wakeupRequest?.status).toBe("queued");
expect(wakeupRequest?.runId).toEqual(expect.any(String));
const [run] = await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, wakeupRequest!.runId!));
expect(run).toMatchObject({ agentId, companyId, status: "queued" });
});
it("skips the assignee wakeup when the issue is cancelled concurrently with the comment being written", async () => {
const { companyId, agentId } = await seedCompanyAndAgent();
const humanUserId = randomUUID();
const issueId = randomUUID();
await db.insert(companyMemberships).values({
companyId,
principalType: "user",
principalId: humanUserId,
status: "active",
membershipRole: "owner",
});
await db.insert(issues).values({
id: issueId,
companyId,
title: "Needs human input",
status: "in_review",
priority: "medium",
assigneeAgentId: agentId,
});
const services = buildHostServices(db, "plugin-record-id", "paperclip.gateway", createEventBusStub());
// Hold a row lock on the issue so createComment's own `UPDATE issues SET
// updated_at` (inside addComment) blocks until this transaction commits a
// concurrent cancellation. That deterministically reproduces "another
// request closes the issue while the comment is being written" — the
// race Greptile flagged on the pre-insert `issue` snapshot — without
// relying on timing luck for the outcome, only for scheduling.
const lockAndCancelPromise = db.transaction(async (tx) => {
await tx.select().from(issues).where(eq(issues.id, issueId)).for("update");
await new Promise((resolve) => setTimeout(resolve, 50));
await tx.update(issues).set({ status: "cancelled" }).where(eq(issues.id, issueId));
});
const commentPromise = services.issues.createComment({
issueId,
companyId,
body: "Here's my answer",
actorUserId: humanUserId,
});
const [comment] = await Promise.all([commentPromise, lockAndCancelPromise]);
expect(comment).toMatchObject({
authorType: "user",
authorUserId: humanUserId,
body: "Here's my answer",
});
await expect(db.select().from(agentWakeupRequests)).resolves.toHaveLength(0);
});
// ---------------------------------------------------------------------------
// LOOA-641 — interactions.respond / approvals.decide impersonation surface.
// The host must independently re-verify the paired user's active membership
// at apply time and never trust the plugin-supplied identity.
// ---------------------------------------------------------------------------
async function seedInteraction(
companyId: string,
issueId: string,
overrides: Partial<typeof issueThreadInteractions.$inferInsert> = {},
) {
const interactionId = randomUUID();
await db.insert(issueThreadInteractions).values({
id: interactionId,
companyId,
issueId,
kind: "request_confirmation",
status: "pending",
continuationPolicy: "wake_assignee",
payload: { version: 1, prompt: "Proceed?" } as never,
...overrides,
});
return interactionId;
}
it("respondInteraction fails closed when actorUserId is omitted", async () => {
const { companyId, agentId } = await seedCompanyAndAgent();
const issueId = randomUUID();
await db.insert(issues).values({
id: issueId, companyId, title: "Decision", status: "in_review", priority: "medium", assigneeAgentId: agentId,
});
const interactionId = await seedInteraction(companyId, issueId);
const services = buildHostServices(db, "plugin-record-id", "paperclip.gateway", createEventBusStub());
await expect(
services.issues.respondInteraction({ issueId, interactionId, companyId, action: "accept" }),
).rejects.toThrow("actorUserId is required");
});
it("respondInteraction rejects an actorUserId that is not an active company member", async () => {
const { companyId, agentId } = await seedCompanyAndAgent();
const issueId = randomUUID();
await db.insert(issues).values({
id: issueId, companyId, title: "Decision", status: "in_review", priority: "medium", assigneeAgentId: agentId,
});
const interactionId = await seedInteraction(companyId, issueId);
const services = buildHostServices(db, "plugin-record-id", "paperclip.gateway", createEventBusStub());
await expect(
services.issues.respondInteraction({
issueId, interactionId, companyId, action: "accept", actorUserId: randomUUID(),
}),
).rejects.toThrow("is not an active human member of this company");
// The interaction must remain pending — no resolution was applied.
const [row] = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, interactionId));
expect(row?.status).toBe("pending");
});
it("respondInteraction rejects a viewer-role active member and leaves the interaction pending", async () => {
// LOOA-648: viewers are read-only board members (routes/authz.ts). A plugin
// holding issue.interactions.respond must not resolve an interaction on
// their behalf — that is a write the viewer is 403'd on in the web app.
const { companyId, agentId } = await seedCompanyAndAgent();
const viewerUserId = randomUUID();
await db.insert(companyMemberships).values({
companyId, principalType: "user", principalId: viewerUserId, status: "active", membershipRole: "viewer",
});
const issueId = randomUUID();
await db.insert(issues).values({
id: issueId, companyId, title: "Decision", status: "in_review", priority: "medium", assigneeAgentId: agentId,
});
const interactionId = await seedInteraction(companyId, issueId);
const services = buildHostServices(db, "plugin-record-id", "paperclip.gateway", createEventBusStub());
await expect(
services.issues.respondInteraction({
issueId, interactionId, companyId, action: "accept", actorUserId: viewerUserId,
}),
).rejects.toThrow("viewer (read-only) access");
const [row] = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, interactionId));
expect(row?.status).toBe("pending");
});
it("respondInteraction applies for a non-viewer human role (operator)", async () => {
// LOOA-648 regression: owner/admin/operator must still pass the write bar.
// Issue has no assignee so the continuation wakeup is a no-op — this
// isolates the membership check from run orchestration.
const { companyId } = await seedCompanyAndAgent();
const operatorUserId = randomUUID();
await db.insert(companyMemberships).values({
companyId, principalType: "user", principalId: operatorUserId, status: "active", membershipRole: "operator",
});
const issueId = randomUUID();
await db.insert(issues).values({
id: issueId, companyId, title: "Decision", status: "in_review", priority: "medium",
});
const interactionId = await seedInteraction(companyId, issueId);
const services = buildHostServices(db, "plugin-record-id", "paperclip.gateway", createEventBusStub());
const result = await services.issues.respondInteraction({
issueId, interactionId, companyId, action: "accept", actorUserId: operatorUserId,
});
expect(result.applied).toBe(true);
const [row] = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, interactionId));
expect(row?.status).toBe("accepted");
});
it.each(["accept", "reject"] as const)(
"respondInteraction rejects %s after the issue closes",
async (action) => {
const { companyId } = await seedCompanyAndAgent();
const operatorUserId = randomUUID();
await db.insert(companyMemberships).values({
companyId,
principalType: "user",
principalId: operatorUserId,
status: "active",
membershipRole: "operator",
});
const issueId = randomUUID();
await db.insert(issues).values({
id: issueId,
companyId,
title: "Closed decision",
status: "done",
priority: "medium",
});
const interactionId = await seedInteraction(companyId, issueId);
const services = buildHostServices(db, "plugin-record-id", "paperclip.gateway", createEventBusStub());
await expect(services.issues.respondInteraction({
issueId,
interactionId,
companyId,
action,
actorUserId: operatorUserId,
})).rejects.toThrow("Interaction is no longer actionable because the issue is closed");
const [row] = await db
.select()
.from(issueThreadInteractions)
.where(eq(issueThreadInteractions.id, interactionId));
expect(row?.status).toBe("pending");
},
);
it("respondInteraction converges (applied:false) when the interaction is already resolved", async () => {
const { companyId, agentId } = await seedCompanyAndAgent();
const humanUserId = randomUUID();
await db.insert(companyMemberships).values({
companyId, principalType: "user", principalId: humanUserId, status: "active", membershipRole: "owner",
});
const issueId = randomUUID();
await db.insert(issues).values({
id: issueId, companyId, title: "Decision", status: "in_review", priority: "medium", assigneeAgentId: agentId,
});
const interactionId = await seedInteraction(companyId, issueId, { status: "rejected" });
const services = buildHostServices(db, "plugin-record-id", "paperclip.gateway", createEventBusStub());
const result = await services.issues.respondInteraction({
issueId, interactionId, companyId, action: "accept", actorUserId: humanUserId,
});
expect(result.applied).toBe(false);
expect(result.interaction).toMatchObject({ id: interactionId, status: "rejected" });
});
it("approvals.decide fails closed when actorUserId is omitted", async () => {
const { companyId } = await seedCompanyAndAgent();
const approvalId = randomUUID();
await db.insert(approvals).values({
id: approvalId, companyId, type: "request_board_approval", status: "pending", payload: { title: "Ship it" },
});
const services = buildHostServices(db, "plugin-record-id", "paperclip.gateway", createEventBusStub());
await expect(
services.approvals.decide({ approvalId, companyId, action: "approve" }),
).rejects.toThrow("actorUserId is required");
const [row] = await db.select().from(approvals).where(eq(approvals.id, approvalId));
expect(row?.status).toBe("pending");
});
it("approvals.decide rejects an actorUserId that is not an active company member", async () => {
const { companyId } = await seedCompanyAndAgent();
const approvalId = randomUUID();
await db.insert(approvals).values({
id: approvalId, companyId, type: "request_board_approval", status: "pending", payload: { title: "Ship it" },
});
const services = buildHostServices(db, "plugin-record-id", "paperclip.gateway", createEventBusStub());
await expect(
services.approvals.decide({ approvalId, companyId, action: "approve", actorUserId: randomUUID() }),
).rejects.toThrow("is not an active human member of this company");
const [row] = await db.select().from(approvals).where(eq(approvals.id, approvalId));
expect(row?.status).toBe("pending");
});
it("approvals.decide rejects a viewer-role active member and leaves the approval pending", async () => {
// LOOA-648: the exploit at the heart of the review — a plugin holding
// approvals.respond decides an approval for a viewer who is 403'd in the
// web UI. The host must reject the viewer before applying the decision.
const { companyId } = await seedCompanyAndAgent();
const viewerUserId = randomUUID();
await db.insert(companyMemberships).values({
companyId, principalType: "user", principalId: viewerUserId, status: "active", membershipRole: "viewer",
});
const approvalId = randomUUID();
await db.insert(approvals).values({
id: approvalId, companyId, type: "request_board_approval", status: "pending", payload: { title: "Ship it" },
});
const services = buildHostServices(db, "plugin-record-id", "paperclip.gateway", createEventBusStub());
await expect(
services.approvals.decide({ approvalId, companyId, action: "approve", actorUserId: viewerUserId }),
).rejects.toThrow("viewer (read-only) access");
const [row] = await db.select().from(approvals).where(eq(approvals.id, approvalId));
expect(row?.status).toBe("pending");
});
it("approvals.decide applies for a non-viewer human role (admin)", async () => {
// LOOA-648 regression: owner/admin/operator must still pass the write bar.
const { companyId } = await seedCompanyAndAgent();
const adminUserId = randomUUID();
await db.insert(companyMemberships).values({
companyId, principalType: "user", principalId: adminUserId, status: "active", membershipRole: "admin",
});
const approvalId = randomUUID();
await db.insert(approvals).values({
id: approvalId, companyId, type: "request_board_approval", status: "pending", payload: { title: "Ship it" },
});
const services = buildHostServices(db, "plugin-record-id", "paperclip.gateway", createEventBusStub());
const result = await services.approvals.decide({
approvalId, companyId, action: "approve", actorUserId: adminUserId,
});
expect(result.applied).toBe(true);
expect(result.approval).toMatchObject({ id: approvalId, status: "approved", decidedByUserId: adminUserId });
const [row] = await db.select().from(approvals).where(eq(approvals.id, approvalId));
expect(row?.status).toBe("approved");
});
it("approvals.decide applies for an active member and redacts secret payload fields", async () => {
const { companyId } = await seedCompanyAndAgent();
const humanUserId = randomUUID();
await db.insert(companyMemberships).values({
companyId, principalType: "user", principalId: humanUserId, status: "active", membershipRole: "owner",
});
const approvalId = randomUUID();
await db.insert(approvals).values({
id: approvalId,
companyId,
type: "request_board_approval",
status: "pending",
payload: { title: "Ship it", botToken: "xoxb-super-secret" },
});
const services = buildHostServices(db, "plugin-record-id", "paperclip.gateway", createEventBusStub());
const result = await services.approvals.decide({
approvalId, companyId, action: "approve", actorUserId: humanUserId, decisionNote: "lgtm",
});
expect(result.applied).toBe(true);
expect(result.approval).toMatchObject({ id: approvalId, status: "approved", decidedByUserId: humanUserId });
// Bridge output must be redacted the same way the web app redacts approvals.
expect(result.approval.payload.title).toBe("Ship it");
expect(result.approval.payload.botToken).toBe("***REDACTED***");
// Persisted row is decided; the raw stored secret is untouched (redaction is
// an output transform, not a mutation).
const [row] = await db.select().from(approvals).where(eq(approvals.id, approvalId));
expect(row?.status).toBe("approved");
});
it("approvals.list redacts payloads and is company-scoped", async () => {
const { companyId } = await seedCompanyAndAgent();
const otherCompany = randomUUID();
await db.insert(companies).values({
id: otherCompany, name: "Other", issuePrefix: issuePrefix(otherCompany), requireBoardApprovalForNewAgents: false,
});
await db.insert(approvals).values({
id: randomUUID(), companyId, type: "request_board_approval", status: "pending",
payload: { title: "Mine", accessToken: "sekret" },
});
await db.insert(approvals).values({
id: randomUUID(), companyId: otherCompany, type: "request_board_approval", status: "pending",
payload: { title: "Theirs" },
});
const services = buildHostServices(db, "plugin-record-id", "paperclip.gateway", createEventBusStub());
const rows = await services.approvals.list({ companyId });
expect(rows).toHaveLength(1);
expect(rows[0]!.payload.title).toBe("Mine");
expect(rows[0]!.payload.accessToken).toBe("***REDACTED***");
});
it("getAttachmentContent returns null for a cross-company attachment id", async () => {
const { companyId, agentId } = await seedCompanyAndAgent();
const otherCompany = randomUUID();
await db.insert(companies).values({
id: otherCompany, name: "Other", issuePrefix: issuePrefix(otherCompany), requireBoardApprovalForNewAgents: false,
});
const otherIssueId = randomUUID();
await db.insert(issues).values({
id: otherIssueId, companyId: otherCompany, title: "Theirs", status: "todo", priority: "medium",
});
const assetId = randomUUID();
await db.insert(assets).values({
id: assetId, companyId: otherCompany, provider: "local_disk", objectKey: "k", contentType: "image/png",
byteSize: 10, sha256: "abc",
});
const attachmentId = randomUUID();
await db.insert(issueAttachments).values({
id: attachmentId, companyId: otherCompany, issueId: otherIssueId, assetId,
});
const services = buildHostServices(db, "plugin-record-id", "paperclip.gateway", createEventBusStub());
// Requested under our own company: the cross-company id must be invisible.
void agentId;
const content = await services.issues.getAttachmentContent({ attachmentId, companyId });
expect(content).toBeNull();
});
it("getAttachmentContent refuses an over-cap attachment", async () => {
const { companyId } = await seedCompanyAndAgent();
const issueId = randomUUID();
await db.insert(issues).values({
id: issueId, companyId, title: "Has asset", status: "todo", priority: "medium",
});
const assetId = randomUUID();
await db.insert(assets).values({
id: assetId, companyId, provider: "local_disk", objectKey: "k", contentType: "image/png",
byteSize: 5_000_000, sha256: "abc",
});
const attachmentId = randomUUID();
await db.insert(issueAttachments).values({
id: attachmentId, companyId, issueId, assetId,
});
const services = buildHostServices(db, "plugin-record-id", "paperclip.gateway", createEventBusStub());
await expect(
services.issues.getAttachmentContent({ attachmentId, companyId, maxBytes: 1_000_000 }),
).rejects.toThrow("over the");
});
});