mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 06:10:44 +01:00
feat: add individual agent interaction permissions (#20411)
Add eleven sparse incoming and outgoing restrictions, human CLI/API administration, shared mutation enforcement, and pending-delivery checks. Preserve existing defaults, agent settings, and existing authorization boundaries. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
4158799d1b
commit
5bad074baa
97 files changed
+3590
-433
No files matched your search
@@ -6,6 +6,9 @@ import {
|
||||
updateAgentInstructionsBundleSchema,
|
||||
updateAgentInstructionsPathSchema,
|
||||
updateAgentPermissionsSchema,
|
||||
updateAgentInteractionPermissionsSchema,
|
||||
AGENT_INTERACTION_PERMISSION_KEYS,
|
||||
type AgentInteractionPermissionsState,
|
||||
updateAgentPermissionGrantSchema,
|
||||
updateAgentSchema,
|
||||
upsertAgentInstructionsFileSchema,
|
||||
@@ -463,6 +466,40 @@ export function registerAgentCommands(program: Command): void {
|
||||
}),
|
||||
);
|
||||
|
||||
function printInteractionPermissions(value: AgentInteractionPermissionsState, json: boolean) {
|
||||
if (json) return printOutput(value, { json: true });
|
||||
for (const key of AGENT_INTERACTION_PERMISSION_KEYS) {
|
||||
const source = Object.hasOwn(value.overrides, key) ? "explicit" : "default";
|
||||
console.log(`${key}: ${value.effective[key]} (${source})`);
|
||||
}
|
||||
}
|
||||
|
||||
addCommonClientOptions(agent.command("interaction-permissions")
|
||||
.description("Show individual interaction restrictions and their defaults")
|
||||
.argument("<agentId>", "Agent UUID")
|
||||
.action(async (agentId: string, opts: BaseClientOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
const value = await ctx.api.get<AgentInteractionPermissionsState>(apiPath`/api/agents/${agentId}/interaction-permissions`);
|
||||
if (!value) throw new Error("Agent interaction permissions not found");
|
||||
printInteractionPermissions(value, ctx.json);
|
||||
} catch (err) { handleCommandError(err); }
|
||||
}));
|
||||
|
||||
addCommonClientOptions(agent.command("interaction-permissions:update")
|
||||
.description("Set individual interaction restrictions; null restores a default (human permission managers only)")
|
||||
.argument("<agentId>", "Agent UUID")
|
||||
.requiredOption("--payload-json <json>", "Partial interaction permissions map: true, false, or null")
|
||||
.action(async (agentId: string, opts: AgentJsonPayloadOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
const payload = updateAgentInteractionPermissionsSchema.parse(parseJson(opts.payloadJson));
|
||||
const value = await ctx.api.patch<AgentInteractionPermissionsState>(apiPath`/api/agents/${agentId}/interaction-permissions`, payload);
|
||||
if (!value) throw new Error("Agent interaction permissions not found");
|
||||
printInteractionPermissions(value, ctx.json);
|
||||
} catch (err) { handleCommandError(err); }
|
||||
}));
|
||||
|
||||
for (const [command, enabled] of [["permission:grant", true], ["permission:revoke", false]] as const) {
|
||||
addCommonClientOptions(
|
||||
agent.command(command)
|
||||
|
||||
@@ -289,6 +289,7 @@ export type PluginRpcErrorCode =
|
||||
*/
|
||||
export interface PluginInvocationScope {
|
||||
companyId: string;
|
||||
agentOrigin?: { agentId: string; runId?: string | null };
|
||||
projectGit?: { projectId: string; workspaceId?: string; runId?: string; agentId?: string; operation?: "prepare" | "inspect" | "remove" };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const AGENT_INTERACTION_PERMISSION_KEYS = [
|
||||
"canAssignTasksToAgents",
|
||||
"canReceiveAssignmentsFromAgents",
|
||||
"canAssignTasksToHumans",
|
||||
"canMentionAgents",
|
||||
"canReceiveMentionsFromAgents",
|
||||
"canCommentOnOtherTasks",
|
||||
"canReceiveCommentsFromAgents",
|
||||
"canEditOtherTasks",
|
||||
"canReceiveTaskEditsFromAgents",
|
||||
"canConfigureOtherAgents",
|
||||
"canReceiveConfigurationChangesFromAgents",
|
||||
] as const;
|
||||
|
||||
export type AgentInteractionPermissionKey = (typeof AGENT_INTERACTION_PERMISSION_KEYS)[number];
|
||||
export type AgentInteractionPermissions = Partial<Record<AgentInteractionPermissionKey, boolean>>;
|
||||
export type AgentInteractionPermissionsPatch = Partial<Record<AgentInteractionPermissionKey, boolean | null>>;
|
||||
|
||||
const shape = Object.fromEntries(AGENT_INTERACTION_PERMISSION_KEYS.map(key => [key, z.boolean().optional()])) as
|
||||
Record<AgentInteractionPermissionKey, z.ZodOptional<z.ZodBoolean>>;
|
||||
const patchShape = Object.fromEntries(AGENT_INTERACTION_PERMISSION_KEYS.map(key => [key, z.boolean().nullable().optional()])) as
|
||||
Record<AgentInteractionPermissionKey, z.ZodOptional<z.ZodNullable<z.ZodBoolean>>>;
|
||||
|
||||
export const agentInteractionPermissionsSchema = z.object(shape).strict();
|
||||
export const updateAgentInteractionPermissionsSchema = z.object(patchShape).strict()
|
||||
.refine(value => Object.keys(value).length > 0, "Provide at least one interaction permission");
|
||||
|
||||
export function resolveAgentInteractionPermissions(value: unknown): Record<AgentInteractionPermissionKey, boolean> {
|
||||
const record = value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
return Object.fromEntries(AGENT_INTERACTION_PERMISSION_KEYS.map(key => [key, record[key] !== false])) as
|
||||
Record<AgentInteractionPermissionKey, boolean>;
|
||||
}
|
||||
|
||||
export interface AgentInteractionPermissionsState {
|
||||
agentId: string;
|
||||
companyId: string;
|
||||
overrides: AgentInteractionPermissions;
|
||||
effective: Record<AgentInteractionPermissionKey, boolean>;
|
||||
}
|
||||
@@ -2907,6 +2907,7 @@ export { readProviderFailure, type ProviderFailure } from "./provider-failure.ge
|
||||
export type { BoardBootstrap, BoardBootstrapSession } from "./types/board-bootstrap.js";
|
||||
export { readBoardBootstrap } from "./board-bootstrap.js";
|
||||
export * from "./provider-rate-limits.js";
|
||||
export * from "./agent-interaction-permissions.js";
|
||||
export {
|
||||
TASK_IDENTITY_CHANGED_FIELD,
|
||||
issueActivityChangesTaskIdentity,
|
||||
|
||||
@@ -17,12 +17,14 @@ import type {
|
||||
} from "../trust-policy.js";
|
||||
import type { AgentOrgChainHealth } from "../agent-eligibility.js";
|
||||
import type { AgentApiKeyScope } from "../validators/agent.js";
|
||||
import type { AgentInteractionPermissions } from "../agent-interaction-permissions.js";
|
||||
|
||||
export interface AgentPermissions extends Record<string, unknown> {
|
||||
canCreateAgents: boolean;
|
||||
canCreateSkills?: boolean;
|
||||
trustPreset?: TrustPreset;
|
||||
authorizationPolicy?: TrustAuthorizationPolicy;
|
||||
interactions?: AgentInteractionPermissions;
|
||||
}
|
||||
|
||||
export interface AgentProjectAccess {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { envConfigSchema } from "./secret.js";
|
||||
import { trustAuthorizationPolicySchema, trustPresetSchema } from "./trust-policy.js";
|
||||
import { agentDesiredSkillSelectionSchema } from "./adapter-skills.js";
|
||||
import { objectWithoutDefaults } from "./partial.js";
|
||||
import { agentInteractionPermissionsSchema } from "../agent-interaction-permissions.js";
|
||||
|
||||
export const agentPermissionsSchema = z.object({
|
||||
// No schema default: the server derives the default (enabled unless the
|
||||
@@ -19,6 +20,7 @@ export const agentPermissionsSchema = z.object({
|
||||
canCreateSkills: z.boolean().optional().default(true),
|
||||
trustPreset: trustPresetSchema.optional(),
|
||||
authorizationPolicy: trustAuthorizationPolicySchema.optional(),
|
||||
interactions: agentInteractionPermissionsSchema.optional(),
|
||||
}).catchall(z.unknown());
|
||||
|
||||
export const agentInstructionsBundleModeSchema = z.enum(["managed", "external"]);
|
||||
@@ -320,6 +322,7 @@ export const updateAgentPermissionsSchema = z.object({
|
||||
canAssignTasks: z.boolean(),
|
||||
trustPreset: trustPresetSchema.optional(),
|
||||
authorizationPolicy: trustAuthorizationPolicySchema.optional(),
|
||||
interactions: z.never({ error: "Use /api/agents/:id/interaction-permissions to change interaction permissions" }).optional(),
|
||||
});
|
||||
|
||||
export type UpdateAgentPermissions = z.infer<typeof updateAgentPermissionsSchema>;
|
||||
|
||||
@@ -5,7 +5,12 @@ import { prepareAgentConfiguration } from "../services/agent-configuration.js";
|
||||
|
||||
const existing = { id: "agent", companyId: "company", status: "idle", budgetMonthlyCents: 500 } as AgentConfigurationRecord;
|
||||
// Preparation queues retention work through the database before reading the patch.
|
||||
const fakeDb = () => ({ execute: async () => [] }) as unknown as Db;
|
||||
const fakeDb = () => ({
|
||||
execute: async () => [],
|
||||
select: () => ({ from: () => ({ where: () => ({
|
||||
orderBy: async () => [{ id: "ceo", companyId: "company", permissions: {} }],
|
||||
}) }) }),
|
||||
}) as unknown as Db;
|
||||
describe("budget authority in configuration preparation", () => {
|
||||
it("rejects agent-originated budget changes before persistence or policy audit", async () => {
|
||||
const db = fakeDb();
|
||||
|
||||
@@ -7,7 +7,7 @@ import { and, eq, sql } from "drizzle-orm";
|
||||
import { agents, agentApiKeys, companies, authUsers, companyMemberships, principalPermissionGrants, heartbeatRuns,
|
||||
agentInstructionRevisions, agentInstructionHeads, issueThreadInteractions, issues, createDb } from "@paperclipai/db";
|
||||
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { agentFileStore } from "../services/agent-file-store.js";
|
||||
import { agentFileStore, snapshotAgentFiles } from "../services/agent-file-store.js";
|
||||
import { agentInstructionRevisionService } from "../services/agent-instruction-revisions.js";
|
||||
import { agentInstructionWorkingCopyService } from "../services/agent-instruction-working-copies.js";
|
||||
import { instructionBytes, instructionPath, materializeInstructionBytes, readInstructionBytes } from "../services/agent-instruction-files.js";
|
||||
@@ -65,6 +65,23 @@ describe("canonical instruction revisions", () => {
|
||||
async function save(content: string, baseRevisionId: string | null) {
|
||||
return service.commit({ ...target(), entryFile, content, baseRevisionId, source: "cleanup" }, actor);
|
||||
}
|
||||
it("checks changed supporting instructions during bulk synchronization without rescanning historical mentions", async () => {
|
||||
const historical = `Existing [@Target](agent://${agentId}) reference`;
|
||||
await fs.writeFile(path.join(root, "NOTES.md"), historical);
|
||||
await base();
|
||||
const sourceDir = path.join(home, `incoming-${randomUUID()}`);
|
||||
await fs.cp(root, sourceDir, { recursive: true });
|
||||
const baseline = await snapshotAgentFiles(root);
|
||||
await db.update(agents).set({ permissions: { interactions: { canMentionAgents: false } } }).where(eq(agents.id, actorId));
|
||||
await fs.writeFile(path.join(sourceDir, "other.md"), "Unrelated instructions");
|
||||
await agentFileStore(db).apply({ ...target(), sourceDir, baseline }, actor);
|
||||
expect(await fs.readFile(path.join(root, "other.md"), "utf8")).toBe("Unrelated instructions");
|
||||
const nextBaseline = await snapshotAgentFiles(root);
|
||||
await fs.writeFile(path.join(sourceDir, "NOTES.md"), `${historical} changed`);
|
||||
await expect(agentFileStore(db).apply({ ...target(), sourceDir, baseline: nextBaseline }, actor))
|
||||
.rejects.toMatchObject({ status: 403, details: { permission: "canMentionAgents" } });
|
||||
expect(await fs.readFile(path.join(root, "NOTES.md"), "utf8")).toBe(historical);
|
||||
});
|
||||
it("exposes recorded creation dates and update dates for root and supporting files", async () => {
|
||||
const files = agentFileStore(db);
|
||||
const instructions = agentInstructionsService(db);
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { expect, it } from "vitest";
|
||||
import { activityLog, agentApiKeys, agents, agentWakeupRequests, authUsers, boardApiKeys, companies,
|
||||
companyMemberships, createDb, heartbeatRuns, issueComments, issues, principalPermissionGrants } from "@paperclipai/db";
|
||||
import { AGENT_INTERACTION_PERMISSION_KEYS, PERMISSION_KEYS } from "@paperclipai/shared";
|
||||
import { createApp } from "../app.js";
|
||||
import { createStorageService } from "../storage/service.js";
|
||||
import { createLocalDiskStorageProvider } from "../storage/local-disk-provider.js";
|
||||
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { PaperclipRunnerToolAuthority } from "../services/native-runtime/paperclip-runner-tool-authority.js";
|
||||
|
||||
const execute = promisify(execFile);
|
||||
|
||||
it("configures each control with the CLI against the assembled app and enforces API and native mutations", async () => {
|
||||
const database = await startEmbeddedPostgresTestDatabase("interaction-acceptance-");
|
||||
const db = createDb(database.connectionString);
|
||||
const directory = await mkdtemp(join(tmpdir(), "interaction-acceptance-storage-"));
|
||||
let app: Awaited<ReturnType<typeof createApp>> | undefined;
|
||||
let server: ReturnType<NonNullable<typeof app>["listen"]> | undefined;
|
||||
try {
|
||||
const companyId = randomUUID(), sender = randomUUID(), recipient = randomUUID(), userId = randomUUID();
|
||||
const senderKey = `pcp_agent_${randomUUID()}`, boardKey = `pcp_board_${randomUUID()}`;
|
||||
const hash = (token: string) => createHash("sha256").update(token).digest("hex");
|
||||
await db.insert(companies).values({ id: companyId, name: "Permission acceptance", issuePrefix: "IPA", requireBoardApprovalForNewAgents: false });
|
||||
await db.insert(authUsers).values({ id: userId, name: "Operator", email: `${userId}@example.com`, createdAt: new Date(), updatedAt: new Date() });
|
||||
await db.insert(agents).values([sender, recipient].map((id, index) => ({ id, companyId, name: index ? "Recipient" : "Sender",
|
||||
adapterType: "process", status: "idle", permissions: {}, runtimeConfig: { heartbeat: { wakeOnDemand: false, intervalSec: 0 } } })));
|
||||
await db.insert(companyMemberships).values([
|
||||
{ companyId, principalType: "user", principalId: userId, membershipRole: "operator", status: "active" },
|
||||
...[sender, recipient].map(principalId => ({ companyId, principalType: "agent", principalId, membershipRole: "member", status: "active" })),
|
||||
]);
|
||||
await db.insert(principalPermissionGrants).values(["user", "agent"].flatMap(principalType => PERMISSION_KEYS.map(permissionKey => ({
|
||||
companyId, principalType, principalId: principalType === "user" ? userId : sender, permissionKey,
|
||||
}))));
|
||||
await db.insert(boardApiKeys).values({ userId, name: "Acceptance human", keyHash: hash(boardKey) });
|
||||
await db.insert(agentApiKeys).values({ companyId, agentId: sender, name: "Acceptance agent", keyHash: hash(senderKey), responsibleUserId: userId });
|
||||
const [owned, peer] = await db.insert(issues).values([
|
||||
{ companyId, title: "Owned task", assigneeAgentId: sender, status: "todo" },
|
||||
{ companyId, title: "Peer task", assigneeAgentId: recipient, status: "todo" },
|
||||
]).returning();
|
||||
app = await createApp(db, {
|
||||
uiMode: "none", serverPort: 0, bindHost: "127.0.0.1", allowedHostnames: ["127.0.0.1"],
|
||||
deploymentMode: "authenticated", deploymentExposure: "private", authReady: true, companyDeletionEnabled: false,
|
||||
managedPluginAutoInstall: [], storageService: createStorageService(createLocalDiskStorageProvider(directory)),
|
||||
decisionServiceOptions: { wakeOriginAgent: async () => null },
|
||||
});
|
||||
await app.locals.bundledPluginsStartup;
|
||||
server = app.listen(0, "127.0.0.1");
|
||||
await new Promise<void>(resolve => server!.once("listening", resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("No HTTP test address");
|
||||
const base = `http://127.0.0.1:${address.port}`;
|
||||
const repo = fileURLToPath(new URL("../../../", import.meta.url));
|
||||
const cli = async (agentId: string, patch?: Record<string, boolean | null>) => {
|
||||
const result = await execute(process.execPath, ["--import", `${repo}cli/node_modules/tsx/dist/loader.mjs`, `${repo}cli/src/index.ts`,
|
||||
"agent", patch ? "interaction-permissions:update" : "interaction-permissions", agentId, "--api-base", base,
|
||||
"--api-key", boardKey, "--json", ...(patch ? ["--payload-json", JSON.stringify(patch)] : [])], {
|
||||
cwd: repo, env: { ...process.env, PAPERCLIP_TELEMETRY_DISABLED: "1" }, timeout: 60000,
|
||||
});
|
||||
return JSON.parse(result.stdout);
|
||||
};
|
||||
const api = async (path: string, body: unknown, method = "POST", token = senderKey) => {
|
||||
const response = await fetch(`${base}/api${path}`, { method, headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, body: JSON.stringify(body) });
|
||||
return { status: response.status, body: await response.json() };
|
||||
};
|
||||
const snapshot = async () => ({
|
||||
tasks: await db.select().from(issues).where(eq(issues.companyId, companyId)),
|
||||
comments: await db.select().from(issueComments).where(eq(issueComments.companyId, companyId)),
|
||||
wakes: await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, companyId)),
|
||||
});
|
||||
expect((await cli(sender)).overrides).toEqual({});
|
||||
expect((await cli(recipient)).overrides).toEqual({});
|
||||
for (const key of AGENT_INTERACTION_PERMISSION_KEYS) {
|
||||
const policyAgent = key.startsWith("canReceive") ? recipient : sender;
|
||||
expect((await cli(policyAgent, { [key]: false })).overrides).toEqual({ [key]: false });
|
||||
const before = await snapshot();
|
||||
const response = key.includes("Assign") ? await api(`/companies/${companyId}/issues`, {
|
||||
title: "Prohibited delegation", status: "todo", ...(key === "canAssignTasksToHumans" ? { assigneeUserId: userId } : { assigneeAgentId: recipient }),
|
||||
}) : key.includes("Mention") ? await api(`/issues/${owned!.id}/comments`, { body: `[@Recipient](agent://${recipient})` })
|
||||
: key.includes("Comment") ? await api(`/issues/${peer!.id}/comments`, { body: "Prohibited reply" })
|
||||
: key.includes("Edit") ? await api(`/issues/${peer!.id}`, { title: "Prohibited edit" }, "PATCH")
|
||||
: await api(`/agents/${recipient}`, { capabilities: "Prohibited configuration" }, "PATCH");
|
||||
expect(response, key).toMatchObject({ status: 403, body: { details: { code: "agent_interaction_denied", permission: key } } });
|
||||
expect(await snapshot(), key).toEqual(before);
|
||||
expect((await cli(policyAgent, { [key]: null })).overrides).toEqual({});
|
||||
}
|
||||
await cli(recipient, { canReceiveAssignmentsFromAgents: false });
|
||||
const runId = randomUUID();
|
||||
await db.update(agents).set({ adapterType: "paperclip_runner" }).where(eq(agents.id, sender));
|
||||
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId: sender, status: "running", runtimeMode: "native",
|
||||
nativeIssueId: owned!.id, invocationSource: "assignment", triggerDetail: "system", contextSnapshot: { issueId: owned!.id } });
|
||||
await db.update(issues).set({ executionRunId: runId, status: "in_progress" }).where(eq(issues.id, owned!.id));
|
||||
let nativeWakes = 0;
|
||||
const native = new PaperclipRunnerToolAuthority(db, { companyId, agentId: sender, issueId: owned!.id, runId,
|
||||
enqueueWakeup: async () => { nativeWakes++; return null; } });
|
||||
await expect(native.execute({ tool: "create_task", callId: randomUUID(), arguments: {
|
||||
idempotencyKey: randomUUID(), title: "Prohibited native delegation", assigneeActorId: recipient,
|
||||
} })).rejects.toMatchObject({ status: 403, details: { permission: "canReceiveAssignmentsFromAgents" } });
|
||||
expect(nativeWakes).toBe(0);
|
||||
await cli(recipient, { canReceiveAssignmentsFromAgents: null, canReceiveCommentsFromAgents: false });
|
||||
expect((await api(`/issues/${peer!.id}/comments`, { body: "Authorized human message" }, "POST", boardKey)).status).toBe(201);
|
||||
expect((await api(`/agents/${sender}/interaction-permissions`, { canMentionAgents: true }, "PATCH")).status).toBe(403);
|
||||
await cli(recipient, { canReceiveCommentsFromAgents: null });
|
||||
const audit = await db.select().from(activityLog).where(and(eq(activityLog.companyId, companyId), eq(activityLog.action, "agent.interaction_permissions_updated")));
|
||||
expect(audit).toHaveLength(25);
|
||||
expect(audit.every(row => row.actorId === userId)).toBe(true);
|
||||
expect((await db.select().from(agents).where(eq(agents.companyId, companyId))).every(row => !Object.hasOwn(row.permissions, "interactions"))).toBe(true);
|
||||
} finally {
|
||||
await app?.locals.paperclipShutdown?.();
|
||||
if (server) await new Promise<void>((resolve, reject) => server!.close(error => error ? reject(error) : resolve()));
|
||||
await db.$client.end();
|
||||
await database.cleanup();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
}, 240000);
|
||||
@@ -0,0 +1,308 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { agents, companies, createDb, documents, heartbeatRuns, issueComments, issueThreadInteractions, issueWorkProducts, issues,
|
||||
EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
|
||||
import { buildAgentMentionHref } from "@paperclipai/shared";
|
||||
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { issueService } from "../services/issues.js";
|
||||
import { documentService } from "../services/documents.js";
|
||||
import { workProductService } from "../services/work-products.js";
|
||||
import { buildHostServices } from "../services/plugin-host-services.js";
|
||||
import { withPluginInteractionOrigin } from "../services/plugin-interaction-context.js";
|
||||
import { issueThreadInteractionService } from "../services/issue-thread-interactions.js";
|
||||
import { PaperclipRunnerToolAuthority } from "../services/native-runtime/paperclip-runner-tool-authority.js";
|
||||
|
||||
describe("agent interaction mutation boundaries", () => {
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
beforeAll(async () => {
|
||||
database = await startEmbeddedPostgresTestDatabase("agent-interaction-mutations-");
|
||||
db = createDb(database.connectionString);
|
||||
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
|
||||
afterAll(async () => { await db?.$client.end(); await database?.cleanup(); });
|
||||
|
||||
async function seed() {
|
||||
const companyId = randomUUID(), sender = randomUUID(), recipient = randomUUID(), issueId = randomUUID();
|
||||
await db.insert(companies).values({ id: companyId, name: "Interactions", issuePrefix: `I${companyId.slice(0, 6)}` });
|
||||
await db.insert(agents).values([
|
||||
{ id: sender, companyId, name: "Sender", adapterType: "process", status: "idle", permissions: {} },
|
||||
{ id: recipient, companyId, name: "Recipient", adapterType: "process", status: "idle", permissions: {} },
|
||||
]);
|
||||
await db.insert(issues).values({ id: issueId, companyId, title: "Original", status: "todo", assigneeAgentId: recipient });
|
||||
return { companyId, sender, recipient, issueId };
|
||||
}
|
||||
async function restrict(agentId: string, interactions: Record<string, boolean>) {
|
||||
await db.update(agents).set({ permissions: { interactions } }).where(eq(agents.id, agentId));
|
||||
}
|
||||
const denied = (permission: string) => ({ status: 403, details: expect.objectContaining({ code: "agent_interaction_denied", permission }) });
|
||||
|
||||
it("keeps unrestricted peer comments and edits working without persisting defaults", async () => {
|
||||
const f = await seed(), svc = issueService(db);
|
||||
await svc.addComment(f.issueId, "Peer reply", { agentId: f.sender });
|
||||
await svc.update(f.issueId, { title: "Edited", actorAgentId: f.sender });
|
||||
const [task] = await db.select().from(issues).where(eq(issues.id, f.issueId));
|
||||
const rows = await db.select().from(agents).where(eq(agents.companyId, f.companyId));
|
||||
expect(task.title).toBe("Edited");
|
||||
expect(rows.every(row => Object.keys(row.permissions).length === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks incoming comments before saving anything, including through plugin host services", async () => {
|
||||
const f = await seed();
|
||||
await restrict(f.recipient, { canReceiveCommentsFromAgents: false });
|
||||
const services = buildHostServices(db, "interaction-test", "interaction-test", {
|
||||
forPlugin: () => ({ emit: vi.fn(), subscribe: vi.fn(), clear: vi.fn() }),
|
||||
} as any);
|
||||
try {
|
||||
await expect(services.issues.createComment({ companyId: f.companyId, issueId: f.issueId,
|
||||
body: "Blocked plugin reply", authorAgentId: f.sender })).rejects.toMatchObject(denied("canReceiveCommentsFromAgents"));
|
||||
expect(await db.select().from(issueComments).where(eq(issueComments.issueId, f.issueId))).toEqual([]);
|
||||
} finally { services.dispose(); }
|
||||
});
|
||||
|
||||
it("rejects an outgoing comment on an unassigned task but permits self comments", async () => {
|
||||
const f = await seed(), svc = issueService(db);
|
||||
await restrict(f.sender, { canCommentOnOtherTasks: false });
|
||||
await db.update(issues).set({ assigneeAgentId: null }).where(eq(issues.id, f.issueId));
|
||||
await expect(svc.addComment(f.issueId, "No", { agentId: f.sender })).rejects.toMatchObject(denied("canCommentOnOtherTasks"));
|
||||
await db.update(issues).set({ assigneeAgentId: f.sender }).where(eq(issues.id, f.issueId));
|
||||
await svc.addComment(f.issueId, "Own work", { agentId: f.sender });
|
||||
expect(await db.select().from(issueComments).where(eq(issueComments.issueId, f.issueId))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("rejects structured forbidden mentions even on the sender's own task", async () => {
|
||||
const f = await seed(), svc = issueService(db);
|
||||
await db.update(issues).set({ assigneeAgentId: f.sender }).where(eq(issues.id, f.issueId));
|
||||
await restrict(f.recipient, { canReceiveMentionsFromAgents: false });
|
||||
const mention = `[@Recipient](${buildAgentMentionHref(f.recipient)})`;
|
||||
await expect(svc.addComment(f.issueId, mention, { agentId: f.sender })).rejects.toMatchObject(denied("canReceiveMentionsFromAgents"));
|
||||
await expect(svc.update(f.issueId, { description: mention, actorAgentId: f.sender })).rejects.toMatchObject(denied("canReceiveMentionsFromAgents"));
|
||||
const [task] = await db.select().from(issues).where(eq(issues.id, f.issueId));
|
||||
expect(task.description).toBeNull();
|
||||
expect(await db.select().from(issueComments).where(eq(issueComments.issueId, f.issueId))).toEqual([]);
|
||||
await svc.addComment(f.issueId, "Plain prose about Recipient", { agentId: f.sender });
|
||||
});
|
||||
|
||||
it("blocks peer edits and reassignment-to-self against the current owner", async () => {
|
||||
const f = await seed(), svc = issueService(db);
|
||||
await restrict(f.recipient, { canReceiveTaskEditsFromAgents: false });
|
||||
await expect(svc.update(f.issueId, { description: "New instructions", actorAgentId: f.sender })).rejects.toMatchObject(denied("canReceiveTaskEditsFromAgents"));
|
||||
await expect(svc.update(f.issueId, { assigneeAgentId: f.sender, actorAgentId: f.sender })).rejects.toMatchObject(denied("canReceiveTaskEditsFromAgents"));
|
||||
expect((await db.select().from(issues).where(eq(issues.id, f.issueId)))[0].assigneeAgentId).toBe(f.recipient);
|
||||
});
|
||||
|
||||
it("keeps comments independent from task-edit permission", async () => {
|
||||
const f = await seed(), svc = issueService(db);
|
||||
await restrict(f.sender, { canEditOtherTasks: false });
|
||||
await svc.addComment(f.issueId, "Allowed comment", { agentId: f.sender });
|
||||
await svc.update(f.issueId, { actorAgentId: f.sender });
|
||||
await expect(svc.update(f.issueId, { title: "No", actorAgentId: f.sender })).rejects.toMatchObject(denied("canEditOtherTasks"));
|
||||
});
|
||||
|
||||
it("rejects creating work for a protected recipient without inserting a task", async () => {
|
||||
const f = await seed();
|
||||
await restrict(f.recipient, { canReceiveAssignmentsFromAgents: false });
|
||||
await expect(issueService(db).create(f.companyId, { title: "Blocked new work", createdByAgentId: f.sender,
|
||||
assigneeAgentId: f.recipient, status: "todo" })).rejects.toMatchObject(denied("canReceiveAssignmentsFromAgents"));
|
||||
expect(await db.select().from(issues).where(eq(issues.companyId, f.companyId))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("allows initial plan content under assignment authority without requiring peer edit permission", async () => {
|
||||
const f = await seed();
|
||||
await restrict(f.sender, { canEditOtherTasks: false });
|
||||
await restrict(f.recipient, { canReceiveTaskEditsFromAgents: false });
|
||||
const created = await issueService(db).create(f.companyId, { title: "New assigned work", createdByAgentId: f.sender,
|
||||
assigneeAgentId: f.recipient, initialPlan: "Initial instructions", status: "todo" });
|
||||
expect((await documentService(db).getIssueDocumentByKey(created.id, "plan"))?.body).toBe("Initial instructions");
|
||||
await expect(documentService(db).upsertIssueDocument({ issueId: created.id, key: "notes", format: "markdown",
|
||||
body: "Later instructions", createdByAgentId: f.sender })).rejects.toMatchObject(denied("canEditOtherTasks"));
|
||||
});
|
||||
|
||||
it("checks assignment restrictions on native create_task before saving tasks or enqueueing wakes", async () => {
|
||||
const f = await seed(), runId = randomUUID(), wakes = vi.fn();
|
||||
await db.update(issues).set({ assigneeAgentId: f.sender, status: "in_progress" }).where(eq(issues.id, f.issueId));
|
||||
await db.update(agents).set({ adapterType: "paperclip_runner", status: "active" }).where(eq(agents.id, f.sender));
|
||||
await db.insert(heartbeatRuns).values({ id: runId, companyId: f.companyId, agentId: f.sender,
|
||||
status: "running", runtimeMode: "native", nativeIssueId: f.issueId,
|
||||
invocationSource: "assignment", triggerDetail: "system", contextSnapshot: { issueId: f.issueId } });
|
||||
await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, f.issueId));
|
||||
await restrict(f.recipient, { canReceiveAssignmentsFromAgents: false });
|
||||
const authority = new PaperclipRunnerToolAuthority(db, { companyId: f.companyId, agentId: f.sender,
|
||||
issueId: f.issueId, runId, enqueueWakeup: wakes });
|
||||
await expect(authority.execute({ tool: "create_task", callId: randomUUID(), arguments: {
|
||||
idempotencyKey: "protected-recipient", title: "Delegated", assigneeActorId: f.recipient,
|
||||
} })).rejects.toMatchObject(denied("canReceiveAssignmentsFromAgents"));
|
||||
expect(await db.select().from(issues).where(eq(issues.companyId, f.companyId))).toHaveLength(1);
|
||||
expect(wakes).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("treats an agent-addressed review as assignment even when task ownership stays unchanged", async () => {
|
||||
const f = await seed();
|
||||
await db.update(issues).set({ assigneeAgentId: f.sender }).where(eq(issues.id, f.issueId));
|
||||
await restrict(f.recipient, { canReceiveAssignmentsFromAgents: false });
|
||||
await expect(issueThreadInteractionService(db).create({ id: f.issueId, companyId: f.companyId }, {
|
||||
kind: "ask_user_questions", resolverPolicy: "board_or_agents", addresseeAgentId: f.recipient,
|
||||
continuationPolicy: "wake_assignee", payload: { version: 1,
|
||||
questions: [{ id: "review", prompt: "Review?", selectionMode: "single", options: [{ id: "yes", label: "Yes" }] }] },
|
||||
}, { agentId: f.sender })).rejects.toMatchObject(denied("canReceiveAssignmentsFromAgents"));
|
||||
expect(await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.issueId, f.issueId))).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects a native reassignment reason before stopping the currently running recipient", async () => {
|
||||
const f = await seed(), runId = randomUUID(), targetRunId = randomUUID(), stop = vi.fn(), wake = vi.fn();
|
||||
await db.update(issues).set({ assigneeAgentId: f.sender, status: "in_progress" }).where(eq(issues.id, f.issueId));
|
||||
await db.update(agents).set({ adapterType: "paperclip_runner", status: "active" }).where(eq(agents.id, f.sender));
|
||||
const [target] = await db.insert(issues).values({ companyId: f.companyId, title: "Running peer task",
|
||||
status: "in_progress", assigneeAgentId: f.recipient }).returning();
|
||||
await db.insert(heartbeatRuns).values([
|
||||
{ id: runId, companyId: f.companyId, agentId: f.sender, status: "running", runtimeMode: "native", nativeIssueId: f.issueId,
|
||||
invocationSource: "assignment", triggerDetail: "system", contextSnapshot: { issueId: f.issueId } },
|
||||
{ id: targetRunId, companyId: f.companyId, agentId: f.recipient, status: "running", runtimeMode: "native", nativeIssueId: target.id,
|
||||
invocationSource: "assignment", triggerDetail: "system", contextSnapshot: { issueId: target.id } },
|
||||
]);
|
||||
await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, f.issueId));
|
||||
await db.update(issues).set({ executionRunId: targetRunId }).where(eq(issues.id, target.id));
|
||||
await restrict(f.recipient, { canReceiveCommentsFromAgents: false });
|
||||
const authority = new PaperclipRunnerToolAuthority(db, { companyId: f.companyId, agentId: f.sender,
|
||||
issueId: f.issueId, runId, enqueueWakeup: wake, stopTaskForReassignment: stop });
|
||||
await expect(authority.execute({ tool: "reassign_task", callId: randomUUID(), arguments: {
|
||||
idempotencyKey: "protected-reason", taskId: target.id, assigneeActorId: f.sender,
|
||||
expectedAssigneeActorId: f.recipient, expectedAssigneeUserId: null, expectedStatusVersion: target.statusVersion,
|
||||
reason: "Taking over your task",
|
||||
} })).rejects.toMatchObject(denied("canReceiveCommentsFromAgents"));
|
||||
expect(stop).not.toHaveBeenCalled();
|
||||
expect(wake).not.toHaveBeenCalled();
|
||||
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, targetRunId)))[0].status).toBe("running");
|
||||
expect((await db.select().from(issues).where(eq(issues.id, target.id)))[0].assigneeAgentId).toBe(f.recipient);
|
||||
expect(await db.select().from(issueComments).where(eq(issueComments.issueId, target.id))).toEqual([]);
|
||||
});
|
||||
|
||||
it("protects peer configuration suggestions before creating a confirmation card", async () => {
|
||||
const f = await seed();
|
||||
await db.update(issues).set({ assigneeAgentId: f.sender }).where(eq(issues.id, f.issueId));
|
||||
await restrict(f.recipient, { canReceiveConfigurationChangesFromAgents: false });
|
||||
await expect(issueThreadInteractionService(db).create({ id: f.issueId, companyId: f.companyId }, {
|
||||
kind: "request_confirmation", payload: { version: 1, prompt: "Change instructions?",
|
||||
target: { type: "custom", key: `agent:${f.recipient}:instructions`, revisionId: "proposed" } },
|
||||
}, { agentId: f.sender })).rejects.toMatchObject(denied("canReceiveConfigurationChangesFromAgents"));
|
||||
expect(await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.issueId, f.issueId))).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves trusted plugin origin despite a supplied human comment actor", async () => {
|
||||
const f = await seed();
|
||||
const write = () => withPluginInteractionOrigin({ companyId: f.companyId, agentId: f.sender }, () =>
|
||||
issueService(db).addComment(f.issueId, "Plugin reply", { userId: "local-board" }));
|
||||
const comment = await write();
|
||||
expect(comment.authorAgentId).toBe(f.sender);
|
||||
expect(comment.authorUserId).toBeNull();
|
||||
await restrict(f.recipient, { canReceiveCommentsFromAgents: false });
|
||||
await expect(write()).rejects.toMatchObject(denied("canReceiveCommentsFromAgents"));
|
||||
expect(await db.select().from(issueComments).where(eq(issueComments.issueId, f.issueId))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("blocks plugin authorization-policy writes to a protected task", async () => {
|
||||
const f = await seed();
|
||||
await restrict(f.recipient, { canReceiveTaskEditsFromAgents: false });
|
||||
const services = buildHostServices(db, "interaction-test", "interaction-test", {
|
||||
forPlugin: () => ({ emit: vi.fn(), subscribe: vi.fn(), clear: vi.fn() }),
|
||||
} as any);
|
||||
try {
|
||||
await expect(withPluginInteractionOrigin({ companyId: f.companyId, agentId: f.sender }, () =>
|
||||
services.authorization.updatePolicy({ companyId: f.companyId, resourceType: "issue", resourceId: f.issueId,
|
||||
policy: { example: "policy" } }))).rejects.toMatchObject(denied("canReceiveTaskEditsFromAgents"));
|
||||
expect((await db.select().from(issues).where(eq(issues.id, f.issueId)))[0].executionPolicy).toBeNull();
|
||||
} finally { services.dispose(); }
|
||||
});
|
||||
|
||||
it("blocks parent influence when creating a child even if the child is self-assigned", async () => {
|
||||
const f = await seed();
|
||||
await restrict(f.recipient, { canReceiveTaskEditsFromAgents: false });
|
||||
await expect(issueService(db).create(f.companyId, { title: "Child", createdByAgentId: f.sender,
|
||||
assigneeAgentId: f.sender, parentId: f.issueId, status: "todo" })).rejects.toMatchObject(denied("canReceiveTaskEditsFromAgents"));
|
||||
expect(await db.select().from(issues).where(eq(issues.companyId, f.companyId))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("protects document writes and restores without adding revisions", async () => {
|
||||
const f = await seed(), svc = documentService(db);
|
||||
const original = await svc.upsertIssueDocument({ issueId: f.issueId, key: "plan", format: "markdown", body: "Original" });
|
||||
await restrict(f.recipient, { canReceiveTaskEditsFromAgents: false });
|
||||
await expect(svc.upsertIssueDocument({ issueId: f.issueId, key: "plan", format: "markdown", body: "Peer instructions",
|
||||
baseRevisionId: original.document.latestRevisionId, createdByAgentId: f.sender })).rejects.toMatchObject(denied("canReceiveTaskEditsFromAgents"));
|
||||
await expect(svc.deleteIssueDocument(f.issueId, "plan", f.sender)).rejects.toMatchObject(denied("canReceiveTaskEditsFromAgents"));
|
||||
expect((await svc.getIssueDocumentByKey(f.issueId, "plan"))?.body).toBe("Original");
|
||||
});
|
||||
|
||||
it("checks mentions in documents and work products, and permits a human write", async () => {
|
||||
const f = await seed(), docs = documentService(db), products = workProductService(db);
|
||||
await restrict(f.sender, { canMentionAgents: false });
|
||||
const mention = `[@Recipient](${buildAgentMentionHref(f.recipient)})`;
|
||||
await expect(docs.upsertIssueDocument({ issueId: f.issueId, key: "notes", format: "markdown", body: mention,
|
||||
createdByAgentId: f.sender })).rejects.toMatchObject(denied("canMentionAgents"));
|
||||
await expect(products.createForIssue(f.issueId, f.companyId, { type: "artifact", provider: "external", status: "ready", title: mention }, f.sender))
|
||||
.rejects.toMatchObject(denied("canMentionAgents"));
|
||||
expect(await db.select().from(documents).where(eq(documents.companyId, f.companyId))).toEqual([]);
|
||||
expect(await db.select().from(issueWorkProducts).where(eq(issueWorkProducts.issueId, f.issueId))).toEqual([]);
|
||||
await docs.upsertIssueDocument({ issueId: f.issueId, key: "notes", format: "markdown", body: mention });
|
||||
});
|
||||
|
||||
it("does not rescan an untouched historical mention when only unrelated fields change", async () => {
|
||||
const f = await seed();
|
||||
await db.update(issues).set({ description: `[@Recipient](${buildAgentMentionHref(f.recipient)})` }).where(eq(issues.id, f.issueId));
|
||||
await restrict(f.sender, { canMentionAgents: false });
|
||||
await issueService(db).update(f.issueId, { priority: "high", actorAgentId: f.sender });
|
||||
expect((await db.select().from(issues).where(and(eq(issues.id, f.issueId), eq(issues.companyId, f.companyId))))[0].priority).toBe("high");
|
||||
});
|
||||
|
||||
it("checks a work product's destination task when moving it from the sender's own task", async () => {
|
||||
const f = await seed(), products = workProductService(db);
|
||||
const [own] = await db.insert(issues).values({ companyId: f.companyId, title: "Own task", assigneeAgentId: f.sender }).returning();
|
||||
const product = await products.createForIssue(own.id, f.companyId,
|
||||
{ type: "artifact", provider: "external", status: "ready", title: "Own output" }, f.sender);
|
||||
await restrict(f.recipient, { canReceiveTaskEditsFromAgents: false });
|
||||
await expect(products.update(product!.id, { issueId: f.issueId }, f.sender))
|
||||
.rejects.toMatchObject(denied("canReceiveTaskEditsFromAgents"));
|
||||
expect((await db.select().from(issueWorkProducts).where(eq(issueWorkProducts.id, product!.id)))[0].issueId).toBe(own.id);
|
||||
});
|
||||
|
||||
it("checks bulk import assignments before reserving issue numbers or inserting tasks", async () => {
|
||||
const f = await seed();
|
||||
const [before] = await db.select().from(companies).where(eq(companies.id, f.companyId));
|
||||
await restrict(f.recipient, { canReceiveAssignmentsFromAgents: false });
|
||||
await expect(issueService(db).importIssues(f.companyId, [{ id: randomUUID(), ref: "imported", title: "Imported task",
|
||||
projectId: null, projectWorkspaceId: null, description: "New initial instructions", assigneeAgentId: f.recipient,
|
||||
status: "todo", priority: "medium", billingCode: null, assigneeAdapterOverrides: null,
|
||||
executionWorkspaceSettings: null, labelIds: [], monitorNotes: null, monitorScheduledBy: null,
|
||||
}], f.sender)).rejects.toMatchObject(denied("canReceiveAssignmentsFromAgents"));
|
||||
expect(await db.select().from(issues).where(eq(issues.companyId, f.companyId))).toHaveLength(1);
|
||||
expect((await db.select().from(companies).where(eq(companies.id, f.companyId)))[0].issueCounter).toBe(before.issueCounter);
|
||||
});
|
||||
|
||||
it("waits for company accounting before locking actors during task creation", async () => {
|
||||
const f = await seed();
|
||||
let creation: Promise<unknown> | undefined;
|
||||
try {
|
||||
await db.transaction(async importer => {
|
||||
await importer.select({ id: companies.id }).from(companies).where(eq(companies.id, f.companyId)).for("no key update");
|
||||
let started!: (pid: number) => void;
|
||||
const creatorPid = new Promise<number>(resolve => { started = resolve; });
|
||||
creation = db.transaction(async creator => {
|
||||
const rows = await creator.execute<{ pid: number }>(sql`select pg_backend_pid() as pid`);
|
||||
started(rows[0].pid);
|
||||
return issueService(db).create(f.companyId, { title: "Concurrent creation", assigneeAgentId: f.recipient,
|
||||
createdByAgentId: f.sender, status: "todo" }, creator);
|
||||
});
|
||||
const pid = await creatorPid;
|
||||
let waitingQuery = "";
|
||||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||
const rows = await db.execute<{ query: string; wait_event_type: string | null }>(
|
||||
sql`select query, wait_event_type from pg_stat_activity where pid = ${pid}`);
|
||||
if (rows[0]?.wait_event_type === "Lock") { waitingQuery = rows[0].query; break; }
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
}
|
||||
expect(waitingQuery).toContain('"companies"');
|
||||
await importer.execute(sql`select id from ${agents} where id in (${f.sender}, ${f.recipient}) for no key update nowait`);
|
||||
});
|
||||
} finally { await creation; }
|
||||
expect(await db.select().from(issues).where(eq(issues.companyId, f.companyId))).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { companyPortabilityService } from "../services/company-portability.js";
|
||||
import { createAgentLifecycle } from "../services/agent-lifecycle.js";
|
||||
import { toolAccessService } from "../services/tool-access.js";
|
||||
import { createToolGatewayService } from "../services/tool-gateway.js";
|
||||
import { withPluginInteractionOrigin } from "../services/plugin-interaction-context.js";
|
||||
import { updateAgentInteractionPermissions } from "../services/agent-interaction-permissions.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { activityLog, agents, authUsers, companies, companyMemberships, createDb, closeFileStorageDb, principalPermissionGrants,
|
||||
issues, issueComments, toolApplications, toolConnections, toolConnectionInstalls, toolProfiles, toolProfileBindings, toolMcpGateways,
|
||||
EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
|
||||
import { AGENT_INTERACTION_PERMISSION_KEYS, resolveAgentInteractionPermissions } from "@paperclipai/shared";
|
||||
import { agentInteractionPermissionRoutes } from "../routes/agent-interaction-permissions.js";
|
||||
import { errorHandler } from "../middleware/error-handler.js";
|
||||
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { agentService } from "../services/agents.js";
|
||||
import { preserveAgentInteractionPermissions, assertNoInitialAgentInteractionPermissions } from "../lib/agent-interaction-permissions.js";
|
||||
|
||||
const execute = promisify(execFile);
|
||||
|
||||
describe("individual agent interaction permissions", () => {
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
let storageHome: string;
|
||||
beforeAll(async () => {
|
||||
storageHome = await mkdtemp(join(tmpdir(), "interaction-permissions-home-"));
|
||||
vi.stubEnv("PAPERCLIP_HOME", storageHome);
|
||||
database = await startEmbeddedPostgresTestDatabase("agent-interaction-permissions-");
|
||||
db = createDb(database.connectionString);
|
||||
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
|
||||
afterAll(async () => { await closeFileStorageDb(db, 1); await db?.$client.end(); await database?.cleanup(); await rm(storageHome, { recursive: true, force: true }); vi.unstubAllEnvs(); });
|
||||
|
||||
async function seed() {
|
||||
const companyId = randomUUID(), agentId = randomUUID(), peerId = randomUUID(), userId = randomUUID();
|
||||
await db.insert(companies).values({ id: companyId, name: "Interaction permissions", issuePrefix: `I${companyId.slice(0, 6)}` });
|
||||
await db.insert(authUsers).values({ id: userId, name: "Permission manager", email: `${userId}@example.com`, createdAt: new Date(), updatedAt: new Date() });
|
||||
await db.insert(agents).values([agentId, peerId].map(id => ({ id, companyId, name: id, adapterType: "process", status: "idle",
|
||||
permissions: { canCreateAgents: true, unrelated: "preserved" } })));
|
||||
await db.insert(companyMemberships).values([
|
||||
{ companyId, principalType: "user", principalId: userId, membershipRole: "member", status: "active" },
|
||||
...[agentId, peerId].map(principalId => ({ companyId, principalType: "agent", principalId, membershipRole: "member", status: "active" })),
|
||||
]);
|
||||
await db.insert(principalPermissionGrants).values(["users:manage_permissions", "agents:read"].map(permissionKey => ({
|
||||
companyId, principalType: "user", principalId: userId, permissionKey,
|
||||
})));
|
||||
return { companyId, agentId, peerId, userId };
|
||||
}
|
||||
type Fixture = Awaited<ReturnType<typeof seed>>;
|
||||
const board = (f: Fixture): express.Request["actor"] => ({ type: "board", source: "session", userId: f.userId,
|
||||
companyIds: [f.companyId], memberships: [{ companyId: f.companyId, membershipRole: "member", status: "active" }] });
|
||||
const actor = (f: Fixture): express.Request["actor"] => ({ type: "agent", source: "agent_key", agentId: f.agentId, companyId: f.companyId });
|
||||
function app(identity: express.Request["actor"]) {
|
||||
const api = express();
|
||||
api.use(express.json());
|
||||
api.use((req, _res, next) => { req.actor = identity; next(); });
|
||||
api.use("/api", agentInteractionPermissionRoutes(db));
|
||||
api.use(errorHandler);
|
||||
return api;
|
||||
}
|
||||
const path = (f: Fixture) => `/api/agents/${f.agentId}/interaction-permissions`;
|
||||
const read = (f: Fixture) => db.select().from(agents).where(eq(agents.id, f.agentId)).then(rows => rows[0]!);
|
||||
|
||||
it("resolves absent settings without writing defaults, changes only supplied keys, and removes overrides with null", async () => {
|
||||
const f = await seed(), api = app(board(f));
|
||||
const initial = await read(f);
|
||||
const response = await request(api).get(path(f));
|
||||
expect(response.status, response.text).toBe(200);
|
||||
expect(response.body).toEqual({ agentId: f.agentId, companyId: f.companyId, overrides: {}, effective: resolveAgentInteractionPermissions({}) });
|
||||
expect((await read(f)).permissions).toEqual(initial.permissions);
|
||||
const first = await request(api).patch(path(f)).send({ canReceiveCommentsFromAgents: false });
|
||||
expect(first.status, first.text).toBe(200);
|
||||
expect(first.body.overrides).toEqual({ canReceiveCommentsFromAgents: false });
|
||||
const second = await request(api).patch(path(f)).send({ canAssignTasksToHumans: true });
|
||||
expect(second.body.overrides).toEqual({ canReceiveCommentsFromAgents: false, canAssignTasksToHumans: true });
|
||||
const reset = await request(api).patch(path(f)).send({ canReceiveCommentsFromAgents: null, canAssignTasksToHumans: null });
|
||||
expect(reset.status, reset.text).toBe(200);
|
||||
expect(reset.body.overrides).toEqual({});
|
||||
expect((await read(f)).permissions).toEqual(initial.permissions);
|
||||
const audit = await db.select().from(activityLog).where(and(eq(activityLog.entityId, f.agentId), eq(activityLog.action, "agent.interaction_permissions_updated")));
|
||||
expect(audit).toHaveLength(3);
|
||||
expect(audit.every(row => row.actorId === f.userId)).toBe(true);
|
||||
});
|
||||
|
||||
it("allows own configuration reads but denies agent/CEO changes, foreign users, and humans without permission management", async () => {
|
||||
const f = await seed(), foreign = await seed();
|
||||
await db.update(agents).set({ role: "ceo" }).where(eq(agents.id, f.agentId));
|
||||
expect((await request(app(actor(f))).get(path(f))).status).toBe(200);
|
||||
expect((await request(app(actor(f))).patch(path(f)).send({ canMentionAgents: false })).status).toBe(403);
|
||||
expect((await request(app(board(foreign))).patch(path(f)).send({ canMentionAgents: false })).status).toBe(404);
|
||||
await expect(withPluginInteractionOrigin({ companyId: f.companyId, agentId: f.peerId }, () =>
|
||||
updateAgentInteractionPermissions(db, board(f), f.companyId, f.agentId, { canMentionAgents: false })))
|
||||
.rejects.toMatchObject({ status: 403 });
|
||||
await db.delete(principalPermissionGrants).where(and(eq(principalPermissionGrants.principalId, f.userId), eq(principalPermissionGrants.permissionKey, "users:manage_permissions")));
|
||||
expect((await request(app(board(f))).patch(path(f)).send({ canMentionAgents: false })).status).toBe(403);
|
||||
expect((await read(f)).permissions).not.toHaveProperty("interactions");
|
||||
});
|
||||
|
||||
it("rejects malformed patches and pending hires without side effects", async () => {
|
||||
const f = await seed(), api = app(board(f));
|
||||
for (const input of [{}, { canMentionAgents: "false" }, { canMentionAgents: false, unexpected: false }]) {
|
||||
expect((await request(api).patch(path(f)).send(input)).status).toBe(400);
|
||||
}
|
||||
await db.update(agents).set({ status: "pending_approval" }).where(eq(agents.id, f.agentId));
|
||||
expect((await request(api).patch(path(f)).send({ canMentionAgents: false })).status).toBe(409);
|
||||
expect((await read(f)).permissions).not.toHaveProperty("interactions");
|
||||
});
|
||||
|
||||
it("serializes independent policy patches and preserves restrictions through legacy permission saves", async () => {
|
||||
const f = await seed(), api = app(board(f));
|
||||
const responses = await Promise.all([
|
||||
request(api).patch(path(f)).send({ canMentionAgents: false }),
|
||||
request(api).patch(path(f)).send({ canAssignTasksToAgents: false }),
|
||||
agentService(db).updatePermissions(f.agentId, { canCreateAgents: false }),
|
||||
]);
|
||||
expect(responses.slice(0, 2).map(result => result && "status" in result ? result.status : null)).toEqual([200, 200]);
|
||||
const saved = await read(f);
|
||||
expect(saved.permissions).toMatchObject({ canCreateAgents: false, unrelated: "preserved",
|
||||
interactions: { canMentionAgents: false, canAssignTasksToAgents: false } });
|
||||
await expect(agentService(db).updatePermissions(f.agentId, { canCreateAgents: false, interactions: {} }))
|
||||
.rejects.toMatchObject({ status: 403 });
|
||||
await agentService(db).update(f.agentId, { permissions: { canCreateSkills: true } }, { recordRevision: { createdByUserId: f.userId } });
|
||||
expect((await read(f)).permissions.interactions).toEqual(saved.permissions.interactions);
|
||||
expect(preserveAgentInteractionPermissions(saved.permissions, {})).toHaveProperty("interactions", saved.permissions.interactions);
|
||||
expect(() => assertNoInitialAgentInteractionPermissions({ interactions: {} })).toThrow(/interaction-permissions/);
|
||||
});
|
||||
|
||||
it("uses the real CLI against HTTP and PostgreSQL for inspect, all eleven settings, and reset", async () => {
|
||||
const f = await seed(), api = app(board(f));
|
||||
const server = api.listen(0, "127.0.0.1");
|
||||
await new Promise<void>(resolve => server.once("listening", resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("HTTP fixture unavailable");
|
||||
const repo = fileURLToPath(new URL("../../../", import.meta.url));
|
||||
const cli = async (command: string, payload?: Record<string, boolean | null>) => {
|
||||
const result = await execute(process.execPath, ["--import", `${repo}cli/node_modules/tsx/dist/loader.mjs`, `${repo}cli/src/index.ts`,
|
||||
"agent", command, f.agentId, "--api-base", `http://127.0.0.1:${address.port}`, "--api-key", "fixture-board-token", "--json",
|
||||
...(payload ? ["--payload-json", JSON.stringify(payload)] : [])], {
|
||||
cwd: repo, env: { ...process.env, PAPERCLIP_TELEMETRY_DISABLED: "1" }, timeout: 60000,
|
||||
});
|
||||
return JSON.parse(result.stdout);
|
||||
};
|
||||
try {
|
||||
expect((await cli("interaction-permissions")).overrides).toEqual({});
|
||||
expect((await cli("interaction-permissions:update", { canReceiveAssignmentsFromAgents: false })).overrides)
|
||||
.toEqual({ canReceiveAssignmentsFromAgents: false });
|
||||
const allDisabled = Object.fromEntries(AGENT_INTERACTION_PERMISSION_KEYS.map(key => [key, false]));
|
||||
expect((await cli("interaction-permissions:update", allDisabled)).effective).toEqual(allDisabled);
|
||||
expect((await read(f)).permissions.interactions).toEqual(allDisabled);
|
||||
const peer = await db.select().from(agents).where(eq(agents.id, f.peerId));
|
||||
expect(peer[0]!.permissions).not.toHaveProperty("interactions");
|
||||
expect((await cli("interaction-permissions:update", Object.fromEntries(AGENT_INTERACTION_PERMISSION_KEYS.map(key => [key, null])))).overrides).toEqual({});
|
||||
expect((await read(f)).permissions).not.toHaveProperty("interactions");
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()));
|
||||
}
|
||||
}, 120000);
|
||||
|
||||
it("preserves historical mentions on a full configuration save but rejects changed mention-bearing text", async () => {
|
||||
const f = await seed();
|
||||
const mention = `Existing [@Peer](agent://${f.peerId}) context`;
|
||||
await db.update(agents).set({ capabilities: mention }).where(eq(agents.id, f.agentId));
|
||||
expect((await request(app(board(f))).patch(path(f)).send({ canMentionAgents: false })).status).toBe(200);
|
||||
const options = { recordRevision: { createdByAgentId: f.agentId, source: "interaction-test" } };
|
||||
await agentService(db).update(f.agentId, { title: "Updated title", capabilities: mention }, options);
|
||||
expect((await read(f)).title).toBe("Updated title");
|
||||
await expect(agentService(db).update(f.agentId, { title: "Rejected title", capabilities: `${mention} changed` }, options))
|
||||
.rejects.toMatchObject({ status: 403, details: { permission: "canMentionAgents" } });
|
||||
expect((await read(f)).title).toBe("Updated title");
|
||||
const peer = { ...f, agentId: f.peerId };
|
||||
expect((await request(app(board(f))).patch(path(peer)).send({ canReceiveConfigurationChangesFromAgents: false })).status).toBe(200);
|
||||
await expect(agentService(db).update(f.peerId, { title: "Prohibited peer change" }, options))
|
||||
.rejects.toMatchObject({ status: 403, details: { permission: "canReceiveConfigurationChangesFromAgents" } });
|
||||
expect((await read(peer)).title).toBeNull();
|
||||
});
|
||||
it("blocks protected peer tool assignments without affecting shared profile changes", async () => {
|
||||
const f = await seed();
|
||||
const target = { ...f, agentId: f.peerId };
|
||||
expect((await request(app(board(f))).patch(path(target)).send({ canReceiveConfigurationChangesFromAgents: false })).status).toBe(200);
|
||||
const [profile] = await db.insert(toolProfiles).values({ companyId: f.companyId, profileKey: "interaction-tools", name: "Interaction tools" }).returning();
|
||||
const [application] = await db.insert(toolApplications).values({ companyId: f.companyId, name: "Tool fixture", type: "mcp_http" }).returning();
|
||||
const [connection] = await db.insert(toolConnections).values({ companyId: f.companyId, applicationId: application.id,
|
||||
name: "Tool connection", uid: randomUUID(), transport: "mcp_remote", authKind: "none", enabled: true, status: "active" }).returning();
|
||||
const service = toolAccessService(db);
|
||||
const source = { actorType: "agent" as const, actorId: f.agentId };
|
||||
const denied = { status: 403, details: { permission: "canReceiveConfigurationChangesFromAgents" } };
|
||||
await expect(service.bindProfile(profile.id, { targetType: "agent", targetId: f.peerId, priority: 100 }, source)).rejects.toMatchObject(denied);
|
||||
await expect(service.putConnectionInstalls(connection.id, { installs: [{ targetType: "agent", targetId: f.peerId }] }, source)).rejects.toMatchObject(denied);
|
||||
await expect(createToolGatewayService(db).createNamedGateway({ companyId: f.companyId,
|
||||
body: { name: "Protected gateway", profileId: profile.id, agentId: f.peerId }, actor: { agentId: f.agentId } })).rejects.toMatchObject(denied);
|
||||
expect(await db.select().from(toolConnectionInstalls).where(eq(toolConnectionInstalls.companyId, f.companyId))).toHaveLength(0);
|
||||
expect(await db.select().from(toolProfileBindings).where(eq(toolProfileBindings.companyId, f.companyId))).toHaveLength(0);
|
||||
expect(await db.select().from(toolMcpGateways).where(eq(toolMcpGateways.companyId, f.companyId))).toHaveLength(0);
|
||||
await service.bindProfile(profile.id, { targetType: "agent", targetId: f.peerId, priority: 100 });
|
||||
await withPluginInteractionOrigin({ companyId: f.companyId, agentId: f.agentId }, async () => {
|
||||
await service.updateProfile(profile.id, { description: "Shared profile remains editable" });
|
||||
await expect(service.unbindProfile(profile.id, { targetType: "agent", targetId: f.peerId })).rejects.toMatchObject(denied);
|
||||
const gateway = await createToolGatewayService(db).createNamedGateway({ companyId: f.companyId,
|
||||
body: { name: "Own gateway", profileId: profile.id, agentId: f.agentId } });
|
||||
await expect(createToolGatewayService(db).updateNamedGateway({ companyId: f.companyId, gatewayId: gateway.id,
|
||||
body: { agentId: f.peerId } })).rejects.toMatchObject(denied);
|
||||
});
|
||||
expect((await service.getProfile(profile.id, f.companyId)).description).toBe("Shared profile remains editable");
|
||||
});
|
||||
|
||||
it("preserves importer identity and rolls back forbidden imported work while retaining historical comments", async () => {
|
||||
const f = await seed();
|
||||
await db.update(agents).set({ name: "Peer" }).where(eq(agents.id, f.peerId));
|
||||
const target = { ...f, agentId: f.peerId };
|
||||
const files: Record<string, string> = {
|
||||
"COMPANY.md": '---\nschema: "agentcompanies/v1"\nname: "Imported"\n---\n',
|
||||
"tasks/work/TASK.md": '---\nname: "Imported task"\nassignee: "peer"\n---\nInitial instructions.',
|
||||
"tasks/work/doc.md": "Initial document.",
|
||||
".paperclip.yaml": 'schemaVersion: 7\ntasks:\n work:\n status: todo\n comments:\n - body: "Historical comment"\n authorType: system\n documents:\n - key: spec\n title: Specification\n format: markdown\n path: tasks/work/doc.md\n',
|
||||
};
|
||||
const input = { source: { type: "inline" as const, files }, include: { company: false, agents: false, projects: false, issues: true },
|
||||
target: { mode: "existing_company" as const, companyId: f.companyId }, collisionStrategy: "skip" as const };
|
||||
const options = { mode: "agent_safe" as const, sourceCompanyId: f.companyId, actorAgentId: f.agentId, actorAgentCompanyId: f.companyId };
|
||||
await request(app(board(f))).patch(path(target)).send({ canReceiveAssignmentsFromAgents: false });
|
||||
await expect(companyPortabilityService(db).importBundle(input, null, options))
|
||||
.rejects.toMatchObject({ status: 403, details: { permission: "canReceiveAssignmentsFromAgents" } });
|
||||
expect(await db.select().from(issues).where(eq(issues.companyId, f.companyId))).toHaveLength(0);
|
||||
await request(app(board(f))).patch(path(target)).send({ canReceiveAssignmentsFromAgents: null, canReceiveCommentsFromAgents: false });
|
||||
await request(app(board(f))).patch(path(f)).send({ canMentionAgents: false, canCommentOnOtherTasks: false });
|
||||
files["tasks/work/doc.md"] = `[@Peer](agent://${f.peerId})`;
|
||||
await expect(companyPortabilityService(db).importBundle(input, null, options))
|
||||
.rejects.toMatchObject({ status: 403, details: { permission: "canMentionAgents" } });
|
||||
expect(await db.select().from(issues).where(eq(issues.companyId, f.companyId))).toHaveLength(0);
|
||||
files["tasks/work/doc.md"] = "Initial document.";
|
||||
await companyPortabilityService(db).importBundle(input, null, options);
|
||||
expect(await db.select().from(issues).where(eq(issues.companyId, f.companyId))).toEqual([
|
||||
expect.objectContaining({ assigneeAgentId: f.peerId }),
|
||||
]);
|
||||
expect(await db.select().from(issueComments).where(eq(issueComments.companyId, f.companyId))).toEqual([
|
||||
expect.objectContaining({ body: "Historical comment", authorType: "system", authorAgentId: null }),
|
||||
]);
|
||||
files["tasks/work/TASK.md"] = '---\nname: "Unassigned imported task"\n---\nInitial instructions.';
|
||||
files["agents/new-peer/AGENTS.md"] = '---\nname: "New Peer"\nrole: engineer\n---\nInitial instructions.';
|
||||
files[".paperclip.yaml"] += 'agents:\n new-peer:\n adapterType: claude_local\n';
|
||||
const created = await companyPortabilityService(db).importBundle({ ...input, adapterOverrides: { "new-peer": { adapterType: "claude_local" } }, include: { ...input.include, agents: true },
|
||||
target: { mode: "new_company", newCompanyName: "Agent imported company" } }, null, options);
|
||||
expect(created.warnings.filter(warning => warning.includes("materialize"))).toEqual([]);
|
||||
expect(await db.select().from(issues).where(eq(issues.companyId, created.company.id))).toHaveLength(1);
|
||||
expect(await db.select().from(agents).where(eq(agents.companyId, created.company.id))).toEqual([
|
||||
expect.objectContaining({ name: "New Peer" }),
|
||||
]);
|
||||
await expect(createAgentLifecycle(db).requestHire(f.companyId, { name: "Mentioned hire", adapterType: "process", capabilities: `[@Peer](agent://${f.peerId})` },
|
||||
{ createdByAgentId: f.agentId })).rejects.toMatchObject({ status: 403, details: { permission: "canMentionAgents" } });
|
||||
expect(await db.select().from(agents).where(and(eq(agents.companyId, f.companyId), eq(agents.name, "Mentioned hire")))).toHaveLength(0);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { agents, authUsers, companies, companyMemberships, createDb, issues, principalPermissionGrants,
|
||||
EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
|
||||
import { AGENT_INTERACTION_PERMISSION_KEYS, type AgentInteractionPermissionKey } from "@paperclipai/shared";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { agentInteractionDenial, assertAgentInteraction, type AgentInteractionInput } from "../services/agent-interaction-policy.js";
|
||||
import { authorizationService, authorizationDeniedDetails } from "../services/authorization.js";
|
||||
import { withPluginInteractionOrigin } from "../services/plugin-interaction-context.js";
|
||||
|
||||
describe("agent interaction authorization", () => {
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
beforeAll(async () => {
|
||||
database = await startEmbeddedPostgresTestDatabase("agent-interaction-policy-");
|
||||
db = createDb(database.connectionString);
|
||||
await db.insert(authUsers).values({ id: "human", name: "Human", email: "human@example.com", createdAt: new Date(), updatedAt: new Date() });
|
||||
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
|
||||
afterAll(async () => { await db?.$client.end(); await database?.cleanup(); });
|
||||
|
||||
async function seed() {
|
||||
const companyId = randomUUID(), actorAgentId = randomUUID(), targetAgentId = randomUUID();
|
||||
await db.insert(companies).values({ id: companyId, name: "Interactions", issuePrefix: `I${companyId.slice(0, 6)}` });
|
||||
await db.insert(agents).values([actorAgentId, targetAgentId].map((id, i) => ({
|
||||
id, companyId, name: `Agent ${i}`, status: "idle", adapterType: "process", permissions: {},
|
||||
})));
|
||||
await db.insert(companyMemberships).values([actorAgentId, targetAgentId].map(principalId => ({
|
||||
companyId, principalId, principalType: "agent", membershipRole: "member", status: "active",
|
||||
})));
|
||||
const [task, own, unassigned, human, conversation] = await db.insert(issues).values([
|
||||
{ assigneeAgentId: targetAgentId }, { assigneeAgentId: actorAgentId }, {}, { assigneeUserId: "human" },
|
||||
{ conversationAgentId: actorAgentId, conversationUserId: "human", assigneeAgentId: actorAgentId, conversationState: "active" as const },
|
||||
].map((value, i) => ({ companyId, title: `Task ${i}`, status: "todo", ...value }))).returning();
|
||||
return { companyId, actorAgentId, targetAgentId, task: task!, own: own!, unassigned: unassigned!, human: human!, conversation: conversation! };
|
||||
}
|
||||
type Fixture = Awaited<ReturnType<typeof seed>>;
|
||||
async function set(agentId: string, key: AgentInteractionPermissionKey, value = false) {
|
||||
await db.update(agents).set({ permissions: { interactions: { [key]: value } } }).where(eq(agents.id, agentId));
|
||||
}
|
||||
const input = (f: Fixture, operation: AgentInteractionInput["operation"], more: Partial<AgentInteractionInput> = {}): AgentInteractionInput => ({
|
||||
companyId: f.companyId, actorAgentId: f.actorAgentId, operation, ...more,
|
||||
});
|
||||
|
||||
const cases: Array<[AgentInteractionPermissionKey, "incoming" | "outgoing", AgentInteractionInput["operation"]]> = [
|
||||
["canAssignTasksToAgents", "outgoing", "assign"], ["canReceiveAssignmentsFromAgents", "incoming", "assign"],
|
||||
["canAssignTasksToHumans", "outgoing", "assign"], ["canMentionAgents", "outgoing", "mention"],
|
||||
["canReceiveMentionsFromAgents", "incoming", "mention"], ["canCommentOnOtherTasks", "outgoing", "comment"],
|
||||
["canReceiveCommentsFromAgents", "incoming", "comment"], ["canEditOtherTasks", "outgoing", "edit"],
|
||||
["canReceiveTaskEditsFromAgents", "incoming", "edit"], ["canConfigureOtherAgents", "outgoing", "configure"],
|
||||
["canReceiveConfigurationChangesFromAgents", "incoming", "configure"],
|
||||
];
|
||||
it.each(cases)("%s independently restricts its operation and can be restored", async (key, direction, operation) => {
|
||||
const f = await seed();
|
||||
const action = input(f, operation, {
|
||||
...(operation === "comment" || operation === "edit" ? { issueId: f.task.id } :
|
||||
key === "canAssignTasksToHumans" ? { assigneeUserId: "human" } : { targetAgentId: f.targetAgentId }),
|
||||
});
|
||||
expect(await agentInteractionDenial(db, action)).toBeNull();
|
||||
const policyAgentId = direction === "incoming" ? f.targetAgentId : f.actorAgentId;
|
||||
await set(policyAgentId, key);
|
||||
expect(await agentInteractionDenial(db, action)).toMatchObject({ permission: key, direction, policyAgentId });
|
||||
await expect(assertAgentInteraction(db, action)).rejects.toMatchObject({ status: 403, details: { code: "agent_interaction_denied", permission: key } });
|
||||
expect(await agentInteractionDenial(db, { ...action, actorAgentId: null })).toBeNull();
|
||||
if (operation !== "configure") expect(await agentInteractionDenial(db, input(f, "configure", { targetAgentId: f.targetAgentId }))).toBeNull();
|
||||
await set(policyAgentId, key, true);
|
||||
expect(await agentInteractionDenial(db, action)).toBeNull();
|
||||
await db.update(agents).set({ permissions: {} }).where(eq(agents.id, policyAgentId));
|
||||
expect(await agentInteractionDenial(db, action)).toBeNull();
|
||||
});
|
||||
|
||||
it("uses current ownership including conversations, never creator or manager relationships", async () => {
|
||||
const f = await seed();
|
||||
await db.update(agents).set({ reportsTo: f.actorAgentId }).where(eq(agents.id, f.targetAgentId));
|
||||
await db.update(issues).set({ createdByAgentId: f.actorAgentId, parentId: f.own.id }).where(eq(issues.id, f.task.id));
|
||||
for (const operation of ["comment", "edit"] as const) {
|
||||
await set(f.actorAgentId, operation === "comment" ? "canCommentOnOtherTasks" : "canEditOtherTasks");
|
||||
for (const task of [f.own, f.conversation]) expect(await agentInteractionDenial(db, input(f, operation, { issueId: task.id }))).toBeNull();
|
||||
for (const task of [f.task, f.unassigned, f.human]) expect(await agentInteractionDenial(db, input(f, operation, { issueId: task.id }))).not.toBeNull();
|
||||
}
|
||||
await db.update(issues).set({ assigneeAgentId: f.targetAgentId }).where(eq(issues.id, f.own.id));
|
||||
expect(await agentInteractionDenial(db, input(f, "edit", { issueId: f.own.id }))).not.toBeNull();
|
||||
});
|
||||
|
||||
it("protects a source owner from self-claim/unassignment and parent edits independently of destination", async () => {
|
||||
const f = await seed();
|
||||
await set(f.targetAgentId, "canReceiveTaskEditsFromAgents");
|
||||
for (const destination of [{}, { targetAgentId: f.actorAgentId }, { assigneeUserId: "human" }]) {
|
||||
expect(await agentInteractionDenial(db, input(f, "assign", { issueId: f.task.id, ...destination })))
|
||||
.toMatchObject({ permission: "canReceiveTaskEditsFromAgents", operation: "edit" });
|
||||
}
|
||||
expect(await agentInteractionDenial(db, input(f, "assign", { parentIssueId: f.task.id, targetAgentId: f.actorAgentId })))
|
||||
.toMatchObject({ issueId: f.task.id, permission: "canReceiveTaskEditsFromAgents" });
|
||||
expect(await agentInteractionDenial(db, input(f, "assign", { targetAgentId: f.targetAgentId }))).toBeNull();
|
||||
expect(await agentInteractionDenial(db, input(f, "assign", { issueId: f.task.id, targetAgentId: f.targetAgentId, delivery: true }))).toBeNull();
|
||||
});
|
||||
|
||||
it("checks every structured mention alongside the operation while allowing self and ordinary text", async () => {
|
||||
const f = await seed(), foreign = await seed();
|
||||
await set(f.targetAgentId, "canReceiveMentionsFromAgents");
|
||||
const action = input(f, "comment", { issueId: f.own.id });
|
||||
expect(await agentInteractionDenial(db, { ...action, texts: [`[@self](agent://${f.actorAgentId}) and [@target](agent://${f.targetAgentId})`] }))
|
||||
.toMatchObject({ operation: "mention", targetAgentId: f.targetAgentId });
|
||||
expect(await agentInteractionDenial(db, { ...action, texts: [`@Agent 1 [@self](agent://${f.actorAgentId}) [@foreign](agent://${foreign.targetAgentId}) [@unknown](agent://not-a-uuid)`] })).toBeNull();
|
||||
await set(f.actorAgentId, "canMentionAgents");
|
||||
expect(await agentInteractionDenial(db, { ...action, texts: [`[@self](agent://${f.actorAgentId})`] })).toBeNull();
|
||||
});
|
||||
|
||||
it("enforces explicit denials after shared authorization, including CEO/grants and responsible-human attribution", async () => {
|
||||
const f = await seed();
|
||||
await db.update(agents).set({ role: "ceo", permissions: { canCreateAgents: true } }).where(eq(agents.id, f.actorAgentId));
|
||||
await db.insert(principalPermissionGrants).values({ companyId: f.companyId, principalType: "agent", principalId: f.actorAgentId, permissionKey: "tasks:assign" });
|
||||
const actor = { type: "agent" as const, agentId: f.actorAgentId, companyId: f.companyId };
|
||||
const resource = { type: "issue" as const, companyId: f.companyId, assigneeAgentId: f.targetAgentId };
|
||||
expect((await authorizationService(db).decide({ actor, action: "tasks:assign", resource })).allowed).toBe(true);
|
||||
await set(f.targetAgentId, "canReceiveAssignmentsFromAgents");
|
||||
const decision = await authorizationService(db).decide({ actor, action: "tasks:assign", resource });
|
||||
expect(decision).toMatchObject({ allowed: false, reason: "deny_agent_interaction" });
|
||||
expect(authorizationDeniedDetails(decision)).toMatchObject({ code: "agent_interaction_denied", permission: "canReceiveAssignmentsFromAgents", direction: "incoming" });
|
||||
await db.update(agents).set({ permissions: { trustPreset: "low_trust_review", interactions: { canAssignTasksToAgents: true },
|
||||
authorizationPolicy: { trustBoundary: { mode: "low_trust_review", companyId: f.companyId, issueIds: [f.own.id] } } } }).where(eq(agents.id, f.actorAgentId));
|
||||
await set(f.targetAgentId, "canReceiveAssignmentsFromAgents", true);
|
||||
expect((await authorizationService(db).decide({ actor, action: "tasks:assign", resource })).allowed).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves sparse existing records and checks fresh policy even for a reused authorizer", async () => {
|
||||
const f = await seed();
|
||||
const auth = authorizationService(db);
|
||||
const query = { actor: { type: "agent" as const, agentId: f.actorAgentId, companyId: f.companyId }, action: "issue:comment" as const,
|
||||
resource: { type: "issue" as const, companyId: f.companyId, issueId: f.task.id, assigneeAgentId: f.targetAgentId, assigneeUserId: null, status: "todo" } };
|
||||
expect((await auth.decide(query)).allowed).toBe(true);
|
||||
expect((await db.select().from(agents).where(eq(agents.id, f.actorAgentId)))[0]?.permissions).toEqual({});
|
||||
await set(f.targetAgentId, "canReceiveCommentsFromAgents");
|
||||
expect((await auth.decide(query)).allowed).toBe(false);
|
||||
expect(cases.map(([key]) => key).sort()).toEqual([...AGENT_INTERACTION_PERMISSION_KEYS].sort());
|
||||
});
|
||||
|
||||
it("serializes an authorized mutation with a concurrent human permission change", async () => {
|
||||
const f = await seed();
|
||||
let locked!: () => void;
|
||||
const ready = new Promise<void>(resolve => { locked = resolve; });
|
||||
let release!: () => void;
|
||||
const held = new Promise<void>(resolve => { release = resolve; });
|
||||
const mutation = db.transaction(async tx => {
|
||||
await assertAgentInteraction(tx, input(f, "edit", { issueId: f.task.id, lock: true }));
|
||||
await tx.update(issues).set({ title: "Authorized before revocation" }).where(eq(issues.id, f.task.id));
|
||||
locked();
|
||||
await held;
|
||||
});
|
||||
await ready;
|
||||
try {
|
||||
await expect(db.transaction(async tx => {
|
||||
await tx.select({ id: agents.id }).from(agents).where(eq(agents.id, f.targetAgentId)).for("update", { noWait: true });
|
||||
})).rejects.toMatchObject({ cause: { code: "55P03" } });
|
||||
} finally {
|
||||
release();
|
||||
await mutation;
|
||||
}
|
||||
await set(f.targetAgentId, "canReceiveTaskEditsFromAgents");
|
||||
await expect(db.transaction(async tx => {
|
||||
await assertAgentInteraction(tx, input(f, "edit", { issueId: f.task.id, lock: true }));
|
||||
await tx.update(issues).set({ title: "Must not persist" }).where(eq(issues.id, f.task.id));
|
||||
})).rejects.toMatchObject({ status: 403 });
|
||||
expect((await db.select({ title: issues.title }).from(issues).where(eq(issues.id, f.task.id)))[0]?.title).toBe("Authorized before revocation");
|
||||
});
|
||||
|
||||
it("uses trusted plugin origin even when a handler labels the operation human or another agent", async () => {
|
||||
const f = await seed();
|
||||
await set(f.targetAgentId, "canReceiveCommentsFromAgents");
|
||||
await withPluginInteractionOrigin({ companyId: f.companyId, agentId: f.actorAgentId }, async () => {
|
||||
for (const actorAgentId of [null, f.targetAgentId]) {
|
||||
expect(await agentInteractionDenial(db, input(f, "comment", { actorAgentId, issueId: f.task.id })))
|
||||
.toMatchObject({ permission: "canReceiveCommentsFromAgents", actorAgentId: f.actorAgentId });
|
||||
}
|
||||
const decision = await authorizationService(db).decide({
|
||||
actor: { type: "board", source: "local_implicit" }, action: "issue:comment",
|
||||
resource: { type: "issue", companyId: f.companyId, issueId: f.task.id },
|
||||
});
|
||||
expect(decision).toMatchObject({ allowed: false, code: "agent_interaction_denied" });
|
||||
await expect(agentInteractionDenial(db, input(f, "comment", { companyId: randomUUID() })))
|
||||
.rejects.toMatchObject({ status: 404 });
|
||||
});
|
||||
});
|
||||
|
||||
it("checks the original company's sender for an independently authorized cross-company import", async () => {
|
||||
const source = await seed(), destination = await seed();
|
||||
const action = input(destination, "assign", { actorAgentId: source.actorAgentId, targetAgentId: destination.targetAgentId });
|
||||
await expect(agentInteractionDenial(db, action)).rejects.toMatchObject({ status: 404 });
|
||||
const attributed = { ...action, actorCompanyId: source.companyId };
|
||||
expect(await agentInteractionDenial(db, attributed)).toBeNull();
|
||||
await set(source.actorAgentId, "canAssignTasksToAgents");
|
||||
expect(await agentInteractionDenial(db, attributed)).toMatchObject({ permission: "canAssignTasksToAgents", direction: "outgoing" });
|
||||
await set(source.actorAgentId, "canAssignTasksToAgents", true);
|
||||
await set(destination.targetAgentId, "canReceiveAssignmentsFromAgents");
|
||||
expect(await agentInteractionDenial(db, attributed)).toMatchObject({ permission: "canReceiveAssignmentsFromAgents", direction: "incoming" });
|
||||
});
|
||||
});
|
||||
@@ -1370,7 +1370,7 @@ describe("agent permission routes", () => {
|
||||
expect.objectContaining({
|
||||
status: "idle",
|
||||
}),
|
||||
{ runnerResolved: true, aiConnectionInstall: undefined, createdByUserId: "agent-admin-user", responsibleUserId: "agent-admin-user", claudeLogin: { storedSessionId: null, ownerUserId: "agent-admin-user", applyExistingWithoutClaim: false } },
|
||||
{ runnerResolved: true, aiConnectionInstall: undefined, createdByAgentId: null, interactionTexts: [expect.any(String)], createdByUserId: "agent-admin-user", responsibleUserId: "agent-admin-user", claudeLogin: { storedSessionId: null, ownerUserId: "agent-admin-user", applyExistingWithoutClaim: false } },
|
||||
);
|
||||
expect(mockAccessService.setPrincipalPermission).toHaveBeenCalledWith(
|
||||
companyId,
|
||||
@@ -1500,7 +1500,7 @@ describe("agent permission routes", () => {
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ runnerResolved: true, aiConnectionInstall: undefined, createdByUserId: "board-user", responsibleUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } },
|
||||
{ runnerResolved: true, aiConnectionInstall: undefined, createdByAgentId: null, interactionTexts: [expect.any(String)], createdByUserId: "board-user", responsibleUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1537,7 +1537,7 @@ describe("agent permission routes", () => {
|
||||
model: DEFAULT_OPENCODE_LOCAL_MODEL,
|
||||
}),
|
||||
}),
|
||||
{ runnerResolved: true, aiConnectionInstall: undefined, createdByUserId: "board-user", responsibleUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } },
|
||||
{ runnerResolved: true, aiConnectionInstall: undefined, createdByAgentId: null, interactionTexts: [expect.any(String)], createdByUserId: "board-user", responsibleUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1576,7 +1576,7 @@ describe("agent permission routes", () => {
|
||||
model: "anthropic/claude-sonnet-4-5",
|
||||
}),
|
||||
}),
|
||||
{ runnerResolved: true, aiConnectionInstall: undefined, createdByUserId: "board-user", responsibleUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } },
|
||||
{ runnerResolved: true, aiConnectionInstall: undefined, createdByAgentId: null, interactionTexts: [expect.any(String)], createdByUserId: "board-user", responsibleUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1618,6 +1618,8 @@ describe("agent permission routes", () => {
|
||||
{
|
||||
runnerResolved: true,
|
||||
aiConnectionInstall: undefined,
|
||||
createdByAgentId: null,
|
||||
interactionTexts: [expect.any(String)],
|
||||
createdByUserId: "board-user", responsibleUserId: "board-user",
|
||||
claudeLogin: {
|
||||
storedSessionId: null,
|
||||
@@ -1992,7 +1994,7 @@ describe("agent permission routes", () => {
|
||||
expect.objectContaining({
|
||||
defaultEnvironmentId: environmentId,
|
||||
}),
|
||||
{ runnerResolved: true, aiConnectionInstall: undefined, createdByUserId: "board-user", responsibleUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } },
|
||||
{ runnerResolved: true, aiConnectionInstall: undefined, createdByAgentId: null, interactionTexts: [expect.any(String)], createdByUserId: "board-user", responsibleUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2079,7 +2081,7 @@ describe("agent permission routes", () => {
|
||||
adapterType: expectedAdapterType,
|
||||
defaultEnvironmentId: environmentId,
|
||||
}),
|
||||
{ runnerResolved: true, aiConnectionInstall: undefined, createdByUserId: "board-user", responsibleUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } },
|
||||
{ runnerResolved: true, aiConnectionInstall: undefined, createdByAgentId: null, interactionTexts: [expect.any(String)], createdByUserId: "board-user", responsibleUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } },
|
||||
);
|
||||
if (["codex_local", "claude_local", "opencode_local", "pi_local"].includes(adapterCase.adapterType)) {
|
||||
if (adapterCase.adapterType === "codex_local") expect(mockAgentService.create.mock.calls[0][1].adapterConfig).toMatchObject({ provider: "codex" });
|
||||
@@ -2303,7 +2305,7 @@ describe("agent permission routes", () => {
|
||||
expect(mockAgentService.updatePermissions).toHaveBeenCalledWith(agentId, {
|
||||
canCreateAgents: false,
|
||||
canAssignTasks: true,
|
||||
});
|
||||
}, { actorAgentId: null });
|
||||
expect(res.body.permissions.canCreateSkills).toBe(false);
|
||||
});
|
||||
|
||||
@@ -2344,7 +2346,7 @@ describe("agent permission routes", () => {
|
||||
expect(mockAgentService.updatePermissions).toHaveBeenCalledWith(agentId, expect.objectContaining({
|
||||
canCreateAgents: false,
|
||||
ceoHiringDeniedBy: expect.objectContaining({ actorType: "user", actorId: "board-user", at: expect.any(String) }),
|
||||
}));
|
||||
}), { actorAgentId: null });
|
||||
|
||||
// The toggle is authoritative: turning it off also removes an explicit agents:create grant.
|
||||
expect(mockAccessService.setPrincipalPermission).toHaveBeenCalledWith(companyId, "agent", agentId, "agents:create", false, null);
|
||||
@@ -2357,7 +2359,7 @@ describe("agent permission routes", () => {
|
||||
.send({ canCreateAgents: false, canAssignTasks: true, trustPreset: "standard" }));
|
||||
expect(mockAgentService.updatePermissions).toHaveBeenCalledWith(agentId, expect.objectContaining({
|
||||
trustPreset: "standard", canCreateAgents: false, ceoHiringDeniedBy: expect.objectContaining({ actorId: "board-user" }),
|
||||
}));
|
||||
}), { actorAgentId: null });
|
||||
|
||||
// An earlier record is kept rather than restamped.
|
||||
const earlier = { actorType: "user", actorId: "first-board-user", at: "2026-11-01T00:00:00.000Z" };
|
||||
@@ -2366,7 +2368,7 @@ describe("agent permission routes", () => {
|
||||
await requestApp(app, (baseUrl) => request(baseUrl)
|
||||
.patch(`/api/agents/${agentId}/permissions`)
|
||||
.send({ canCreateAgents: false, canCreateSkills: false, canAssignTasks: true }));
|
||||
expect(mockAgentService.updatePermissions).toHaveBeenCalledWith(agentId, expect.objectContaining({ ceoHiringDeniedBy: earlier }));
|
||||
expect(mockAgentService.updatePermissions).toHaveBeenCalledWith(agentId, expect.objectContaining({ ceoHiringDeniedBy: earlier }), { actorAgentId: null });
|
||||
|
||||
// Other roles get no record.
|
||||
mockAgentService.updatePermissions.mockClear();
|
||||
@@ -2374,7 +2376,7 @@ describe("agent permission routes", () => {
|
||||
await requestApp(app, (baseUrl) => request(baseUrl)
|
||||
.patch(`/api/agents/${agentId}/permissions`)
|
||||
.send({ canCreateAgents: false, canAssignTasks: true }));
|
||||
expect(mockAgentService.updatePermissions).toHaveBeenCalledWith(agentId, { canCreateAgents: false, canAssignTasks: true });
|
||||
expect(mockAgentService.updatePermissions).toHaveBeenCalledWith(agentId, { canCreateAgents: false, canAssignTasks: true }, { actorAgentId: null });
|
||||
});
|
||||
|
||||
it("rejects CEO permission updates outside the caller company scope", async () => {
|
||||
|
||||
@@ -967,7 +967,7 @@ describe("agent skill routes", () => {
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
{ runnerResolved: true, aiConnectionInstall: undefined, createdByUserId: "local-board", responsibleUserId: "local-board", claudeLogin: { storedSessionId: null, ownerUserId: "local-board", applyExistingWithoutClaim: false } },
|
||||
{ runnerResolved: true, aiConnectionInstall: undefined, createdByAgentId: null, interactionTexts: [expect.any(String)], createdByUserId: "local-board", responsibleUserId: "local-board", claudeLogin: { storedSessionId: null, ownerUserId: "local-board", applyExistingWithoutClaim: false } },
|
||||
);
|
||||
expect(mockTrackAgentCreated).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
@@ -1019,7 +1019,7 @@ describe("agent skill routes", () => {
|
||||
expect.objectContaining({
|
||||
role: "security",
|
||||
}),
|
||||
{ runnerResolved: true, aiConnectionInstall: undefined, createdByUserId: "local-board", responsibleUserId: "local-board", claudeLogin: { storedSessionId: null, ownerUserId: "local-board", applyExistingWithoutClaim: false } },
|
||||
{ runnerResolved: true, aiConnectionInstall: undefined, createdByAgentId: null, interactionTexts: [expect.any(String)], createdByUserId: "local-board", responsibleUserId: "local-board", claudeLogin: { storedSessionId: null, ownerUserId: "local-board", applyExistingWithoutClaim: false } },
|
||||
);
|
||||
expect(mockTrackAgentCreated).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
@@ -1473,6 +1473,8 @@ describe("agent skill routes", () => {
|
||||
{
|
||||
runnerResolved: true,
|
||||
aiConnectionInstall: undefined,
|
||||
createdByAgentId: null,
|
||||
interactionTexts: [expect.any(String)],
|
||||
createdByUserId: "local-board", responsibleUserId: "local-board",
|
||||
claudeLogin: {
|
||||
storedSessionId: null,
|
||||
|
||||
@@ -78646,7 +78646,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
kind: "request_confirmation",
|
||||
status: "pending",
|
||||
effectiveResolverPolicy: "human_only",
|
||||
createdByAgentId: null,
|
||||
createdByAgentId: fixture.assignedAgentId,
|
||||
createdByUserId: null,
|
||||
});
|
||||
await db
|
||||
|
||||
@@ -1268,7 +1268,7 @@ describe("company portability routes", () => {
|
||||
expect(mockCompanyPortabilityService.importBundle).toHaveBeenCalledWith(
|
||||
{ ...importRequest, pauseAutomations: true },
|
||||
null,
|
||||
{ mode: "agent_safe", sourceCompanyId: companyId, pauseAutomations: true, assertCanCreateAgents: expect.any(Function) },
|
||||
{ mode: "agent_safe", sourceCompanyId: companyId, actorAgentId: ceoAgentId, actorAgentCompanyId: companyId, pauseAutomations: true, assertCanCreateAgents: expect.any(Function) },
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1545,7 +1545,7 @@ describe("company portability", () => {
|
||||
projectWorkspaceId: "workspace-imported",
|
||||
title: "Write launch task",
|
||||
}),
|
||||
]));
|
||||
]), undefined, undefined);
|
||||
});
|
||||
|
||||
it("normalizes invalid imported project icon names to null", async () => {
|
||||
@@ -4015,6 +4015,8 @@ describe("company portability", () => {
|
||||
labelIds: ["label-created-bug", "label-created-urgent"],
|
||||
}),
|
||||
]),
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -4073,6 +4075,8 @@ describe("company portability", () => {
|
||||
labelIds: ["target-bug", "label-created-urgent"],
|
||||
}),
|
||||
]),
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
expect(result.warnings).toContain(
|
||||
"Existing label color was kept for bug; the imported bundle used different colors.",
|
||||
@@ -4131,6 +4135,8 @@ describe("company portability", () => {
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ labelIds: [] }),
|
||||
]),
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
expect(result.warnings).toContain(
|
||||
"Task kickoff dropped 2 label references because the bundle carries raw label ids that do not exist in the target company.",
|
||||
@@ -4370,6 +4376,8 @@ describe("company portability", () => {
|
||||
sourceTrust: null,
|
||||
}),
|
||||
]),
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
expect(insertedRelationValues).toEqual([
|
||||
{
|
||||
@@ -4424,6 +4432,8 @@ describe("company portability", () => {
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ title: "Beta task" }),
|
||||
]),
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
expect(documentSvc.createIssueDocumentsForImport).not.toHaveBeenCalled();
|
||||
expect(workProductSvc.createManyForImport).not.toHaveBeenCalled();
|
||||
@@ -5283,6 +5293,8 @@ describe("company portability", () => {
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ title: "Kickoff" }),
|
||||
]),
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
expect(result.warnings).toContain(v5Warning);
|
||||
});
|
||||
@@ -5431,7 +5443,7 @@ describe("company portability", () => {
|
||||
metadata,
|
||||
createdAt: "2026-05-04T12:00:00.000Z",
|
||||
}),
|
||||
]));
|
||||
]), undefined, undefined);
|
||||
});
|
||||
|
||||
it("does not export raw comment author user ids", async () => {
|
||||
@@ -5547,7 +5559,7 @@ describe("company portability", () => {
|
||||
metadata: null,
|
||||
createdAt: "2026-05-04T12:00:00.000Z",
|
||||
}),
|
||||
]));
|
||||
]), undefined, undefined);
|
||||
expect(result.warnings).toContain(
|
||||
"Comment on task pap-1 was imported as a system comment because no importing user was available.",
|
||||
);
|
||||
|
||||
@@ -207,7 +207,15 @@ const support = externalUrl ? { supported: true } : await getEmbeddedPostgresTes
|
||||
expect(await service().recoverEffects(new Date(at.getTime() + 60_000))).toEqual({ resumed: 1 });
|
||||
vi.stubEnv("PAPERCLIP_DECISIONS_RECOVERY_GRACE_MS", "0");
|
||||
const second = await create(); await claim(second.id, at);
|
||||
expect(await service().recoverEffects(at)).toEqual({ resumed: 1 }); expect(await comments()).toHaveLength(2);
|
||||
expect(await service().recoverEffects(at)).toEqual({ resumed: 1 });
|
||||
const saved = await comments();
|
||||
expect(saved).toHaveLength(2);
|
||||
expect(saved.map(comment => comment.createdByRunId)).toEqual([runId, runId]);
|
||||
expect(saved.map(comment => comment.clientRequestId).sort()).toEqual([
|
||||
`decision-effect:${row.id}:0`, `decision-effect:${second.id}:0`,
|
||||
].sort());
|
||||
expect(await service().recoverEffects(at)).toEqual({ resumed: 0 });
|
||||
expect(await comments()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("joins active foreground effects when their recovery deadline arrives", async () => {
|
||||
|
||||
@@ -413,6 +413,29 @@ describePg("decisionService", () => {
|
||||
expect((await db.select().from(issues).where(eq(issues.id, unreviewedChildId)))[0]?.status).toBe("todo");
|
||||
});
|
||||
|
||||
it.each(["canCommentOnOtherTasks", "canReceiveCommentsFromAgents"] as const)("rejects a cancellation reason when %s is revoked before the human decision", async permission => {
|
||||
const peerId = randomUUID();
|
||||
const childId = randomUUID();
|
||||
await db.insert(agents).values({ id: peerId, companyId, name: "Recipient", adapterType: "codex_local" });
|
||||
await db.update(issues).set({ assigneeAgentId: peerId }).where(eq(issues.id, targetIssueId));
|
||||
await db.insert(issues).values({ id: childId, companyId, title: "Child", status: "todo", parentId: targetIssueId });
|
||||
const created = await service().create({
|
||||
companyId, actor: agentActor(), agentId, runId, title: "Cancel?", body: "Body",
|
||||
options: [{ id: "yes", label: "Yes", effects: [{ type: "cancel_issue_tree", targetIssueId,
|
||||
staleness: "strict", reasonComment: "Agent-authored cancellation reason" }] }],
|
||||
});
|
||||
await db.update(agents).set({ permissions: { interactions: { [permission]: false } } })
|
||||
.where(eq(agents.id, permission === "canCommentOnOtherTasks" ? agentId : peerId));
|
||||
|
||||
const result = await service().decide({ id: created.id, optionId: "yes", decidedByUserId, userActor: boardActor() });
|
||||
|
||||
expect(result.executions[0]).toMatchObject({ status: "failed",
|
||||
result: { message: expect.stringContaining(permission) } });
|
||||
expect(await db.select({ status: issues.status }).from(issues).where(inArray(issues.id, [targetIssueId, childId])))
|
||||
.toEqual([{ status: "todo" }, { status: "todo" }]);
|
||||
expect(await db.select().from(issueComments).where(eq(issueComments.issueId, targetIssueId))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("bounds cyclic issue traversal for snapshots and cancel-tree execution", async () => {
|
||||
const childId = randomUUID();
|
||||
await db.insert(issues).values({ id: childId, companyId, title: "Cycle child", status: "todo", priority: "medium",
|
||||
|
||||
@@ -9,7 +9,7 @@ function send(message) {
|
||||
|
||||
function sendNestedHostRequest(originalRequest, invocationId) {
|
||||
const nestedId = `nested-${nextRequestId++}`;
|
||||
const params = originalRequest.params?.params ?? {};
|
||||
const params = originalRequest.params?.params ?? originalRequest.params?.parameters ?? {};
|
||||
const mode = params.mode;
|
||||
const requestedCompanyId = params.requestedCompanyId;
|
||||
const hostMethod = params.hostMethod || "companies.get";
|
||||
@@ -98,13 +98,13 @@ rl.on("line", (line) => {
|
||||
id: message.id,
|
||||
result: {
|
||||
ok: true,
|
||||
supportedMethods: ["getData", "performAction", "resourceLifecycle", "workspaceOperation"],
|
||||
supportedMethods: ["getData", "performAction", "resourceLifecycle", "workspaceOperation", "executeTool"],
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (["getData", "performAction", "resourceLifecycle", "workspaceOperation"].includes(method)) {
|
||||
if (["getData", "performAction", "resourceLifecycle", "workspaceOperation", "executeTool"].includes(method)) {
|
||||
sendNestedHostRequest(message, message.paperclipInvocation?.id);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -392,24 +392,36 @@ function createRunContextDb(
|
||||
}
|
||||
return [{ id: runAgentId, companyId: runAgentCompanyId, permissions: {}, role: "engineer", reportsTo: null }];
|
||||
};
|
||||
const buildQuery = (selection: Record<string, unknown>, chatBindingQuery = false, settledRecoveryQuery = false) => {
|
||||
const buildQuery = (selection: Record<string, unknown>, chatBindingQuery = false, settledRecoveryQuery = false,
|
||||
policyTable?: "issues" | "agents") => {
|
||||
let selectedIds: string[] = [];
|
||||
const selectedRows = async () => {
|
||||
if (policyTable) {
|
||||
const getById = policyTable === "issues" ? mockIssueService.getById : mockAgentService.getById;
|
||||
return (await Promise.all(selectedIds.map(id => getById(id))))
|
||||
.filter(row => row && selectedIds.includes(row.id));
|
||||
}
|
||||
return rowsForSelection(selection, chatBindingQuery, settledRecoveryQuery);
|
||||
};
|
||||
const whereResult = {
|
||||
orderBy: vi.fn(async () => []),
|
||||
orderBy: vi.fn((): unknown => policyTable ? whereResult : Promise.resolve([])),
|
||||
limit: vi.fn(() => ({
|
||||
then: async (resolve: (limitedRows: unknown[]) => unknown) => resolve(await rowsForSelection(selection, chatBindingQuery, settledRecoveryQuery)),
|
||||
then: async (resolve: (limitedRows: unknown[]) => unknown) => resolve(await selectedRows()),
|
||||
for: vi.fn(() => ({
|
||||
then: async (resolve: (lockedRows: unknown[]) => unknown) => resolve(await rowsForSelection(selection, chatBindingQuery, settledRecoveryQuery)),
|
||||
then: async (resolve: (lockedRows: unknown[]) => unknown) => resolve(await selectedRows()),
|
||||
})),
|
||||
})),
|
||||
for: vi.fn(() => ({
|
||||
then: async (resolve: (selectedRows: unknown[]) => unknown) => resolve(await rowsForSelection(selection, chatBindingQuery, settledRecoveryQuery)),
|
||||
then: async (resolve: (rows: unknown[]) => unknown) => resolve(await selectedRows()),
|
||||
})),
|
||||
then: async (resolve: (selectedRows: unknown[]) => unknown) => resolve(await rowsForSelection(selection, chatBindingQuery, settledRecoveryQuery)),
|
||||
then: async (resolve: (rows: unknown[]) => unknown) => resolve(await selectedRows()),
|
||||
};
|
||||
const query = {
|
||||
innerJoin: vi.fn(() => query),
|
||||
where: vi.fn((condition: SQL) => {
|
||||
if (chatBindingQuery) chatBindingQueries.push(new PgDialect().sqlToQuery(condition));
|
||||
if (policyTable) selectedIds = [...new Set(new PgDialect().sqlToQuery(condition).params
|
||||
.filter((value): value is string => typeof value === "string" && value !== companyId))];
|
||||
return whereResult;
|
||||
}),
|
||||
};
|
||||
@@ -438,6 +450,12 @@ function createRunContextDb(
|
||||
if (getTableName(table) === "issue_thread_interactions") {
|
||||
return { where: vi.fn(() => ({ limit: vi.fn(async () => []) })) };
|
||||
}
|
||||
if (getTableName(table) === "issues" && "conversationAgentId" in selection) {
|
||||
return buildQuery(selection, false, false, "issues");
|
||||
}
|
||||
if (getTableName(table) === "agents" && Object.keys(selection).sort().join(",") === "companyId,id,permissions") {
|
||||
return buildQuery(selection, false, false, "agents");
|
||||
}
|
||||
return buildQuery(selection, getTableName(table) === "chat_conversations", getTableName(table) === "issue_recovery_actions");
|
||||
}),
|
||||
})),
|
||||
@@ -1254,6 +1272,7 @@ describe("agent issue mutation checkout ownership", () => {
|
||||
changedFiles: 3,
|
||||
}),
|
||||
}),
|
||||
ownerAgentId,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1283,6 +1302,7 @@ describe("agent issue mutation checkout ownership", () => {
|
||||
expect.objectContaining({
|
||||
metadata: expect.objectContaining({ additions: 11, deletions: 3, changedFiles: 2 }),
|
||||
}),
|
||||
ownerAgentId,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1707,7 +1727,7 @@ describe("agent issue mutation checkout ownership", () => {
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(mockDocumentService.upsertIssueDocument).toHaveBeenCalled();
|
||||
expect(mockWorkProductService.update).toHaveBeenCalledWith("product-1", { title: "Updated product" });
|
||||
expect(mockWorkProductService.update).toHaveBeenCalledWith("product-1", { title: "Updated product" }, ownerAgentId);
|
||||
});
|
||||
|
||||
it("preserves board mutations on active checkouts", async () => {
|
||||
@@ -3482,6 +3502,7 @@ describe("agent issue mutation checkout ownership", () => {
|
||||
ancestryParentId?: string | null;
|
||||
watchdogRows?: Record<string, unknown>[];
|
||||
} = {}) {
|
||||
const policyDb = createRunContextDb();
|
||||
const watchedIssueId = options.watchedIssueId ?? issueId;
|
||||
const runRows = [{
|
||||
id: watchdogRunId,
|
||||
@@ -3530,8 +3551,13 @@ describe("agent issue mutation checkout ownership", () => {
|
||||
return {
|
||||
transaction: async (callback: (tx: Record<string, never>) => Promise<unknown>) => callback({}),
|
||||
select: vi.fn((selection: Record<string, unknown> = {}) => ({
|
||||
from: vi.fn((table: Parameters<typeof getTableName>[0]) => getTableName(table) === "case_handoffs"
|
||||
? emptyCaseHandoffQuery() : buildQuery(selection)),
|
||||
from: vi.fn((table: Parameters<typeof getTableName>[0]) => {
|
||||
if ((getTableName(table) === "issues" && "conversationAgentId" in selection)
|
||||
|| (getTableName(table) === "agents" && Object.keys(selection).sort().join(",") === "companyId,id,permissions")) {
|
||||
return policyDb.select(selection).from(table);
|
||||
}
|
||||
return getTableName(table) === "case_handoffs" ? emptyCaseHandoffQuery() : buildQuery(selection);
|
||||
}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { getTableName } from "drizzle-orm";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const AGENT_ACTOR_ID = "11111111-1111-4111-8111-111111111111";
|
||||
@@ -156,15 +157,28 @@ function agentActor(): Actor {
|
||||
};
|
||||
}
|
||||
|
||||
// Minimal chainable/thenable db stub: any query resolves to an empty row set.
|
||||
// Run containment is mocked because this suite targets assignee invokability.
|
||||
function stubDb(): any {
|
||||
const query: any = {};
|
||||
for (const method of ["select", "from", "where", "innerJoin", "leftJoin", "orderBy", "limit", "groupBy", "for"]) {
|
||||
query[method] = () => query;
|
||||
}
|
||||
query.then = (resolve: (rows: unknown[]) => unknown) => Promise.resolve(resolve([]));
|
||||
return { select: () => query };
|
||||
return { select: () => ({ from: (table: Parameters<typeof getTableName>[0]) => {
|
||||
const name = getTableName(table);
|
||||
if (name !== "issues" && name !== "agents") return query;
|
||||
const rows = async () => name === "issues"
|
||||
? [await mockIssueService.getById()].filter(Boolean)
|
||||
: Object.entries(agentStatusById).map(([id, status]) => ({ id, status, companyId: "company-1", permissions: null }));
|
||||
const result = {
|
||||
where: () => result,
|
||||
orderBy: () => result,
|
||||
limit: () => result,
|
||||
for: () => result,
|
||||
then: (resolve: (rows: unknown[]) => unknown, reject?: (reason: unknown) => unknown) =>
|
||||
rows().then(resolve, reject),
|
||||
};
|
||||
return result;
|
||||
} }) };
|
||||
}
|
||||
|
||||
function createApp(actor: Actor) {
|
||||
@@ -255,7 +269,7 @@ describe("issue assignee invokability guard", () => {
|
||||
.patch(`/api/issues/${existing.id}`)
|
||||
.send({ assigneeAgentId: IDLE_AGENT_ID });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockIssueService.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -269,7 +283,7 @@ describe("issue assignee invokability guard", () => {
|
||||
.patch(`/api/issues/${existing.id}`)
|
||||
.send({ assigneeAgentId: PAUSED_AGENT_ID });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockIssueService.update).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -785,6 +785,7 @@ describe("issue attachment routes", () => {
|
||||
originalFilename: "clip.mp4",
|
||||
},
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -878,6 +879,7 @@ describe("issue attachment routes", () => {
|
||||
originalFilename: "clip.webm",
|
||||
},
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { getTableName } from "drizzle-orm";
|
||||
import { getTableName, type SQL } from "drizzle-orm";
|
||||
import { PgDialect } from "drizzle-orm/pg-core";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { HttpError, unprocessable } from "../errors.js";
|
||||
import { normalizeIssueExecutionPolicy } from "../services/issue-execution-policy.js";
|
||||
@@ -455,6 +456,20 @@ describe("issue comment reopen routes", () => {
|
||||
}));
|
||||
mockDbSelectFrom.mockImplementation((table) => {
|
||||
const name = getTableName(table as Parameters<typeof getTableName>[0]);
|
||||
if (name === "issues" || name === "agents") return { where: (condition: SQL) => {
|
||||
const ids = [...new Set(new PgDialect().sqlToQuery(condition).params
|
||||
.filter((value): value is string => typeof value === "string" && value !== "company-1"))];
|
||||
const rows = async () => name === "issues"
|
||||
? (await Promise.all(ids.map(id => mockIssueService.getById(id)))).filter(row => row && ids.includes(row.id))
|
||||
: ids.map(id => ({ id, companyId: "company-1", permissions: null }));
|
||||
const query = {
|
||||
orderBy: () => query,
|
||||
for: () => query,
|
||||
then: (resolve: (rows: unknown[]) => unknown, reject?: (reason: unknown) => unknown) =>
|
||||
rows().then(resolve, reject),
|
||||
};
|
||||
return query;
|
||||
} };
|
||||
if (name === "case_handoffs") return { where: vi.fn(() => ({
|
||||
orderBy: vi.fn(() => ({ limit: vi.fn(async () => []) })),
|
||||
})) };
|
||||
@@ -1231,6 +1246,7 @@ describe("issue comment reopen routes", () => {
|
||||
assigneeAgentId: otherAgentId,
|
||||
status: "todo",
|
||||
}),
|
||||
mockTx,
|
||||
);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
@@ -2408,6 +2424,7 @@ describe("issue comment reopen routes", () => {
|
||||
actorAgentId: "22222222-2222-4222-8222-222222222222",
|
||||
actorUserId: null,
|
||||
}),
|
||||
mockTx,
|
||||
);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { getTableName, type SQL } from "drizzle-orm";
|
||||
import { PgDialect } from "drizzle-orm/pg-core";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { normalizeIssueExecutionPolicy } from "../services/issue-execution-policy.ts";
|
||||
|
||||
@@ -51,7 +53,7 @@ const mockDbSelectWhere = vi.hoisted(() => vi.fn(() => ({
|
||||
permissions: null,
|
||||
}]).then(onFulfilled, onRejected),
|
||||
})));
|
||||
const mockDbSelectFrom = vi.hoisted(() => vi.fn(() => ({ where: mockDbSelectWhere })));
|
||||
const mockDbSelectFrom = vi.hoisted(() => vi.fn((_table: unknown): { where: (...args: never[]) => unknown } => ({ where: mockDbSelectWhere })));
|
||||
const mockDbSelect = vi.hoisted(() => vi.fn(() => ({ from: mockDbSelectFrom })));
|
||||
const mockDb = vi.hoisted(() => ({
|
||||
select: mockDbSelect,
|
||||
@@ -221,7 +223,24 @@ describe("issue execution policy routes", () => {
|
||||
mockIssueThreadInteractionService.expireRequestConfirmationsSupersededByComment.mockResolvedValue([]);
|
||||
mockIssueApprovalService.listApprovalsForIssue.mockResolvedValue([]);
|
||||
mockDbSelect.mockImplementation(() => ({ from: mockDbSelectFrom }));
|
||||
mockDbSelectFrom.mockImplementation(() => ({ where: mockDbSelectWhere }));
|
||||
mockDbSelectFrom.mockImplementation((table) => {
|
||||
const name = getTableName(table as Parameters<typeof getTableName>[0]);
|
||||
if (name !== "issues" && name !== "agents") return { where: mockDbSelectWhere };
|
||||
return { where: (condition: SQL) => {
|
||||
const ids = [...new Set(new PgDialect().sqlToQuery(condition).params
|
||||
.filter((value): value is string => typeof value === "string" && value !== "company-1"))];
|
||||
const rows = async () => name === "issues"
|
||||
? (await Promise.all(ids.map(id => mockIssueService.getById(id)))).filter(row => row && ids.includes(row.id))
|
||||
: ids.map(id => ({ id, companyId: "company-1", permissions: null }));
|
||||
const query = {
|
||||
orderBy: () => query,
|
||||
for: () => query,
|
||||
then: (resolve: (rows: unknown[]) => unknown, reject?: (reason: unknown) => unknown) =>
|
||||
rows().then(resolve, reject),
|
||||
};
|
||||
return query;
|
||||
} };
|
||||
});
|
||||
mockDbSelectWhere.mockImplementation(() => ({
|
||||
for: () => ({
|
||||
then: (onFulfilled: (rows: unknown[]) => unknown, onRejected?: (reason: unknown) => unknown) =>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { getTableName } from "drizzle-orm";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockIssueService = vi.hoisted(() => ({
|
||||
@@ -16,27 +17,7 @@ const mockAgentService = vi.hoisted(() => ({
|
||||
|
||||
const mockTrackAgentTaskCompleted = vi.hoisted(() => vi.fn());
|
||||
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
|
||||
const mockDbSelectWhere = vi.hoisted(() => vi.fn(() => ({
|
||||
for: () => ({
|
||||
then: (onFulfilled: (rows: unknown[]) => unknown, onRejected?: (reason: unknown) => unknown) =>
|
||||
Promise.resolve([{
|
||||
id: "22222222-2222-4222-8222-222222222222",
|
||||
companyId: "company-1",
|
||||
agentId: "agent-1",
|
||||
contextSnapshot: { issueId: "11111111-1111-4111-8111-111111111111" },
|
||||
permissions: null,
|
||||
}]).then(onFulfilled, onRejected),
|
||||
}),
|
||||
then: (onFulfilled: (rows: unknown[]) => unknown, onRejected?: (reason: unknown) => unknown) =>
|
||||
Promise.resolve([{
|
||||
id: "22222222-2222-4222-8222-222222222222",
|
||||
companyId: "company-1",
|
||||
agentId: "agent-1",
|
||||
contextSnapshot: { issueId: "11111111-1111-4111-8111-111111111111" },
|
||||
permissions: null,
|
||||
}]).then(onFulfilled, onRejected),
|
||||
})));
|
||||
const mockDbSelectFrom = vi.hoisted(() => vi.fn(() => ({ where: mockDbSelectWhere })));
|
||||
const mockDbSelectFrom = vi.hoisted(() => vi.fn());
|
||||
const mockDbSelect = vi.hoisted(() => vi.fn(() => ({ from: mockDbSelectFrom })));
|
||||
const mockDb = vi.hoisted(() => ({
|
||||
select: mockDbSelect,
|
||||
@@ -180,27 +161,25 @@ describe("issue telemetry routes", () => {
|
||||
...patch,
|
||||
}));
|
||||
mockDbSelect.mockImplementation(() => ({ from: mockDbSelectFrom }));
|
||||
mockDbSelectFrom.mockImplementation(() => ({ where: mockDbSelectWhere }));
|
||||
mockDbSelectWhere.mockImplementation(() => ({
|
||||
for: () => ({
|
||||
then: (onFulfilled: (rows: unknown[]) => unknown, onRejected?: (reason: unknown) => unknown) =>
|
||||
Promise.resolve([{
|
||||
id: "22222222-2222-4222-8222-222222222222",
|
||||
companyId: "company-1",
|
||||
agentId: "agent-1",
|
||||
contextSnapshot: { issueId: "11111111-1111-4111-8111-111111111111" },
|
||||
permissions: null,
|
||||
}]).then(onFulfilled, onRejected),
|
||||
}),
|
||||
then: (onFulfilled: (rows: unknown[]) => unknown, onRejected?: (reason: unknown) => unknown) =>
|
||||
Promise.resolve([{
|
||||
mockDbSelectFrom.mockImplementation((table: Parameters<typeof getTableName>[0]) => {
|
||||
const rows = getTableName(table) === "issues" ? [makeIssue("todo")]
|
||||
: getTableName(table) === "agents" ? [{ id: "agent-1", companyId: "company-1", permissions: null }]
|
||||
: [{
|
||||
id: "22222222-2222-4222-8222-222222222222",
|
||||
companyId: "company-1",
|
||||
agentId: "agent-1",
|
||||
contextSnapshot: { issueId: "11111111-1111-4111-8111-111111111111" },
|
||||
permissions: null,
|
||||
}]).then(onFulfilled, onRejected),
|
||||
}));
|
||||
}];
|
||||
const query = {
|
||||
where: () => query,
|
||||
orderBy: () => query,
|
||||
for: () => query,
|
||||
then: (resolve: (rows: unknown[]) => unknown, reject?: (reason: unknown) => unknown) =>
|
||||
Promise.resolve(rows).then(resolve, reject),
|
||||
};
|
||||
return query;
|
||||
});
|
||||
// Keep cold route imports in setup rather than the HTTP assertion timeout.
|
||||
await loadAppModules();
|
||||
}, 60_000);
|
||||
|
||||
@@ -2313,6 +2313,19 @@ describe("P6-31 Section 18.13 executable status-authority corpus", () => {
|
||||
return { promise, release };
|
||||
}
|
||||
|
||||
it.each(["legacy", "source agent", "different agent"] as const)("retires only proven fallback reviews attributed to %s", async attribution => {
|
||||
const created = await seedReview();
|
||||
const creator = attribution === "legacy" ? null : attribution === "source agent" ? agentId : outsideAgentId;
|
||||
await db.update(issueThreadInteractions).set({ createdByAgentId: creator })
|
||||
.where(eq(issueThreadInteractions.id, created.interaction.id));
|
||||
await start(); await flush();
|
||||
expect(await card(created.interaction.id)).toMatchObject({
|
||||
createdByAgentId: creator,
|
||||
status: attribution === "different agent" ? "pending" : "cancelled",
|
||||
});
|
||||
await quiet();
|
||||
});
|
||||
|
||||
it("leaves genuine pending reviews and completed history quiet", async () => {
|
||||
const genuine = await seedReview({ genuine: true });
|
||||
const settled = await seedReview();
|
||||
|
||||
@@ -24,6 +24,7 @@ const apiPrefixes: Record<string, string> = {
|
||||
"agents.ts": "/api",
|
||||
"agent-project-access.ts": "/api",
|
||||
"agent-permission-grants.ts": "/api",
|
||||
"agent-interaction-permissions.ts": "/api",
|
||||
"agent-commentary.ts": "/api",
|
||||
"agent-chat-messages.ts": "/api",
|
||||
"agent-avatars.ts": "/api",
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { withPluginInteractionOrigin } from "../services/plugin-interaction-context.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
activityLog,
|
||||
agentWakeupRequests,
|
||||
agents,
|
||||
companies,
|
||||
companyMemberships,
|
||||
@@ -64,6 +66,21 @@ describeEmbeddedPostgres("plugin access and authorization host services", () =>
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
it("keeps agent tool invocation origin when a plugin tries to invoke a protected peer", async () => {
|
||||
const company = await createCompany(db, "PIO");
|
||||
const [sender, recipient] = await db.insert(agents).values([
|
||||
{ companyId: company.id, name: "Sender", adapterType: "process" },
|
||||
{ companyId: company.id, name: "Protected", adapterType: "process", permissions: { interactions: { canReceiveAssignmentsFromAgents: false } } },
|
||||
]).returning();
|
||||
const services = buildHostServices(db, pluginId, "permissions-extension", createEventBusStub());
|
||||
try {
|
||||
await expect(withPluginInteractionOrigin({ companyId: company.id, agentId: sender!.id, runId: randomUUID() }, () =>
|
||||
services.agents.invoke({ companyId: company.id, agentId: recipient!.id, prompt: "Start work" })))
|
||||
.rejects.toMatchObject({ status: 403, details: { permission: "canReceiveAssignmentsFromAgents", actorAgentId: sender!.id } });
|
||||
expect(await db.select().from(agentWakeupRequests)).toEqual([]);
|
||||
} finally { services.dispose(); }
|
||||
});
|
||||
|
||||
it("rejects grant writes for principals outside the requested company", async () => {
|
||||
const targetCompany = await createCompany(db, "PAX");
|
||||
const otherCompany = await createCompany(db, "PAY");
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { currentPluginInteractionOrigin } from "../services/plugin-interaction-context.js";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
@@ -380,13 +381,40 @@ describe("plugin-worker-manager stderr failure context", () => {
|
||||
});
|
||||
expect(companiesGet).toHaveBeenCalledWith(
|
||||
{ companyId: "company-a" },
|
||||
{ invocationScope: { companyId: "company-a" }, traceparent: undefined, providerSpanBudget: { remaining: 256 } },
|
||||
{ invocationScope: { companyId: "company-a", agentOrigin: { agentId: "agent-1", runId: "run-1" } }, traceparent: undefined, providerSpanBudget: { remaining: 256 } },
|
||||
);
|
||||
} finally {
|
||||
await handle.stop().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it("binds nested host operations to the initiating agent and refuses proactive fallback during its invocation", async () => {
|
||||
const companiesGet = vi.fn(async () => {
|
||||
await Promise.resolve();
|
||||
return currentPluginInteractionOrigin();
|
||||
});
|
||||
const handle = createPluginWorkerHandle("test.plugin", {
|
||||
entrypointPath: INVOCATION_SCOPE_WORKER_ENTRYPOINT, manifest: TEST_MANIFEST, config: {},
|
||||
instanceInfo: { instanceId: "instance-1", hostVersion: "1.0.0" }, apiVersion: 1,
|
||||
proactiveCompanyScopes: ["company-a"], hostHandlers: { "companies.get": companiesGet as never },
|
||||
});
|
||||
try {
|
||||
await handle.start();
|
||||
const input = { toolName: "probe", parameters: { mode: "echo", requestedCompanyId: "company-a" },
|
||||
runContext: { companyId: "company-a", agentId: "agent-a", runId: "run-a" } };
|
||||
await expect(handle.call("executeTool", input as HostToWorkerMethods["executeTool"][0])).resolves.toEqual({
|
||||
companyId: "company-a", agentId: "agent-a", runId: "run-a",
|
||||
});
|
||||
expect(currentPluginInteractionOrigin()).toBeUndefined();
|
||||
const calls = companiesGet.mock.calls.length;
|
||||
await expect(handle.call("executeTool", { ...input, parameters: { ...input.parameters, mode: "missing" } } as HostToWorkerMethods["executeTool"][0]))
|
||||
.rejects.toMatchObject({ code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED });
|
||||
await expect(handle.call("executeTool", { ...input, parameters: { ...input.parameters, mode: "unknown" } } as HostToWorkerMethods["executeTool"][0]))
|
||||
.rejects.toMatchObject({ code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED });
|
||||
expect(companiesGet).toHaveBeenCalledTimes(calls);
|
||||
} finally { await handle.stop().catch(() => undefined); }
|
||||
});
|
||||
|
||||
it("passes echoed invocation scope to worker-to-host handlers", async () => {
|
||||
const companiesGet = vi.fn(async () => ({ id: "company-1" }) as Company);
|
||||
const handle = createPluginWorkerHandle("test.plugin", {
|
||||
|
||||
@@ -239,6 +239,18 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
|
||||
return { companyId, agentId, issueSvc, projectId, routine, svc, wakeups };
|
||||
}
|
||||
|
||||
it("checks the author of an agent-edited routine revision when dispatching later work", async () => {
|
||||
const f = await seedFixture();
|
||||
const editorId = randomUUID();
|
||||
await db.insert(agents).values({ id: editorId, companyId: f.companyId, name: "Editor", status: "active", adapterType: "process" });
|
||||
await f.svc.update(f.routine.id, { description: "Agent-authored instructions" }, { agentId: editorId });
|
||||
await db.update(agents).set({ permissions: { interactions: { canAssignTasksToAgents: false } } }).where(eq(agents.id, editorId));
|
||||
await expect(f.svc.runRoutine(f.routine.id, { source: "api" }, {})).rejects.toMatchObject({ status: 403,
|
||||
details: expect.objectContaining({ permission: "canAssignTasksToAgents", actorAgentId: editorId }) });
|
||||
expect(await db.select().from(routineRuns).where(eq(routineRuns.routineId, f.routine.id))).toEqual([]);
|
||||
expect(f.wakeups).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves creation ancestry through regular webhooks, child tasks, and standalone follow-ups", async () => {
|
||||
vi.stubEnv("PAPERCLIP_AGENT_JWT_SECRET", randomUUID().repeat(2));
|
||||
const f = await seedFixture();
|
||||
|
||||
@@ -106,6 +106,7 @@ import { summarySlotRoutes } from "./routes/summary-slots.js";
|
||||
import { statusCardRoutes } from "./routes/status-cards.js";
|
||||
import { teamsCatalogRoutes } from "./routes/teams-catalog.js";
|
||||
import { agentRoutes } from "./routes/agents.js";
|
||||
import { agentInteractionPermissionRoutes } from "./routes/agent-interaction-permissions.js";
|
||||
import { agentPermissionGrantRoutes } from "./routes/agent-permission-grants.js";
|
||||
import { resolveAgentRunnerTargetForCompany } from "./services/agent-runner-selection.js";
|
||||
import type { SetupTokenSessionService } from "./services/setup-token-session.js";
|
||||
@@ -797,6 +798,7 @@ export async function createApp(
|
||||
}),
|
||||
);
|
||||
api.use(agentPermissionGrantRoutes(db));
|
||||
api.use(agentInteractionPermissionRoutes(db));
|
||||
api.use(assetRoutes(db, opts.storageService));
|
||||
api.use(agentProfileAvatarRoutes(db, opts.storageService));
|
||||
api.use(projectToolRoutes(db));
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { forbidden } from "../errors.js";
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
const message = "Use PATCH /api/agents/:id/interaction-permissions to change interaction permissions";
|
||||
|
||||
export function preserveAgentInteractionPermissions(existing: unknown, incoming: unknown): Record<string, unknown> {
|
||||
const previous = record(existing), next = { ...record(incoming) };
|
||||
if (Object.hasOwn(next, "interactions") && !isDeepStrictEqual(next.interactions, previous.interactions)) {
|
||||
throw forbidden(message, { code: "agent_interaction_permissions_separate_endpoint" });
|
||||
}
|
||||
if (Object.hasOwn(previous, "interactions")) next.interactions = previous.interactions;
|
||||
else delete next.interactions;
|
||||
return next;
|
||||
}
|
||||
|
||||
export function assertNoInitialAgentInteractionPermissions(permissions: unknown) {
|
||||
if (Object.hasOwn(record(permissions), "interactions")) {
|
||||
throw forbidden(message, { code: "agent_interaction_permissions_separate_endpoint" });
|
||||
}
|
||||
}
|
||||
@@ -89,6 +89,9 @@ export type AgentConfigurationPatch = Partial<Pick<typeof agents.$inferInsert, t
|
||||
export type AgentHireRecord = Pick<typeof agents.$inferSelect, "id" | "companyId" | "adapterType" | "adapterConfig" | "permissions" | "metadata" | "status">;
|
||||
|
||||
export interface CreateAgentOptions {
|
||||
createdByAgentId?: string | null;
|
||||
createdByAgentCompanyId?: string;
|
||||
interactionTexts?: readonly string[];
|
||||
initialBudgetPolicyUserId?: string;
|
||||
setupOnly?: boolean;
|
||||
setupAgentId?: string;
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { LifecycleEffects } from "./effects.js";
|
||||
export async function updateAgentConfiguration(tx: Db, effects: LifecycleEffects, id: string, data: AgentConfigurationPatch, options?: UpdateAgentOptions, publications: ActivityPublication[] = []) {
|
||||
if (!("nestedIndex" in tx)) throw new AgentLifecycleConflict("Configuration integration requires a database transaction");
|
||||
assertConfigurationFields(data);
|
||||
options = await effects.lockConfigurationInteractions(tx, id, data, options);
|
||||
const [current] = await tx.select().from(agents).where(eq(agents.id, id)).for("no key update");
|
||||
if (!current) return null;
|
||||
if (options?.expectedConfigurationRevision && options.expectedConfigurationRevision !== agentConfigurationRevision(current)) {
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface LifecycleEffects {
|
||||
beforeConnectionEligibilityRestore?(db: Db, companyId: string, agentId: string): Promise<void>;
|
||||
reportFailure(context: LifecycleFailure, error: unknown): void;
|
||||
deleteDependencies(db: Db, companyId: string, agentId: string): Promise<void>;
|
||||
lockConfigurationInteractions(db: Db, id: string, data: AgentConfigurationPatch, options?: UpdateAgentOptions): Promise<UpdateAgentOptions | undefined>;
|
||||
prepareConfiguration(db: Db, current: AgentConfigurationRecord, data: AgentConfigurationPatch, options?: UpdateAgentOptions): Promise<AgentConfigurationPatch>;
|
||||
completeConfiguration(db: Db, before: AgentConfigurationRecord, after: AgentConfigurationRecord, patch: AgentConfigurationPatch, options: UpdateAgentOptions | undefined, publications: ActivityPublication[]): Promise<void>;
|
||||
transaction<T>(db: Db, companyId: string, work: (tx: Db, publications: ActivityPublication[]) => Promise<T>): Promise<T>;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { preserveAgentInteractionPermissions } from "../../../lib/agent-interaction-permissions.js";
|
||||
import type { LifecycleEffects } from "./effects.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
@@ -43,6 +44,7 @@ export function agentRecords(db: Db, effects: LifecycleEffects) {
|
||||
const createdStatus=status === "pending_approval" ? "pending_approval" : status === "terminated" ? "terminated" : "paused";
|
||||
const values: typeof agents.$inferInsert = {
|
||||
...prepared,
|
||||
...(setup ? { permissions: preserveAgentInteractionPermissions(setup.permissions, prepared.permissions) } : {}),
|
||||
createdByUserId: setup?.createdByUserId ?? options?.createdByUserId ?? null,
|
||||
lifecycleState,
|
||||
lifecycleVersion: setup ? setup.lifecycleVersion + 1 : 1,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ActivityPublication } from "../../types/activity-publication.js";
|
||||
import { AgentLifecycleConflict } from "./domain/policy.js";
|
||||
import { hireApprovalService as approvalRecords, type HireDecisionTarget } from "./adapters/approvals.js";
|
||||
import { agents, companies } from "@paperclipai/db";
|
||||
import { and, asc, desc, eq, gt, inArray, lte, sql } from "drizzle-orm";
|
||||
@@ -43,6 +45,24 @@ export function processAgentLifecycle(db: Db, id: string): Promise<void> {
|
||||
return workers.get(db)?.process(id) ?? Promise.resolve();
|
||||
}
|
||||
|
||||
export function initializeImportedAgentConfigurationInTransaction(tx: Db, effects: LifecycleEffects, publications: ActivityPublication[],
|
||||
id: string, data: Pick<AgentConfigurationPatch, "adapterConfig" | "reportsTo">) {
|
||||
if (!("nestedIndex" in tx)) throw new AgentLifecycleConflict("Imported configuration requires a database transaction");
|
||||
if (Object.keys(data).some(key => key !== "adapterConfig" && key !== "reportsTo")) throw new AgentLifecycleConflict("Only initial imported instructions and reporting links can be initialized");
|
||||
return updateAgentConfiguration(tx, effects, id, data, undefined, publications);
|
||||
}
|
||||
|
||||
export async function requestAgentHireInTransaction(tx: Db, effects: LifecycleEffects, publications: ActivityPublication[],
|
||||
...args: Parameters<ReturnType<typeof agentRecords>["create"]>) {
|
||||
if (!("nestedIndex" in tx)) throw new AgentLifecycleConflict("Imported hires require a database transaction");
|
||||
const records = agentRecords(tx, { ...effects, transaction: async (database, companyId, work) => {
|
||||
const [company] = await database.select({ id: companies.id }).from(companies).where(eq(companies.id, companyId)).for("no key update");
|
||||
if (!company) throw new AgentLifecycleConflict("Import company is unavailable");
|
||||
return work(database, publications);
|
||||
} });
|
||||
return records.create(...args);
|
||||
}
|
||||
|
||||
export function createAgentLifecycle(db: Db, effects: LifecycleEffects) {
|
||||
assertRootDatabase(db);
|
||||
const records = agentRecords(db, effects);
|
||||
|
||||
@@ -11,6 +11,7 @@ import { resolveExternalTestDatabaseUrl } from "../../../__tests__/helpers/exter
|
||||
import { createQueuedCommentIssueLockWriter } from "./queued-comment-postgres.js";
|
||||
import type { QueuedCommentQueuePostgresAdapterDeps } from "./queued-comment-postgres.js";
|
||||
import { QueuedCommentMutationError } from "../application/queued-comment-use-cases.js";
|
||||
import { buildAgentMentionHref } from "@paperclipai/shared";
|
||||
|
||||
// Proves the same two properties the release-half adapter test proves for
|
||||
// this module's other transaction: the one company the caller names in
|
||||
@@ -138,6 +139,30 @@ describeEmbeddedPostgres("queued-comment postgres adapter", () => {
|
||||
return id;
|
||||
}
|
||||
|
||||
it("checks the authenticated editor and changed mentions atomically before updating a queued comment", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const recipient = await seedAgent({ companyId }), sender = await seedAgent({ companyId });
|
||||
const issueId = await seedIssue({ companyId, assigneeAgentId: recipient });
|
||||
const commentId = await seedComment({ companyId, issueId, authorUserId: "user-1" });
|
||||
const wakeId = await seedDeferredWake({ companyId, agentId: recipient, issueId, commentIds: [commentId] });
|
||||
const issueLock = createQueuedCommentIssueLockWriter(db, noopDeps);
|
||||
const input = { issue: { id: issueId, companyId, assigneeAgentId: recipient, executionRunId: null },
|
||||
actor: { actorType: "agent" as const, actorId: sender, agentId: sender, runId: null, agentApiKeyId: null }, queueId: wakeId };
|
||||
await db.update(agents).set({ permissions: { interactions: { canMentionAgents: false } } }).where(eq(agents.id, sender));
|
||||
await expect(issueLock.withLockedQueue(input, async (_locked, tx) => tx.updateCommentBody({ issueId, commentId,
|
||||
body: `[@Recipient](${buildAgentMentionHref(recipient)})`, updatedAt: new Date() }))).rejects.toMatchObject({ status: 403,
|
||||
details: expect.objectContaining({ permission: "canMentionAgents", actorAgentId: sender }) });
|
||||
await db.update(agents).set({ permissions: { interactions: { canReceiveCommentsFromAgents: false } } }).where(eq(agents.id, recipient));
|
||||
await expect(issueLock.withLockedQueue(input, async (_locked, tx) => tx.deleteComment({ issueId, commentId })))
|
||||
.rejects.toMatchObject({ status: 403, details: expect.objectContaining({ permission: "canReceiveCommentsFromAgents" }) });
|
||||
expect((await db.select().from(issueComments).where(eq(issueComments.id, commentId)))[0]?.body).toBe("queued message");
|
||||
expect((await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, wakeId)))[0]?.payload)
|
||||
.toMatchObject({ _paperclipWakeContext: { wakeCommentIds: [commentId] } });
|
||||
await issueLock.withLockedQueue({ ...input, actor: { ...input.actor, actorType: "user", actorId: "user-1", agentId: null } },
|
||||
async (_locked, tx) => tx.updateCommentBody({ issueId, commentId, body: "Human edit", updatedAt: new Date() }));
|
||||
expect((await db.select().from(issueComments).where(eq(issueComments.id, commentId)))[0]?.body).toBe("Human edit");
|
||||
});
|
||||
|
||||
it("scopes the wake lookup to its own company: a foreign-company issue context resolves not_pending and deletes nothing", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const otherCompanyId = await seedCompany();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { notifyStrandedAgentStatus } from "../../../services/live-run-recovery-notifications.js";
|
||||
import { assertAgentInteraction } from "../../../services/agent-interaction-policy.js";
|
||||
import { notifyExecutionRunWork } from "../../../services/execution-control-notifications.js";
|
||||
import { notifyStatusCardIssueChange } from "../../../services/status-card-notifications.js";
|
||||
import { notifyExternalAgentSettlement } from "../../external-agents/index.js";
|
||||
@@ -26,6 +27,7 @@ import { parseObject, readNonEmptyString } from "../domain/values.js";
|
||||
import { QueuedCommentMutationError } from "../application/queued-comment-use-cases.js";
|
||||
import type {
|
||||
LockedQueuedCommentState,
|
||||
QueuedCommentActor,
|
||||
QueuedCommentActivityLogInput,
|
||||
QueuedCommentActivityPublication,
|
||||
QueuedCommentIssueLockWriter,
|
||||
@@ -56,13 +58,18 @@ export type QueuedCommentQueuePostgresAdapterDeps = {
|
||||
syncCommentExternalObjectsSafely(commentId: string, tx: Db): Promise<void>;
|
||||
};
|
||||
|
||||
function buildTransaction(tx: Db, companyId: string, issueId: string, deps: QueuedCommentQueuePostgresAdapterDeps, wakeAgentId: string): QueuedCommentQueueTransaction {
|
||||
function buildTransaction(tx: Db, companyId: string, issueId: string, deps: QueuedCommentQueuePostgresAdapterDeps, wakeAgentId: string, actor: QueuedCommentActor): QueuedCommentQueueTransaction {
|
||||
// Messages the active run took into its first turn leave the visible queue
|
||||
// but stay on the wake until that turn is accepted, so a run that fails
|
||||
// before then still leaves them queued. Queue edits keep them.
|
||||
let heldByFirstTurn: string[] = [];
|
||||
return {
|
||||
async updateCommentBody({ issueId, commentId, body, updatedAt }) {
|
||||
const [existing] = await tx.select({ body: issueComments.body }).from(issueComments).where(and(
|
||||
eq(issueComments.id, commentId), eq(issueComments.issueId, issueId), eq(issueComments.companyId, companyId)));
|
||||
if (!existing) return false;
|
||||
await assertAgentInteraction(tx, { companyId, actorAgentId: actor.actorType === "agent" ? actor.actorId : null,
|
||||
operation: "comment", issueId, texts: body === existing.body ? [] : [body], lock: true });
|
||||
await notifyStatusCardIssueChange(tx, companyId);
|
||||
const updated = await tx
|
||||
.update(issueComments)
|
||||
@@ -334,7 +341,7 @@ export function createQueuedCommentIssueLockWriter(db: Db, deps: QueuedCommentQu
|
||||
.then((rows) => rows[0] ?? null)
|
||||
: null;
|
||||
|
||||
const transaction = buildTransaction(tx, companyId, input.issue.id, deps, wakeRow.agentId);
|
||||
const transaction = buildTransaction(tx, companyId, input.issue.id, deps, wakeRow.agentId, input.actor);
|
||||
const wake = toWakeRow(wakeRow);
|
||||
const queueRun = queueRunRow ? toRunRow(queueRunRow) : null;
|
||||
const activeRun = activeRunRow ? toRunRow(activeRunRow) : null;
|
||||
@@ -349,6 +356,8 @@ export function createQueuedCommentIssueLockWriter(db: Db, deps: QueuedCommentQu
|
||||
});
|
||||
|
||||
const locked: LockedQueuedCommentState = { wake, state, queueRun, activeRun, queue };
|
||||
await assertAgentInteraction(tx, { companyId, actorAgentId: input.actor.actorType === "agent" ? input.actor.actorId : null,
|
||||
operation: "comment", issueId: input.issue.id, lock: true });
|
||||
return fn(locked, transaction);
|
||||
});
|
||||
},
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Router, type Request } from "express";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { agents, type Db } from "@paperclipai/db";
|
||||
import { isUuidLike, updateAgentInteractionPermissionsSchema } from "@paperclipai/shared";
|
||||
import { badRequest, notFound } from "../errors.js";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { readAgentInteractionPermissions, updateAgentInteractionPermissions } from "../services/agent-interaction-permissions.js";
|
||||
import { assertAuthenticated, assertBoard, assertCompanyAccess, hasCompanyAccess } from "./authz.js";
|
||||
|
||||
export function agentInteractionPermissionRoutes(db: Db) {
|
||||
const router = Router();
|
||||
async function target(req: Request) {
|
||||
const id = req.params.id as string;
|
||||
if (!isUuidLike(id)) throw badRequest("Agent ID must be a UUID");
|
||||
const [agent] = await db.select({ id: agents.id, companyId: agents.companyId }).from(agents).where(eq(agents.id, id));
|
||||
if (!agent || !hasCompanyAccess(req, agent.companyId)) throw notFound("Agent not found");
|
||||
assertCompanyAccess(req, agent.companyId);
|
||||
return agent;
|
||||
}
|
||||
router.get("/agents/:id/interaction-permissions", async (req, res) => {
|
||||
assertAuthenticated(req);
|
||||
const agent = await target(req);
|
||||
res.json(await readAgentInteractionPermissions(db, req.actor, agent.companyId, agent.id));
|
||||
});
|
||||
router.patch("/agents/:id/interaction-permissions", validate(updateAgentInteractionPermissionsSchema), async (req, res) => {
|
||||
assertAuthenticated(req);
|
||||
assertBoard(req);
|
||||
const agent = await target(req);
|
||||
res.json(await updateAgentInteractionPermissions(db, req.actor, agent.companyId, agent.id, req.body));
|
||||
});
|
||||
return router;
|
||||
}
|
||||
@@ -4679,6 +4679,8 @@ export function agentRoutes(
|
||||
{
|
||||
...(setupAgentId ? { setupAgentId } : {}),
|
||||
createdByUserId: req.actor.type === "board" ? req.actor.userId : null,
|
||||
createdByAgentId: req.actor.type === "agent" ? req.actor.agentId : null,
|
||||
interactionTexts: [JSON.stringify(req.body)],
|
||||
...(req.actor.type === "board" && req.body.budgetMonthlyCents > 0 ? { initialBudgetPolicyUserId: getActorInfo(req).actorId } : {}),
|
||||
runnerResolved: true,
|
||||
responsibleUserId: responsibleUserForAiRequest(req),
|
||||
@@ -4962,6 +4964,8 @@ export function agentRoutes(
|
||||
{
|
||||
...(setupId ? { setupOnly: true } : {}),
|
||||
createdByUserId: req.actor.type === "board" ? req.actor.userId : null,
|
||||
createdByAgentId: req.actor.type === "agent" ? req.actor.agentId : null,
|
||||
interactionTexts: [JSON.stringify(req.body)],
|
||||
...(req.actor.type === "board" && req.body.budgetMonthlyCents > 0 ? { initialBudgetPolicyUserId: getActorInfo(req).actorId } : {}),
|
||||
runnerResolved: true,
|
||||
responsibleUserId: responsibleUserForAiRequest(req),
|
||||
@@ -5066,7 +5070,7 @@ export function agentRoutes(
|
||||
at: new Date().toISOString(),
|
||||
} }
|
||||
: req.body;
|
||||
const agent = await svc.updatePermissions(id, permissionsPatch);
|
||||
const agent = await svc.updatePermissions(id, permissionsPatch, { actorAgentId: req.actor.type === "agent" ? req.actor.agentId : null });
|
||||
if (!agent) {
|
||||
res.status(404).json({ error: "Agent not found" });
|
||||
return;
|
||||
@@ -5517,7 +5521,12 @@ export function agentRoutes(
|
||||
res.json(completed);
|
||||
});
|
||||
|
||||
router.patch("/agents/:id", validate(updateAgentSchema), async (req, res) => {
|
||||
router.patch("/agents/:id", (req, _res, next) => {
|
||||
if (req.body?.permissions && typeof req.body.permissions === "object" && hasOwn(req.body.permissions, "interactions")) {
|
||||
throw unprocessable("Use /api/agents/:id/interaction-permissions to change interaction permissions");
|
||||
}
|
||||
next();
|
||||
}, validate(updateAgentSchema), async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const existing = await getAccessibleResource(req, res, svc.getById(id), "Agent not found");
|
||||
if (!existing) return;
|
||||
|
||||
@@ -1171,6 +1171,8 @@ export function companyRoutes(db: Db, storage?: StorageService, options?: Compan
|
||||
const actor = getActorInfo(req);
|
||||
const result = await portability.importBundle(body, req.actor.type === "board" ? req.actor.userId : null, {
|
||||
mode: "agent_safe",
|
||||
actorAgentId: req.actor.type === "agent" ? req.actor.agentId : null,
|
||||
actorAgentCompanyId: req.actor.type === "agent" ? req.actor.companyId : null,
|
||||
sourceCompanyId: companyId,
|
||||
pauseAutomations: body.pauseAutomations === true,
|
||||
// An agent importing new agents is hiring, so it needs agents:create
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { assertAgentInteraction } from "../services/agent-interaction-policy.js";
|
||||
import { assertQueuedCommentInteractionDelivery, recheckQueuedCommentInteractionDelivery } from "../services/agent-interaction-delivery.js";
|
||||
import { listStalledDependencies } from "../services/recovery/blocker-stall-wakeups.js";
|
||||
import { confirmMcpAttachmentCleanup, recordMcpAttachmentCleanup, type McpAttachmentCleanup } from "../services/public-mcp/upload-cleanup.js";
|
||||
import { notifyDeferredExecutionWork } from "../services/deferred-execution-notifications.js";
|
||||
@@ -5269,7 +5271,8 @@ export function issueRoutes(
|
||||
function issueWriteDenialCodeForDecision(
|
||||
decision: Awaited<ReturnType<typeof decideIssueAccess>>,
|
||||
): IssueWriteDenialCode {
|
||||
if (decision.code)
|
||||
if (decision.interactionDenial) throw forbidden(decision.explanation, authorizationDeniedDetails(decision));
|
||||
if (decision.code === "RESPONSIBLE_USER_UNAUTHORIZED" || decision.code === "RESPONSIBLE_USER_UNAVAILABLE")
|
||||
return issueWriteDenialCodeForResponsibleUserDenial(decision.code);
|
||||
if (
|
||||
decision.reason === "deny_low_trust_boundary" ||
|
||||
@@ -5680,6 +5683,9 @@ export function issueRoutes(
|
||||
) {
|
||||
if (!(await assertIssueReadAllowed(req, res, issue))) return false;
|
||||
if (req.actor.type !== "agent") return true;
|
||||
await assertAgentInteraction(db, { companyId: issue.companyId, actorAgentId: req.actor.agentId,
|
||||
operation: "comment", issueId: issue.id,
|
||||
texts: [typeof req.body?.body === "string" ? req.body.body : "", typeof req.body?.comment === "string" ? req.body.comment : ""] });
|
||||
if (req.actor.runId) await assertNoCaseHandoffMutation(db, req.actor.runId, issue.id);
|
||||
const actorAgentId = req.actor.agentId;
|
||||
if (!actorAgentId) {
|
||||
@@ -12207,11 +12213,13 @@ export function issueRoutes(
|
||||
? await workProductsSvc.update(
|
||||
existingRunAttachmentProduct.id,
|
||||
createInput,
|
||||
actor.agentId,
|
||||
)
|
||||
: await workProductsSvc.createForIssue(
|
||||
issue.id,
|
||||
issue.companyId,
|
||||
createInput,
|
||||
actor.agentId,
|
||||
);
|
||||
if (!product) {
|
||||
res.status(422).json({ error: "Invalid work product payload" });
|
||||
@@ -12605,7 +12613,7 @@ export function issueRoutes(
|
||||
const product = await workProductsSvc.update(id, {
|
||||
...patch,
|
||||
...(sourceTrust ? { sourceTrust } : {}),
|
||||
});
|
||||
}, actor.agentId);
|
||||
if (!product) {
|
||||
res.status(404).json({ error: "Work product not found" });
|
||||
return;
|
||||
@@ -12666,7 +12674,7 @@ export function issueRoutes(
|
||||
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
|
||||
if (!(await assertDeliverableMutationAllowedByRunContext(req, res, issue)))
|
||||
return;
|
||||
const removed = await workProductsSvc.remove(id);
|
||||
const removed = await workProductsSvc.remove(id, req.actor.type === "agent" ? req.actor.agentId : null);
|
||||
if (!removed) {
|
||||
res.status(404).json({ error: "Work product not found" });
|
||||
return;
|
||||
@@ -14751,7 +14759,9 @@ export function issueRoutes(
|
||||
);
|
||||
return;
|
||||
}
|
||||
const issueMutationAccess = await assertAgentIssueMutationAllowed(
|
||||
const commentOnlyUpdate = Boolean(req.body.comment) && Object.keys(req.body).every(key =>
|
||||
["comment", "commentClientRequestId", "attachmentIds", "onBehalfOfUserId"].includes(key));
|
||||
const issueMutationAccess = commentOnlyUpdate ? await assertAgentIssueCommentAllowed(req, res, existing) : await assertAgentIssueMutationAllowed(
|
||||
req,
|
||||
res,
|
||||
existing,
|
||||
@@ -14763,7 +14773,7 @@ export function issueRoutes(
|
||||
req.actor.type === "agent"
|
||||
? issueWriteAuthorizationReason(
|
||||
req,
|
||||
await decideIssueAccess(req, existing, "issue:mutate"),
|
||||
await decideIssueAccess(req, existing, commentOnlyUpdate ? "issue:comment" : "issue:mutate"),
|
||||
)
|
||||
: issueWriteAuthorizationReason(req, true);
|
||||
|
||||
@@ -14797,6 +14807,17 @@ export function issueRoutes(
|
||||
onBehalfOfUserId: _requestedOnBehalfOfUserId,
|
||||
...updateFields
|
||||
} = req.body;
|
||||
if (commentBody && req.actor.type === "agent") {
|
||||
await assertAgentInteraction(db, { companyId: existing.companyId, actorAgentId: req.actor.agentId,
|
||||
operation: "comment", issueId: existing.id, texts: [commentBody] });
|
||||
}
|
||||
await assertAgentInteraction(db, { companyId: existing.companyId,
|
||||
actorAgentId: req.actor.type === "agent" ? req.actor.agentId : null, operation: "mention", issueId: existing.id,
|
||||
texts: [
|
||||
...(updateFields.title !== undefined && updateFields.title !== existing.title ? [updateFields.title] : []),
|
||||
...(updateFields.description !== undefined && updateFields.description !== existing.description ? [updateFields.description ?? ""] : []),
|
||||
],
|
||||
});
|
||||
// Board users move tasks to any depth. An agent move into a child is
|
||||
// placed beside it, and an agent move of a task with children is skipped,
|
||||
// both with a notice instead of an error.
|
||||
@@ -15802,11 +15823,13 @@ export function issueRoutes(
|
||||
const commentWithAdapterOverrides = Boolean(
|
||||
commentBody && updateFields.assigneeAdapterOverrides !== undefined,
|
||||
);
|
||||
const transactionalCommentSourceTrust = commentAttachmentIds?.length || commentWithAdapterOverrides
|
||||
const agentCommentUpdate = Boolean(commentBody && actor.actorType === "agent");
|
||||
const transactionalCommentSourceTrust = commentAttachmentIds?.length || commentWithAdapterOverrides || agentCommentUpdate
|
||||
? await sourceTrustForActorWrite(existing, actor)
|
||||
: undefined;
|
||||
const shouldUseTransactionalIssueUpdate =
|
||||
Boolean(commentAttachmentIds?.length) ||
|
||||
agentCommentUpdate ||
|
||||
commentWithAdapterOverrides ||
|
||||
Boolean(decision) ||
|
||||
shouldRelayStop ||
|
||||
@@ -15815,6 +15838,10 @@ export function issueRoutes(
|
||||
try {
|
||||
if (shouldUseTransactionalIssueUpdate) {
|
||||
issue = await db.transaction(async (tx) => {
|
||||
if (agentCommentUpdate) await assertAgentInteraction(tx, {
|
||||
companyId: existing.companyId, actorAgentId: actor.agentId,
|
||||
operation: "comment", issueId: id, texts: [commentBody], lock: true,
|
||||
});
|
||||
if (
|
||||
reviewPolicySensitiveMutationRequested &&
|
||||
!(await assertLockedReviewPolicyAllowsMutation(tx))
|
||||
@@ -15822,7 +15849,7 @@ export function issueRoutes(
|
||||
return null;
|
||||
const updated = await updateIssue(tx);
|
||||
if (!updated) return null;
|
||||
if (commentAttachmentIds?.length || commentWithAdapterOverrides) {
|
||||
if (commentAttachmentIds?.length || commentWithAdapterOverrides || agentCommentUpdate) {
|
||||
// Adapter settings, reassignment, comment and upload binding commit together.
|
||||
// A failed comment or invalid receipt rolls back the issue update.
|
||||
transactionalComment = await svc.addComment(
|
||||
@@ -17120,7 +17147,7 @@ export function issueRoutes(
|
||||
if (!(await assertAgentIssueMutationAllowed(req, res, existing))) return;
|
||||
const attachments = await svc.listAttachments(id);
|
||||
|
||||
const issue = await svc.remove(id);
|
||||
const issue = await svc.remove(id, req.actor.type === "agent" ? req.actor.agentId : null);
|
||||
if (!issue) {
|
||||
res.status(404).json({ error: "Issue not found" });
|
||||
return;
|
||||
@@ -17728,6 +17755,10 @@ export function issueRoutes(
|
||||
eq(agentWakeupRequests.id, req.body.queueId), eq(agentWakeupRequests.companyId, issue.companyId),
|
||||
)).then(rows => rows[0]);
|
||||
const response = responseWake ? await readQueuedInteractionResponse(db, issue.companyId, issue.id, responseWake.payload) : null;
|
||||
if (responseWake) await recheckQueuedCommentInteractionDelivery(db, {
|
||||
companyId: issue.companyId, issueId: issue.id, agentId: responseWake.agentId,
|
||||
commentId, queueId: responseWake.id,
|
||||
});
|
||||
const steeringIdentity = await reserveSteeredIdentity(db, {
|
||||
companyId: issue.companyId,
|
||||
runId: req.body.targetRunId,
|
||||
@@ -17872,6 +17903,10 @@ export function issueRoutes(
|
||||
code: "queued_response_requires_fresh_session",
|
||||
});
|
||||
}
|
||||
await assertQueuedCommentInteractionDelivery(tx, {
|
||||
companyId: issue.companyId, issueId: issue.id, agentId: locked.activeRun.agentId,
|
||||
commentId, lock: true,
|
||||
});
|
||||
steeringDeliveryAttempted = true;
|
||||
const acknowledgement =
|
||||
(await storedSteeringAcknowledgement(tx, steeringIdentity ?? {
|
||||
@@ -21095,7 +21130,7 @@ export function issueRoutes(
|
||||
);
|
||||
}
|
||||
|
||||
const removed = await svc.removeAttachment(attachmentId, { mcpCleanup });
|
||||
const removed = await svc.removeAttachment(attachmentId, { mcpCleanup, actorAgentId: req.actor.type === "agent" ? req.actor.agentId : null });
|
||||
if (!removed) {
|
||||
res.status(404).json({ error: "Attachment not found" });
|
||||
return;
|
||||
|
||||
@@ -54,6 +54,8 @@ import {
|
||||
updatePrimaryAgentSchema,
|
||||
updateAgentPermissionsSchema,
|
||||
updateAgentPermissionGrantSchema,
|
||||
updateAgentInteractionPermissionsSchema,
|
||||
agentInteractionPermissionsSchema,
|
||||
addAgentProjectAccessSchema,
|
||||
updateAgentInstructionsPathSchema,
|
||||
updateAgentInstructionsBundleSchema,
|
||||
@@ -3850,6 +3852,26 @@ registry.registerPath({
|
||||
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict },
|
||||
});
|
||||
|
||||
const agentInteractionPermissionsResponse = z.object({
|
||||
agentId: z.string().uuid(), companyId: z.string().uuid(), overrides: agentInteractionPermissionsSchema,
|
||||
effective: agentInteractionPermissionsSchema.required(),
|
||||
});
|
||||
registry.registerPath({
|
||||
method: "get", path: "/api/agents/{id}/interaction-permissions", tags: ["agents"],
|
||||
summary: "Read individual agent interaction restrictions",
|
||||
description: "Requires agent configuration read access. Returns sparse saved overrides and effective restriction values; true does not grant authority denied by other policies.",
|
||||
request: { params: z.object({ id: z.string().uuid() }) },
|
||||
responses: { 200: r.ok(agentInteractionPermissionsResponse), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound },
|
||||
});
|
||||
registry.registerPath({
|
||||
method: "patch", path: "/api/agents/{id}/interaction-permissions", tags: ["agents"],
|
||||
summary: "Set individual agent interaction restrictions",
|
||||
description: "Human permission managers only: requires board authentication and users:manage_permissions. A partial map sets true/false; null removes the override. Missing values preserve existing behavior. Does not change grants or start work. Every change is audited atomically.",
|
||||
request: { params: z.object({ id: z.string().uuid() }), body: jsonBody(updateAgentInteractionPermissionsSchema) },
|
||||
responses: { 200: r.ok(agentInteractionPermissionsResponse), 400: r.badRequest, 401: r.unauthorized,
|
||||
403: r.forbidden, 404: r.notFound, 409: r.conflict },
|
||||
});
|
||||
|
||||
const agentProjectAccessResponse = z.object({ projectIds: z.array(z.string().uuid()), canManage: z.boolean() });
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { currentPluginInteractionOrigin } from "./plugin-interaction-context.js";
|
||||
import { assertAgentInteraction } from "./agent-interaction-policy.js";
|
||||
import { preserveAgentInteractionPermissions } from "../lib/agent-interaction-permissions.js";
|
||||
import { notifyMuseContactVerification } from "../modules/external-agents/index.js";
|
||||
import { assertEphemeralAgentInstructions } from "./agent-instructions.js";
|
||||
import { notifyStrandedAgentStatus } from "./live-run-recovery-notifications.js";
|
||||
@@ -8,8 +12,8 @@ import { notifySkillVersionPinChange } from "./skill-version-retention-notificat
|
||||
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
|
||||
import { notifyExternalConversationSettlement } from "./external-conversation-settlement-notifications.js";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { assets, agentConfigRevisions, agentTaskSessions, agentRuntimeState, type Db } from "@paperclipai/db";
|
||||
import { normalizeAgentUrlKey } from "@paperclipai/shared";
|
||||
import { agents, assets, agentConfigRevisions, agentTaskSessions, agentRuntimeState, type Db } from "@paperclipai/db";
|
||||
import { normalizeAgentUrlKey, extractAgentMentionIds } from "@paperclipai/shared";
|
||||
import { normalizePaperclipRunnerAdapterConfig } from "@paperclipai/adapter-utils/server-utils";
|
||||
import { conflict, forbidden, notFound, unprocessable } from "../errors.js";
|
||||
import { agentRecordQueries, type UpdateAgentOptions, type AgentConfigurationPatch, type AgentConfigurationRecord, isPlainRecord, buildConfigSnapshot,
|
||||
@@ -21,7 +25,24 @@ import { agentCredentialService } from "./agent-credentials.js";
|
||||
import { clearPrimaryAgent } from "./primary-agent.js";
|
||||
import { budgetServiceInTransaction } from "./budgets.js";
|
||||
|
||||
export async function lockAgentConfigurationInteractions(tx: Db, id: string, data: AgentConfigurationPatch, options?: UpdateAgentOptions) {
|
||||
const pluginOrigin = currentPluginInteractionOrigin();
|
||||
if (pluginOrigin) options = { ...options, recordRevision: { ...options?.recordRevision,
|
||||
createdByAgentId: pluginOrigin.agentId, createdByUserId: null, source: options?.recordRevision?.source ?? "plugin_agent" } };
|
||||
const interactionActorId = pluginOrigin?.agentId ?? options?.recordRevision?.createdByAgentId;
|
||||
if (interactionActorId) {
|
||||
const [target] = await tx.select({ companyId: agents.companyId }).from(agents).where(eq(agents.id, id));
|
||||
if (!target) return options;
|
||||
await assertAgentInteraction(tx, { companyId: target.companyId, actorAgentId: interactionActorId,
|
||||
operation: "configure", targetAgentId: id, lockAgentIds: configurationText(data).flatMap(extractAgentMentionIds), lock: "update" });
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
export async function prepareAgentConfiguration(db: Db, existing: AgentConfigurationRecord, data: AgentConfigurationPatch, options?: UpdateAgentOptions): Promise<AgentConfigurationPatch> {
|
||||
await assertAgentInteraction(db, { companyId: existing.companyId, actorAgentId: options?.recordRevision?.createdByAgentId,
|
||||
operation: "mention", texts: changedConfigurationText(data, existing) });
|
||||
if (data.permissions !== undefined) data = { ...data, permissions: preserveAgentInteractionPermissions(existing.permissions, data.permissions) };
|
||||
if (options?.recordRevision?.createdByAgentId && data.budgetMonthlyCents !== undefined && data.budgetMonthlyCents !== existing.budgetMonthlyCents) {
|
||||
throw forbidden("Only board operators can change agent budgets");
|
||||
}
|
||||
@@ -184,3 +205,20 @@ export async function completeAgentConfiguration(txDb: Db, existing: AgentConfig
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function configurationText(value: unknown): string[] {
|
||||
if (typeof value === "string") return [value];
|
||||
if (Array.isArray(value)) return value.flatMap(configurationText);
|
||||
if (value && typeof value === "object") return Object.values(value).flatMap(configurationText);
|
||||
return [];
|
||||
}
|
||||
|
||||
function changedConfigurationText(next: unknown, previous: unknown): string[] {
|
||||
if (isDeepStrictEqual(next, previous)) return [];
|
||||
if (typeof next === "string") return [next];
|
||||
if (next && typeof next === "object") {
|
||||
const prior = previous && typeof previous === "object" ? previous as Record<string, unknown> : {};
|
||||
return Object.entries(next).flatMap(([key, value]) => changedConfigurationText(value, prior[key]));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
import { currentPluginInteractionOrigin } from "./plugin-interaction-context.js";
|
||||
import { extractAgentMentionIds } from "@paperclipai/shared";
|
||||
import { assertAgentInteraction } from "./agent-interaction-policy.js";
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import { cachedAgentFileManifest, checkpointSnapshot, type AgentFileManifest } from "./agent-file-checkpoints.js";
|
||||
@@ -189,8 +192,10 @@ export async function isAgentFileSaveRecorded(tx: Tx, agent: Agent, relative: st
|
||||
export function agentFileStore(db: Db) {
|
||||
db = fileStorageDb(db);
|
||||
async function locked<T>(companyId: string, agentId: string, actor: AuthorizationActor, write: boolean,
|
||||
fn: (tx: Tx, agent: Agent, root: string, bound: AuthorizationActor) => Promise<T>) {
|
||||
fn: (tx: Tx, agent: Agent, root: string, bound: AuthorizationActor) => Promise<T>, texts: string[] = []) {
|
||||
return db.transaction(async tx => {
|
||||
if (write) await assertAgentInteraction(tx, { companyId, actorAgentId: actor.type === "agent" ? actor.agentId : null,
|
||||
operation: "configure", targetAgentId: agentId, lockAgentIds: texts.flatMap(extractAgentMentionIds), lock: "update" });
|
||||
const [agent] = await tx.select().from(agents).where(and(eq(agents.companyId, companyId), eq(agents.id, agentId))).for("update");
|
||||
if (!agent) throw notFound("Agent not found");
|
||||
const bound = write ? await authorizeInstructionCommit(tx, actor, agent) : await authorizeInstructionRead(tx, actor, agent);
|
||||
@@ -202,9 +207,10 @@ export function agentFileStore(db: Db) {
|
||||
});
|
||||
}
|
||||
async function audit(tx: Tx, agent: Agent, actor: AuthorizationActor, details: Record<string, unknown>) {
|
||||
await tx.insert(activityLog).values({ companyId: agent.companyId, actorType: actor.type === "board" ? "user" : "agent",
|
||||
actorId: (actor.type === "board" ? actor.userId : actor.agentId)!, agentId: actor.type === "agent" ? actor.agentId : null,
|
||||
runId: actor.runId, responsibleUserId: actor.type === "board" ? actor.userId : actor.onBehalfOfUserId,
|
||||
const origin = currentPluginInteractionOrigin();
|
||||
await tx.insert(activityLog).values({ companyId: agent.companyId, actorType: origin ? "agent" : actor.type === "board" ? "user" : "agent",
|
||||
actorId: origin?.agentId ?? (actor.type === "board" ? actor.userId : actor.agentId)!, agentId: origin?.agentId ?? (actor.type === "agent" ? actor.agentId : null),
|
||||
runId: origin ? origin.runId : actor.runId, responsibleUserId: origin ? null : actor.type === "board" ? actor.userId : actor.onBehalfOfUserId,
|
||||
action: "agent.files_updated", entityType: "agent", entityId: agent.id, details });
|
||||
}
|
||||
return {
|
||||
@@ -236,6 +242,10 @@ export function agentFileStore(db: Db) {
|
||||
if (!await isAgentFileSaveRecorded(tx, agent, relative, incomingHash)) await audit(tx, agent, bound, { path: relative, contentHash: incomingHash });
|
||||
return { contentHash: currentHash, changed: false };
|
||||
}
|
||||
if (input.bytes && input.bytes.length <= MAX_INSTRUCTION_BYTES) {
|
||||
await assertAgentInteraction(tx, { companyId: input.companyId, actorAgentId: bound.type === "agent" ? bound.agentId : null,
|
||||
operation: "mention", texts: [input.bytes.toString("utf8")] });
|
||||
}
|
||||
if (currentHash !== input.baseHash) throw conflict("This file changed since it was read. Reload before saving.", { code: "AGENT_FILE_CONFLICT", path: relative, currentHash });
|
||||
if (input.bytes === null && relative === deriveBundleState(agent).entryFile) throw unprocessable("The configured instruction entry cannot be deleted");
|
||||
if (input.bytes !== null) {
|
||||
@@ -251,10 +261,24 @@ export function agentFileStore(db: Db) {
|
||||
else await materializeInstructionBytes(root, relative, input.bytes);
|
||||
await audit(tx, agent, bound, { path: relative, contentHash: incomingHash, created: previous === null && input.bytes !== null });
|
||||
return { contentHash: incomingHash, changed: true };
|
||||
}),
|
||||
apply: (input: { companyId: string; agentId: string; sourceDir: string; baseline: DirectorySnapshot; checkpoint?: AgentFileManifest }, actor: AuthorizationActor) =>
|
||||
locked(input.companyId, input.agentId, actor, true, async (tx, agent, root, bound) => {
|
||||
const incoming = input.checkpoint ? checkpointSnapshot(input.checkpoint) : await snapshotAgentFiles(input.sourceDir);
|
||||
}, input.bytes && input.bytes.length <= MAX_INSTRUCTION_BYTES ? [input.bytes.toString("utf8")] : []),
|
||||
apply: async (input: { companyId: string; agentId: string; sourceDir: string; baseline: DirectorySnapshot; checkpoint?: AgentFileManifest }, actor: AuthorizationActor) => {
|
||||
const incoming = input.checkpoint ? checkpointSnapshot(input.checkpoint) : await snapshotAgentFiles(input.sourceDir);
|
||||
const [target] = await db.select().from(agents).where(and(eq(agents.companyId, input.companyId), eq(agents.id, input.agentId)));
|
||||
if (!target) throw notFound("Agent not found");
|
||||
const requestedEntry = deriveBundleState(target).entryFile;
|
||||
const changedMentions = new Map<string, { hash: string; text: string }>();
|
||||
if (actor.type === "agent" || currentPluginInteractionOrigin()) {
|
||||
for (const [name, entry] of incoming.entries) {
|
||||
if (entry.kind !== "file" || JSON.stringify(entry) === JSON.stringify(input.baseline.entries.get(name))) continue;
|
||||
if ((await fs.stat(await assertInstructionPathSafe(input.sourceDir, name))).size > MAX_INSTRUCTION_BYTES) continue;
|
||||
const bytes = (await inspectAgentFile(input.sourceDir, name))?.bytes;
|
||||
const mentions = bytes ? extractAgentMentionIds(bytes.toString("utf8")) : [];
|
||||
if (mentions.length) changedMentions.set(name, { hash: fileHash(bytes!), text: mentions.map(id => `[@agent](agent://${id})`).join(" ") });
|
||||
}
|
||||
}
|
||||
return locked(input.companyId, input.agentId, actor, true, async (tx, agent, root, bound) => {
|
||||
if (deriveBundleState(agent).entryFile !== requestedEntry) throw conflict("Agent instructions entry changed; retry the file synchronization");
|
||||
const entryPath = deriveBundleState(agent).entryFile;
|
||||
if (incoming.entries.get(entryPath)?.kind !== "file") throw unprocessable("The configured instruction entry cannot be deleted");
|
||||
if (!input.checkpoint || JSON.stringify(incoming.entries.get(entryPath)) !== JSON.stringify(input.baseline.entries.get(entryPath))) {
|
||||
@@ -266,6 +290,12 @@ export function agentFileStore(db: Db) {
|
||||
// over quota. Enforce limits on the incoming and resulting tree so a
|
||||
// run can delete files to recover instead of being locked out forever.
|
||||
const current = input.checkpoint ? checkpointSnapshot(await cachedAgentFileManifest(root)) : await snapshotAgentFiles(root, false);
|
||||
const texts = [...changedMentions].flatMap(([name, value]) => {
|
||||
const prior = current.entries.get(name);
|
||||
return prior?.kind === "file" && prior.hash === value.hash ? [] : [value.text];
|
||||
});
|
||||
await assertAgentInteraction(tx, { companyId: input.companyId, actorAgentId: bound.type === "agent" ? bound.agentId : null,
|
||||
operation: "mention", texts });
|
||||
// Rebase only the run's changed paths onto the current tree. This makes
|
||||
// same-file edits/deletions last-sync-wins while untouched files retain
|
||||
// changes from other runs. Ancestors may need recreating after a writer
|
||||
@@ -313,8 +343,9 @@ export function agentFileStore(db: Db) {
|
||||
assertDirectorySize(total, finalEntries.size);
|
||||
await mergeDirectoryWithBaseline({ ...input, baseline: applyBaseline, targetDir: root,
|
||||
...(input.checkpoint ? { snapshots: { source: incoming, current } } : {}) });
|
||||
await audit(tx, agent, bound, { sourceRunId: actor.runId, contract: AGENT_FILES_CONTRACT });
|
||||
await audit(tx, agent, bound, { sourceRunId: currentPluginInteractionOrigin()?.runId ?? actor.runId, contract: AGENT_FILES_CONTRACT });
|
||||
return { storageWarning: storageWarning(total, finalEntries.size, fullFile) };
|
||||
}),
|
||||
}, [...changedMentions.values()].map(value => value.text));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { assertAgentInteraction } from "./agent-interaction-policy.js";
|
||||
import { assertNoInitialAgentInteractionPermissions, preserveAgentInteractionPermissions } from "../lib/agent-interaction-permissions.js";
|
||||
import { assertEphemeralAgentInstructions } from "./agent-instructions.js";
|
||||
import { initializeDurableAgentFiles } from "./agent-file-durability.js";
|
||||
import { notifyAttentionRetentionWork } from "./retention-work-notifications.js";
|
||||
@@ -21,6 +23,9 @@ import { instanceSettingsService } from "./instance-settings.js";
|
||||
import { assertProviderApiPlacementPolicy, isProviderApiSetupDraft, resolveProviderApiPlacement } from "./native-runtime/provider-api-placement.js";
|
||||
|
||||
export async function prepareAgentHire(db: Db, companyId: string, input: CreateAgentData, options?: CreateAgentOptions): Promise<CreateAgentData> {
|
||||
assertNoInitialAgentInteractionPermissions(input.permissions);
|
||||
await assertAgentInteraction(db, { companyId, actorAgentId: options?.createdByAgentId, actorCompanyId: options?.createdByAgentCompanyId,
|
||||
operation: "mention", texts: [JSON.stringify(input), ...(options?.interactionTexts ?? [])], lock: "update" });
|
||||
await notifyAttentionRetentionWork(db, { companyId });
|
||||
const { runner, ...data } = input;
|
||||
const selection = options?.runnerResolved
|
||||
@@ -135,7 +140,7 @@ export async function prepareAgentHireApproval(txDb: Db, existing: AgentHireReco
|
||||
if (patch.permissions !== undefined) {
|
||||
// The pending-approval activation replays the original hire
|
||||
// request, so the new-agent creation default applies.
|
||||
patch.permissions = normalizeAgentPermissions(patch.permissions, { context: "create" });
|
||||
patch.permissions = normalizeAgentPermissions(preserveAgentInteractionPermissions(existing.permissions, patch.permissions), { context: "create" });
|
||||
}
|
||||
if (hasApprovedAdapterConfig) {
|
||||
await agentCredentialService(txDb).enforceClaudeOAuthBindingClaim(txDb, {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { currentPluginInteractionOrigin } from "./plugin-interaction-context.js";
|
||||
import { extractAgentMentionIds } from "@paperclipai/shared";
|
||||
import { assertAgentInteraction } from "./agent-interaction-policy.js";
|
||||
import { updateAgentConfigurationInTransaction } from "./agent-configuration-transaction.js";
|
||||
import { createHash } from "node:crypto";
|
||||
import { adoptAgentFiles, agentFileToken, isAgentFileSaveRecorded } from "./agent-file-store.js";
|
||||
@@ -164,13 +167,14 @@ export function agentInstructionRevisionService(db: Db) {
|
||||
return currentRow(target, state.entryFile, bytes, actor);
|
||||
}
|
||||
function currentRow(target: InstructionTarget, entryFile: string, bytes: Buffer, actor?: RevisionActor): Revision {
|
||||
const origin = currentPluginInteractionOrigin();
|
||||
return { ...target, id: agentFileToken(bytes), entryFile, contentBase64: bytes.toString("base64"),
|
||||
contentHash: createHash("sha256").update(bytes).digest("hex"), byteLength: bytes.length,
|
||||
parentRevisionId: null, baseRevisionId: null, restoredFromRevisionId: null, source: "api",
|
||||
actorAgentId: actor?.type === "agent" ? actor.agentId ?? null : null,
|
||||
actorUserId: actor?.type === "board" ? actor.userId ?? null : null,
|
||||
responsibleUserId: actor?.type === "board" ? actor.userId ?? null : actor?.type === "agent" ? actor.onBehalfOfUserId ?? null : null,
|
||||
sourceRunId: actor && actor.type !== "plugin" ? actor.runId ?? null : null, createdAt: new Date(0) };
|
||||
actorAgentId: origin?.agentId ?? (actor?.type === "agent" ? actor.agentId ?? null : null),
|
||||
actorUserId: origin ? null : actor?.type === "board" ? actor.userId ?? null : null,
|
||||
responsibleUserId: origin ? null : actor?.type === "board" ? actor.userId ?? null : actor?.type === "agent" ? actor.onBehalfOfUserId ?? null : null,
|
||||
sourceRunId: origin ? origin.runId ?? null : actor && actor.type !== "plugin" ? actor.runId ?? null : null, createdAt: new Date(0) };
|
||||
}
|
||||
async function getRevision(
|
||||
tx: Tx,
|
||||
@@ -224,6 +228,11 @@ export function agentInstructionRevisionService(db: Db) {
|
||||
instructionPath(input.entryFile);
|
||||
const bytes = "content" in input ? instructionBytes(input.content) : null;
|
||||
const result = await db.transaction(async (tx) => {
|
||||
const restoredInput = "restoreRevisionId" in input
|
||||
? await getRevision(tx, input, input.entryFile, input.restoreRevisionId) : null;
|
||||
const submittedContent = (bytes ?? Buffer.from(restoredInput!.contentBase64, "base64")).toString("utf8");
|
||||
await assertAgentInteraction(tx, { companyId: input.companyId, actorAgentId: actor.type === "agent" ? actor.agentId : null,
|
||||
operation: "configure", targetAgentId: input.agentId, lockAgentIds: extractAgentMentionIds(submittedContent), lock: "update" });
|
||||
const state = await lockTarget(tx, input, actor.type === "plugin" ? undefined : actor);
|
||||
const bound = actor.type === "plugin"
|
||||
? await authorizePluginReset(tx, actor, input, state.agent)
|
||||
@@ -250,10 +259,15 @@ export function agentInstructionRevisionService(db: Db) {
|
||||
)
|
||||
: null;
|
||||
const candidate = bytes ?? Buffer.from(restored!.contentBase64, "base64");
|
||||
if (current?.contentBase64 !== candidate.toString("base64")) {
|
||||
await assertAgentInteraction(tx, { companyId: input.companyId, actorAgentId: bound.type === "agent" ? bound.agentId : null,
|
||||
operation: "mention", texts: [submittedContent] });
|
||||
}
|
||||
const origin = currentPluginInteractionOrigin();
|
||||
const recordSave = (row: Revision) => tx.insert(activityLog).values({ companyId: input.companyId,
|
||||
actorType: bound.type === "plugin" ? "plugin" : bound.type === "board" ? "user" : "agent",
|
||||
actorId: (bound.type === "plugin" ? bound.pluginId : bound.type === "board" ? bound.userId : bound.agentId)!,
|
||||
agentId: bound.type === "agent" ? bound.agentId : null, runId: row.sourceRunId,
|
||||
actorType: origin ? "agent" : bound.type === "plugin" ? "plugin" : bound.type === "board" ? "user" : "agent",
|
||||
actorId: origin?.agentId ?? (bound.type === "plugin" ? bound.pluginId : bound.type === "board" ? bound.userId : bound.agentId)!,
|
||||
agentId: origin?.agentId ?? (bound.type === "agent" ? bound.agentId : null), runId: row.sourceRunId,
|
||||
responsibleUserId: row.responsibleUserId, action: "agent.files_updated", entityType: "agent", entityId: input.agentId,
|
||||
details: { path: input.entryFile, contentHash: row.contentHash, created: current === null, source: restored ? "legacy_restore" : (input as InstructionCommitInput).source } });
|
||||
const bindEntry = async () => {
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { activityLog, agents, agentWakeupRequests, companies, createDb, heartbeatRunEvents, heartbeatRuns, issueComments, issues, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS, type Db } from "@paperclipai/db";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../__tests__/helpers/embedded-postgres.js";
|
||||
import { filterRunMessageInteractionDelivery, hasAutomaticInteractionDeliveryHold, preparedRunInteractionDeliveryDenied, recheckQueuedCommentInteractionDelivery,
|
||||
recheckQueuedRunInteractions, withInteractionDeliveryOrigin } from "./agent-interaction-delivery.js";
|
||||
import { createHeartbeatQueue } from "./heartbeat/queue.js";
|
||||
import { createHeartbeatQueueTestDependencies } from "../__tests__/helpers/heartbeat-queue.js";
|
||||
import { withPluginInteractionOrigin } from "./plugin-interaction-context.js";
|
||||
import { recoveryService } from "./recovery/service.js";
|
||||
import { createAdmissionTransactionScope, createWakeAdmissionWriter } from "../modules/wake-queue/adapters/postgres.js";
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
describe.skipIf(!support.supported)("pending agent interaction delivery", () => {
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
beforeAll(async () => {
|
||||
database = await startEmbeddedPostgresTestDatabase("interaction-delivery-");
|
||||
db = createDb(database.connectionString);
|
||||
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
|
||||
afterAll(async () => { await db?.$client.end(); await database?.cleanup(); });
|
||||
|
||||
async function seed() {
|
||||
const [company] = await db.insert(companies).values({ name: "Delivery", issuePrefix: `D${randomUUID().slice(0, 5)}` }).returning();
|
||||
const [sender, recipient] = await db.insert(agents).values([
|
||||
{ companyId: company!.id, name: "Sender", adapterType: "process" },
|
||||
{ companyId: company!.id, name: "Recipient", adapterType: "process" },
|
||||
]).returning();
|
||||
const [issue] = await db.insert(issues).values({ companyId: company!.id, title: "Work", status: "todo", assigneeAgentId: recipient!.id }).returning();
|
||||
const [run] = await db.insert(heartbeatRuns).values({ companyId: company!.id, agentId: recipient!.id, status: "queued",
|
||||
invocationSource: "assignment", contextSnapshot: { issueId: issue!.id }, nativeIssueId: issue!.id }).returning();
|
||||
return { companyId: company!.id, sender: sender!.id, recipient: recipient!.id, issueId: issue!.id, run: run! };
|
||||
}
|
||||
type Fixture = Awaited<ReturnType<typeof seed>>;
|
||||
const restriction = (id: string, interactions: Record<string, boolean>) => db.update(agents)
|
||||
.set({ permissions: { interactions } }).where(eq(agents.id, id));
|
||||
const readRun = async (id: string) => (await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, id)))[0]!;
|
||||
async function wake(f: Fixture, input: { commentIds?: string[]; actorType?: string | null; actorId?: string | null } = {}) {
|
||||
const context = { issueId: f.issueId, ...(input.commentIds ? { wakeCommentIds: input.commentIds, wakeCommentId: input.commentIds.at(-1) } : {}) };
|
||||
const [receipt] = await db.insert(agentWakeupRequests).values({ companyId: f.companyId, agentId: f.recipient,
|
||||
source: "assignment", reason: input.commentIds ? "issue_commented" : "issue_assigned", runId: f.run.id,
|
||||
requestedByActorType: input.actorType === undefined ? "agent" : input.actorType,
|
||||
requestedByActorId: input.actorId === undefined ? f.sender : input.actorId,
|
||||
payload: { issueId: f.issueId, _paperclipWakeContext: context },
|
||||
}).returning();
|
||||
await db.update(heartbeatRuns).set({ wakeupRequestId: receipt!.id, contextSnapshot: context }).where(eq(heartbeatRuns.id, f.run.id));
|
||||
return receipt!;
|
||||
}
|
||||
async function comment(f: Fixture, kind: "agent" | "user") {
|
||||
const [value] = await db.insert(issueComments).values({ companyId: f.companyId, issueId: f.issueId, body: `${kind} input`,
|
||||
authorType: kind, ...(kind === "agent" ? { authorAgentId: f.sender } : { authorUserId: "human" }),
|
||||
}).returning();
|
||||
return value!.id;
|
||||
}
|
||||
|
||||
it("keeps old queues unchanged when settings are absent", async () => {
|
||||
const f = await seed(); await wake(f, { actorType: null, actorId: null });
|
||||
const before = await readRun(f.run.id);
|
||||
expect(await recheckQueuedRunInteractions(db, before)).toEqual({ run: before, denied: false, denials: {} });
|
||||
expect(await readRun(f.run.id)).toEqual(before);
|
||||
});
|
||||
|
||||
it("preserves an unrestricted old queue's rendered continuation and timestamps", async () => {
|
||||
const f = await seed(); const message = await comment(f, "agent"); const receipt = await wake(f, { commentIds: [message] });
|
||||
const run = await readRun(f.run.id);
|
||||
await db.update(heartbeatRuns).set({ contextSnapshot: { ...run.contextSnapshot,
|
||||
executionContinuation: { messages: [{ id: message, body: "saved input" }] }, paperclipWake: { retained: true },
|
||||
} }).where(eq(heartbeatRuns.id, run.id));
|
||||
const before = await readRun(run.id);
|
||||
const wakeBefore = (await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, receipt.id)))[0];
|
||||
expect((await recheckQueuedRunInteractions(db, before)).denied).toBe(false);
|
||||
expect(await readRun(run.id)).toEqual(before);
|
||||
expect((await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, receipt.id)))[0]).toEqual(wakeBefore);
|
||||
});
|
||||
|
||||
it.each(["incoming", "outgoing"])("refuses a previously accepted assignment after %s revocation", async direction => {
|
||||
const f = await seed(); const receipt = await wake(f);
|
||||
await restriction(direction === "incoming" ? f.recipient : f.sender,
|
||||
direction === "incoming" ? { canReceiveAssignmentsFromAgents: false } : { canAssignTasksToAgents: false });
|
||||
const result = await recheckQueuedRunInteractions(db, await readRun(f.run.id));
|
||||
expect(result.denied).toBe(true);
|
||||
expect(result.denials.assignment).toMatchObject({ code: "agent_interaction_denied", direction });
|
||||
expect((await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, receipt.id)))[0]!.status).toBe("skipped");
|
||||
expect((await db.select().from(issues).where(eq(issues.id, f.issueId)))[0]!.assigneeAgentId).toBe(f.recipient);
|
||||
await restriction(f.sender, {}); await restriction(f.recipient, {});
|
||||
expect((await recheckQueuedRunInteractions(db, await readRun(f.run.id))).denied).toBe(true);
|
||||
});
|
||||
|
||||
it("checks each author's permissions inside a mixed human and agent batch", async () => {
|
||||
const f = await seed(); const agentComment = await comment(f, "agent"), humanComment = await comment(f, "user");
|
||||
const receipt = await wake(f, { commentIds: [agentComment, humanComment], actorType: "user", actorId: "human" });
|
||||
await restriction(f.recipient, { canReceiveCommentsFromAgents: false });
|
||||
const result = await recheckQueuedRunInteractions(db, await readRun(f.run.id));
|
||||
expect(result.denied).toBe(false);
|
||||
expect(result.run.contextSnapshot?.wakeCommentIds).toEqual([humanComment]);
|
||||
expect(result.denials[agentComment]).toMatchObject({ permission: "canReceiveCommentsFromAgents" });
|
||||
const savedWake = (await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, receipt.id)))[0]!;
|
||||
expect(savedWake.status).toBe("queued");
|
||||
expect((savedWake.payload?._paperclipWakeContext as Record<string, unknown>).wakeCommentIds).toEqual([humanComment]);
|
||||
expect(await db.select().from(issueComments).where(eq(issueComments.issueId, f.issueId))).toHaveLength(2);
|
||||
});
|
||||
|
||||
it.each(["queued", "prepared"] as const)("rechecks every assignment in a %s run with an allowed coalesced comment", async boundary => {
|
||||
for (const assignmentFirst of [true, false]) for (const direction of ["outgoing", "incoming"]) {
|
||||
const f = await seed();
|
||||
const messageId = await comment(f, "user");
|
||||
const primary = await wake(f, assignmentFirst ? {} : { commentIds: [messageId], actorType: "user", actorId: "human" });
|
||||
const primaryOrigin = { actorType: primary.requestedByActorType, actorId: primary.requestedByActorId };
|
||||
await db.update(agentWakeupRequests).set({ payload: { ...primary.payload, interactionOrigin: primaryOrigin } })
|
||||
.where(eq(agentWakeupRequests.id, primary.id));
|
||||
const reason = assignmentFirst ? "issue_commented" : "issue_assigned";
|
||||
const context = { issueId: f.issueId, wakeReason: reason, ...(assignmentFirst ? { wakeCommentId: messageId, wakeCommentIds: [messageId] } : {}) };
|
||||
const actorType = assignmentFirst ? "user" : "agent", actorId = assignmentFirst ? "human" : f.sender;
|
||||
const payload = withInteractionDeliveryOrigin({ issueId: f.issueId, _paperclipWakeContext: context }, actorType, actorId);
|
||||
await db.transaction(tx => createWakeAdmissionWriter().coalesceIntoActiveExecutionRun(
|
||||
createAdmissionTransactionScope(f.companyId, tx as unknown as Db), {
|
||||
companyId: f.companyId, agentId: f.recipient, activeExecutionRunId: f.run.id, activeExecutionRunStatus: "queued",
|
||||
mergedContextSnapshot: { ...context, wakeCommentId: messageId, wakeCommentIds: [messageId] },
|
||||
source: "assignment", triggerDetail: "system", payload, requestedByActorType: actorType,
|
||||
requestedByActorId: actorId, idempotencyKey: null,
|
||||
},
|
||||
));
|
||||
const receipts = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.runId, f.run.id));
|
||||
expect(receipts).toHaveLength(2);
|
||||
expect(receipts.find(row => row.id === primary.id)?.payload?.interactionOrigin).toEqual(primaryOrigin);
|
||||
const assignment = receipts.find(row => row.requestedByActorType === "agent")!;
|
||||
expect(assignment.payload?.interactionOrigin).toEqual({ actorType: "agent", actorId: f.sender });
|
||||
if (boundary === "queued") expect((await recheckQueuedRunInteractions(db, await readRun(f.run.id))).denied).toBe(false);
|
||||
else expect(await preparedRunInteractionDeliveryDenied(db, await readRun(f.run.id))).toBe(false);
|
||||
const permission = direction === "outgoing" ? "canAssignTasksToAgents" : "canReceiveAssignmentsFromAgents";
|
||||
await restriction(direction === "outgoing" ? f.sender : f.recipient, { [permission]: false });
|
||||
if (boundary === "queued") expect((await recheckQueuedRunInteractions(db, await readRun(f.run.id))).denied).toBe(true);
|
||||
else expect(await preparedRunInteractionDeliveryDenied(db, await readRun(f.run.id))).toBe(true);
|
||||
const denied = await readRun(f.run.id);
|
||||
expect(denied.resultJson?.interactionDeliveryDenied).toBe(true);
|
||||
expect((denied.resultJson?.interactionPermissionDenials as Record<string, unknown>).assignment)
|
||||
.toMatchObject({ actorAgentId: f.sender, permission });
|
||||
expect((await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, assignment.id)))[0])
|
||||
.toMatchObject({ status: "skipped", error: "agent_interaction_delivery_denied", requestedByActorId: f.sender });
|
||||
expect((await db.select().from(issues).where(eq(issues.id, f.issueId)))[0]?.assigneeAgentId).toBe(f.recipient);
|
||||
expect(await db.select().from(issueComments).where(eq(issueComments.id, messageId))).toHaveLength(1);
|
||||
const retained = (await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, f.companyId)))
|
||||
.filter(row => row.status === "deferred_issue_execution");
|
||||
expect(retained).toHaveLength(1);
|
||||
expect(retained[0]).toMatchObject({ requestedByActorType: "user", requestedByActorId: "human", runId: null });
|
||||
await restriction(f.sender, {}); await restriction(f.recipient, {});
|
||||
if (boundary === "queued") expect((await recheckQueuedRunInteractions(db, await readRun(f.run.id))).denied).toBe(true);
|
||||
else expect(await preparedRunInteractionDeliveryDenied(db, await readRun(f.run.id))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not apply assignment restrictions to a comment-only pending run", async () => {
|
||||
const f = await seed(); const message = await comment(f, "agent"); const receipt = await wake(f, { commentIds: [message] });
|
||||
await restriction(f.sender, { canAssignTasksToAgents: false });
|
||||
await restriction(f.recipient, { canReceiveAssignmentsFromAgents: false });
|
||||
expect((await recheckQueuedRunInteractions(db, await readRun(f.run.id))).denied).toBe(false);
|
||||
expect(await preparedRunInteractionDeliveryDenied(db, await readRun(f.run.id))).toBe(false);
|
||||
await db.update(heartbeatRuns).set({ wakeupRequestId: null, contextSnapshot: {
|
||||
issueId: f.issueId, wakeReason: "issue_execution_same_name", wakeCommentId: message, wakeCommentIds: [message],
|
||||
} }).where(eq(heartbeatRuns.id, f.run.id));
|
||||
await db.delete(agentWakeupRequests).where(eq(agentWakeupRequests.id, receipt.id));
|
||||
expect((await recheckQueuedRunInteractions(db, await readRun(f.run.id))).denied).toBe(false);
|
||||
expect(await preparedRunInteractionDeliveryDenied(db, await readRun(f.run.id))).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["queued", "prepared"] as const)("recovers an older %s assignment's audit origin when its wake is missing and comments were added", async boundary => {
|
||||
const f = await seed(); const messageId = await comment(f, "user");
|
||||
await db.insert(activityLog).values({ companyId: f.companyId, entityType: "issue", entityId: f.issueId,
|
||||
actorType: "agent", actorId: f.sender, agentId: f.sender, action: "issue.updated",
|
||||
createdAt: new Date(f.run.createdAt.getTime() - 1), details: { assigneeAgentId: f.recipient } });
|
||||
await db.update(heartbeatRuns).set({ contextSnapshot: { issueId: f.issueId, wakeReason: "issue_assigned",
|
||||
wakeCommentId: messageId, wakeCommentIds: [messageId] } }).where(eq(heartbeatRuns.id, f.run.id));
|
||||
await restriction(f.sender, { canAssignTasksToAgents: false });
|
||||
if (boundary === "queued") expect((await recheckQueuedRunInteractions(db, await readRun(f.run.id))).denied).toBe(true);
|
||||
else expect(await preparedRunInteractionDeliveryDenied(db, await readRun(f.run.id))).toBe(true);
|
||||
expect(((await readRun(f.run.id)).resultJson?.interactionPermissionDenials as Record<string, unknown>).assignment)
|
||||
.toMatchObject({ actorAgentId: f.sender, permission: "canAssignTasksToAgents" });
|
||||
await restriction(f.sender, {});
|
||||
if (boundary === "queued") expect((await recheckQueuedRunInteractions(db, await readRun(f.run.id))).denied).toBe(true);
|
||||
else expect(await preparedRunInteractionDeliveryDenied(db, await readRun(f.run.id))).toBe(true);
|
||||
});
|
||||
|
||||
it("makes refused steering permanent while leaving a running task running", async () => {
|
||||
const f = await seed(); const message = await comment(f, "agent"); const receipt = await wake(f, { commentIds: [message] });
|
||||
await db.update(heartbeatRuns).set({ status: "running" }).where(eq(heartbeatRuns.id, f.run.id));
|
||||
await restriction(f.sender, { canCommentOnOtherTasks: false });
|
||||
const input = { companyId: f.companyId, issueId: f.issueId, agentId: f.recipient, commentId: message, queueId: receipt.id };
|
||||
await expect(withPluginInteractionOrigin({ companyId: f.companyId, agentId: f.recipient }, () =>
|
||||
recheckQueuedCommentInteractionDelivery(db, input))).rejects.toMatchObject({ status: 403 });
|
||||
expect((await readRun(f.run.id)).status).toBe("running");
|
||||
await restriction(f.sender, {});
|
||||
await expect(recheckQueuedCommentInteractionDelivery(db, input)).rejects.toMatchObject({ status: 403 });
|
||||
});
|
||||
|
||||
it("refuses unknown older assignment provenance only for an explicitly restricted recipient", async () => {
|
||||
const f = await seed(); await wake(f, { actorType: null, actorId: null });
|
||||
await restriction(f.recipient, { canReceiveAssignmentsFromAgents: false });
|
||||
const result = await recheckQueuedRunInteractions(db, await readRun(f.run.id));
|
||||
expect(result.denials.assignment).toMatchObject({ code: "agent_interaction_provenance_unavailable" });
|
||||
const human = await seed(); await wake(human, { actorType: "user", actorId: "human" });
|
||||
await restriction(human.recipient, { canReceiveAssignmentsFromAgents: false });
|
||||
expect((await recheckQueuedRunInteractions(db, await readRun(human.run.id))).denied).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["wake", "run"])("recovers old assignment provenance within the same millisecond as its %s receipt", async source => {
|
||||
const f = await seed();
|
||||
await db.insert(activityLog).values({ companyId: f.companyId, entityType: "issue", entityId: f.issueId,
|
||||
actorType: "agent", actorId: f.sender, agentId: f.sender, action: "issue.updated",
|
||||
details: { assigneeAgentId: f.recipient }, createdAt: sql`'2026-11-09T12:00:00.000100Z'::timestamptz` });
|
||||
if (source === "wake") {
|
||||
const receipt = await wake(f, { actorType: null, actorId: null });
|
||||
await db.update(agentWakeupRequests).set({ requestedAt: sql`'2026-11-09T12:00:00.000900Z'::timestamptz` })
|
||||
.where(eq(agentWakeupRequests.id, receipt.id));
|
||||
} else {
|
||||
await db.update(heartbeatRuns).set({ createdAt: sql`'2026-11-09T12:00:00.000900Z'::timestamptz` })
|
||||
.where(eq(heartbeatRuns.id, f.run.id));
|
||||
}
|
||||
await restriction(f.sender, { canAssignTasksToAgents: false });
|
||||
const result = await recheckQueuedRunInteractions(db, await readRun(f.run.id));
|
||||
expect(result.denials.assignment).toMatchObject({ actorAgentId: f.sender, permission: "canAssignTasksToAgents" });
|
||||
});
|
||||
|
||||
it.each(["canCommentOnOtherTasks", "canMentionAgents"])("checks %s for saved parent reports through a system dispatch envelope", async permission => {
|
||||
const f = await seed(); const receipt = await wake(f, { actorType: "system", actorId: "native-status-committer" });
|
||||
const payload = withInteractionDeliveryOrigin(receipt.payload, "system", "native-status-wake-dispatch", {
|
||||
actorType: "agent", actorId: f.sender, operation: "comment",
|
||||
});
|
||||
payload.childIssueSummaries = [{ summary: `Report for [@recipient](agent://${f.recipient})` }];
|
||||
await db.update(agentWakeupRequests).set({ payload, reason: "issue_children_completed" }).where(eq(agentWakeupRequests.id, receipt.id));
|
||||
await restriction(f.sender, { [permission]: false });
|
||||
expect((await recheckQueuedRunInteractions(db, await readRun(f.run.id))).denials.assignment)
|
||||
.toMatchObject({ actorAgentId: f.sender, operation: permission === "canMentionAgents" ? "mention" : "comment", permission });
|
||||
});
|
||||
|
||||
it("recovers an older automatic retry's initiating agent from its predecessor", async () => {
|
||||
const f = await seed(); await wake(f);
|
||||
await db.update(heartbeatRuns).set({ status: "failed", errorCode: "controller_lease_lost" }).where(eq(heartbeatRuns.id, f.run.id));
|
||||
const [retry] = await db.insert(heartbeatRuns).values({ companyId: f.companyId, agentId: f.recipient, status: "queued",
|
||||
retryOfRunId: f.run.id, invocationSource: "automation", contextSnapshot: { issueId: f.issueId }, nativeIssueId: f.issueId }).returning();
|
||||
await restriction(f.sender, { canAssignTasksToAgents: false });
|
||||
expect((await recheckQueuedRunInteractions(db, retry!)).denials.assignment)
|
||||
.toMatchObject({ actorAgentId: f.sender, permission: "canAssignTasksToAgents" });
|
||||
});
|
||||
|
||||
it("preserves a server-authorized import routine's source company at delivery", async () => {
|
||||
const source = await seed(), target = await seed();
|
||||
const receipt = await wake(target, { actorType: "agent", actorId: source.sender });
|
||||
await db.update(agentWakeupRequests).set({ payload: withInteractionDeliveryOrigin(receipt.payload,
|
||||
"agent", source.sender, undefined, source.companyId) }).where(eq(agentWakeupRequests.id, receipt.id));
|
||||
expect((await recheckQueuedRunInteractions(db, await readRun(target.run.id))).denied).toBe(false);
|
||||
await restriction(source.sender, { canAssignTasksToAgents: false });
|
||||
expect((await recheckQueuedRunInteractions(db, await readRun(target.run.id))).denials.assignment)
|
||||
.toMatchObject({ actorAgentId: source.sender, permission: "canAssignTasksToAgents" });
|
||||
});
|
||||
|
||||
it("uses an originating agent run even when a decision effect is presented as a human comment", async () => {
|
||||
const f = await seed();
|
||||
const [origin] = await db.insert(heartbeatRuns).values({ companyId: f.companyId, agentId: f.sender, status: "succeeded" }).returning();
|
||||
const [message] = await db.insert(issueComments).values({ companyId: f.companyId, issueId: f.issueId,
|
||||
authorUserId: "human", authorType: "user", createdByRunId: origin!.id, body: "Agent-originated effect" }).returning();
|
||||
await wake(f, { commentIds: [message!.id], actorType: "user", actorId: "human" });
|
||||
await restriction(f.sender, { canCommentOnOtherTasks: false });
|
||||
const result = await recheckQueuedRunInteractions(db, await readRun(f.run.id));
|
||||
expect(result.denied).toBe(true);
|
||||
expect(result.denials[message!.id]).toMatchObject({ actorAgentId: f.sender, permission: "canCommentOnOtherTasks" });
|
||||
});
|
||||
|
||||
it("rechecks rendered pending input after preparation and persists unread restart denial", async () => {
|
||||
const f = await seed(); const message = await comment(f, "agent"); await wake(f, { commentIds: [message] });
|
||||
expect(await preparedRunInteractionDeliveryDenied(db, await readRun(f.run.id))).toBe(false);
|
||||
await restriction(f.recipient, { canReceiveCommentsFromAgents: false });
|
||||
expect(await preparedRunInteractionDeliveryDenied(db, await readRun(f.run.id))).toBe(true);
|
||||
await restriction(f.recipient, {});
|
||||
expect(await filterRunMessageInteractionDelivery(db, { companyId: f.companyId, runId: f.run.id, issueId: f.issueId, commentIds: [message] })).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("retains allowed human input once when a mixed rendered startup must be refused", async () => {
|
||||
const f = await seed(); const agentComment = await comment(f, "agent"), humanComment = await comment(f, "user");
|
||||
await wake(f, { commentIds: [agentComment, humanComment] });
|
||||
await restriction(f.recipient, { canReceiveCommentsFromAgents: false });
|
||||
expect(await preparedRunInteractionDeliveryDenied(db, await readRun(f.run.id))).toBe(true);
|
||||
expect(await preparedRunInteractionDeliveryDenied(db, await readRun(f.run.id))).toBe(true);
|
||||
const retained = (await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, f.companyId)))
|
||||
.filter(row => row.status === "deferred_issue_execution");
|
||||
expect(retained).toHaveLength(1);
|
||||
expect(retained[0]).toMatchObject({ requestedByActorType: "user", requestedByActorId: "human", runId: null });
|
||||
expect((retained[0]!.payload?._paperclipWakeContext as Record<string, unknown>).wakeCommentIds).toEqual([humanComment]);
|
||||
});
|
||||
|
||||
it("does not revoke recovery of an already accepted provider turn", async () => {
|
||||
const f = await seed(); await wake(f);
|
||||
await db.update(heartbeatRuns).set({ status: "running", startedAt: new Date() }).where(eq(heartbeatRuns.id, f.run.id));
|
||||
await db.insert(heartbeatRunEvents).values({ companyId: f.companyId, agentId: f.recipient, runId: f.run.id,
|
||||
seq: 1, eventType: "turn.accepted", stream: "system", level: "info" });
|
||||
await restriction(f.recipient, { canReceiveAssignmentsFromAgents: false });
|
||||
expect(await preparedRunInteractionDeliveryDenied(db, await readRun(f.run.id))).toBe(false);
|
||||
expect((await readRun(f.run.id)).status).toBe("running");
|
||||
});
|
||||
|
||||
it("cancels refused queued runs through the public queue claim without dispatch", async () => {
|
||||
const f = await seed(); await wake(f);
|
||||
await restriction(f.recipient, { canReceiveAssignmentsFromAgents: false });
|
||||
const callbacks = createHeartbeatQueueTestDependencies(db);
|
||||
callbacks.setRunStatus.mockImplementation(async (id, status, values) =>
|
||||
(await db.update(heartbeatRuns).set({ ...values, status }).where(eq(heartbeatRuns.id, id)).returning())[0] ?? null);
|
||||
callbacks.setWakeupStatus.mockImplementation(async (id, status, values) => {
|
||||
if (id) await db.update(agentWakeupRequests).set({ ...values, status }).where(eq(agentWakeupRequests.id, id));
|
||||
});
|
||||
const queue = createHeartbeatQueue(db, callbacks);
|
||||
expect(await queue.claimQueuedRun(await readRun(f.run.id))).toBeNull();
|
||||
expect(callbacks.setRunStatus).toHaveBeenCalledWith(f.run.id, "cancelled", expect.objectContaining({ errorCode: "agent_interaction_delivery_denied" }));
|
||||
expect(callbacks.executeRun).not.toHaveBeenCalled();
|
||||
expect((await readRun(f.run.id)).status).toBe("cancelled");
|
||||
expect(callbacks.releaseIssueExecutionAndPromote).toHaveBeenCalledWith(expect.objectContaining({ id: f.run.id }),
|
||||
expect.objectContaining({ suppressImmediateRecovery: true }));
|
||||
});
|
||||
|
||||
it.each(["todo", "in_progress"])("leaves a refused %s assignment held across recovery and permission resets", async status => {
|
||||
const f = await seed(); await wake(f);
|
||||
await restriction(f.recipient, { canReceiveAssignmentsFromAgents: false });
|
||||
await recheckQueuedRunInteractions(db, await readRun(f.run.id));
|
||||
await db.update(heartbeatRuns).set({ status: "cancelled", errorCode: "agent_interaction_delivery_denied", finishedAt: new Date() })
|
||||
.where(eq(heartbeatRuns.id, f.run.id));
|
||||
await db.update(issues).set({ status }).where(eq(issues.id, f.issueId));
|
||||
const [before] = await db.select().from(issues).where(eq(issues.id, f.issueId));
|
||||
const enqueueWakeup = vi.fn(async () => { throw new Error("Policy denial must not be automatically retried"); });
|
||||
const recovery = recoveryService(db, { enqueueWakeup });
|
||||
await restriction(f.recipient, {});
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
expect(await recovery.reconcileStrandedAssignedIssues({ companyId: f.companyId, issueId: f.issueId }))
|
||||
.toMatchObject({ skipped: 1, dispatchRequeued: 0, continuationRequeued: 0, escalated: 0 });
|
||||
}
|
||||
expect(enqueueWakeup).not.toHaveBeenCalled();
|
||||
expect((await db.select().from(issues).where(eq(issues.id, f.issueId)))[0]).toEqual(before);
|
||||
const callbacks = createHeartbeatQueueTestDependencies(db);
|
||||
callbacks.getAgent.mockImplementation(async id => (await db.select().from(agents).where(eq(agents.id, id)))[0] ?? null);
|
||||
expect(await createHeartbeatQueue(db, callbacks).enqueueWakeup(f.recipient, {
|
||||
source: "assignment", reason: "issue_assignment_recovery", requestedByActorType: "system",
|
||||
requestedByActorId: "recovery", contextSnapshot: { issueId: f.issueId },
|
||||
})).toBeNull();
|
||||
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, f.companyId))).toHaveLength(1);
|
||||
expect(await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, f.companyId))).toHaveLength(1);
|
||||
expect(await hasAutomaticInteractionDeliveryHold(db, {
|
||||
companyId: f.companyId, issueId: f.issueId, agentId: f.recipient, payload: {}, context: {}, actorType: "user", actorId: "human",
|
||||
})).toBe(false);
|
||||
const humanMessage = await comment(f, "user");
|
||||
expect(await hasAutomaticInteractionDeliveryHold(db, {
|
||||
companyId: f.companyId, issueId: f.issueId, agentId: f.recipient, payload: {}, context: { wakeCommentId: humanMessage }, actorType: "system",
|
||||
})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("wake provenance", () => {
|
||||
it("replaces any payload origin with the authenticated server actor", () => {
|
||||
expect(withInteractionDeliveryOrigin({ interactionOrigin: { actorType: "user", actorId: "spoof" } }, "agent", "sender"))
|
||||
.toEqual({ interactionOrigin: { actorType: "agent", actorId: "sender" } });
|
||||
expect(withInteractionDeliveryOrigin({ interactionOrigin: { actorType: "user" } })).toEqual({});
|
||||
});
|
||||
it("captures the host's invoking agent despite a plugin's user presentation", () => {
|
||||
expect(withPluginInteractionOrigin({ companyId: "company", agentId: "invoker" }, () =>
|
||||
withInteractionDeliveryOrigin({}, "user", "human")))
|
||||
.toEqual({ interactionOrigin: { actorType: "agent", actorId: "invoker", actorCompanyId: "company" } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,430 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, asc, desc, eq, lte, or, sql } from "drizzle-orm";
|
||||
import { activityLog, agents, agentWakeupRequests, caseHandoffs, heartbeatRunEvents, heartbeatRuns, issueComments, issueThreadInteractions, issues, statusDecisions, type Db } from "@paperclipai/db";
|
||||
import { forbidden } from "../errors.js";
|
||||
import { agentInteractionDenial, type AgentInteractionDenial, type AgentInteractionInput } from "./agent-interaction-policy.js";
|
||||
import { withQueuedCommentIdsInRunContext, withQueuedCommentIdsInWakePayload } from "./issue-queued-comment-queue.js";
|
||||
import { notifyDeferredExecutionWork } from "./deferred-execution-notifications.js";
|
||||
import { currentPluginInteractionOrigin, withPluginInteractionOrigin } from "./plugin-interaction-context.js";
|
||||
|
||||
export const INTERACTION_DELIVERY_DENIED = "agent_interaction_delivery_denied";
|
||||
const DENIALS_KEY = "interactionPermissionDenials";
|
||||
const ORIGIN_KEY = "interactionOrigin";
|
||||
type Executor = Db | Parameters<Parameters<Db["transaction"]>[0]>[0];
|
||||
type Wake = typeof agentWakeupRequests.$inferSelect;
|
||||
type Run = typeof heartbeatRuns.$inferSelect;
|
||||
type DeliveryDenial = AgentInteractionDenial | {
|
||||
code: "agent_interaction_provenance_unavailable";
|
||||
operation: "assign" | "comment";
|
||||
targetAgentId: string;
|
||||
issueId: string | null;
|
||||
};
|
||||
|
||||
const record = (value: unknown): Record<string, unknown> =>
|
||||
value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
const string = (value: unknown) => typeof value === "string" && value.length ? value : null;
|
||||
const ids = (value: unknown): string[] => Array.isArray(value)
|
||||
? value.filter((entry): entry is string => typeof entry === "string") : [];
|
||||
|
||||
export function agentInteractionDeliveryDenial(db: Executor, input: AgentInteractionInput) {
|
||||
return withPluginInteractionOrigin(undefined, () => agentInteractionDenial(db, input));
|
||||
}
|
||||
|
||||
export function agentWakeInteractionTexts(value: unknown): string[] {
|
||||
const payload = record(value);
|
||||
return [payload.continuationSummary, payload.retrySummary, payload.unblockAction,
|
||||
...(Array.isArray(payload.childIssueSummaries) ? payload.childIssueSummaries.map(entry => record(entry).summary) : []),
|
||||
].filter((entry): entry is string => typeof entry === "string");
|
||||
}
|
||||
|
||||
export function isInteractionDeliveryDeniedRun(run: { errorCode?: string | null } | null | undefined) {
|
||||
return run?.errorCode === INTERACTION_DELIVERY_DENIED;
|
||||
}
|
||||
|
||||
export async function hasAutomaticInteractionDeliveryHold(db: Executor, input: {
|
||||
companyId: string; issueId: string; agentId: string; payload: unknown; context: unknown;
|
||||
actorType?: string; actorId?: string | null;
|
||||
}) {
|
||||
const origin = record(record(input.payload)[ORIGIN_KEY]);
|
||||
const actorType = origin.actorType === "agent" ? "agent" : input.actorType;
|
||||
if (actorType === "user" || actorType === "agent" || commentIds(input.context).length || commentIds(input.payload).length) return false;
|
||||
const [latest] = await db.select({ errorCode: heartbeatRuns.errorCode }).from(heartbeatRuns).where(and(
|
||||
eq(heartbeatRuns.companyId, input.companyId), eq(heartbeatRuns.agentId, input.agentId),
|
||||
or(eq(heartbeatRuns.nativeIssueId, input.issueId), eq(heartbeatRuns.issueId, input.issueId),
|
||||
sql`${heartbeatRuns.contextSnapshot}->>'issueId' = ${input.issueId}`),
|
||||
)).orderBy(desc(heartbeatRuns.createdAt), desc(heartbeatRuns.id)).limit(1);
|
||||
return isInteractionDeliveryDeniedRun(latest);
|
||||
}
|
||||
|
||||
export function withInteractionDeliveryOrigin(payload: unknown, actorType?: string, actorId?: string | null, savedOrigin?: unknown, actorCompanyId?: string) {
|
||||
const next = { ...record(payload) };
|
||||
delete next[ORIGIN_KEY];
|
||||
const saved = record(savedOrigin);
|
||||
if (saved.actorType === "agent" && string(saved.actorId)) {
|
||||
actorType = "agent"; actorId = string(saved.actorId); actorCompanyId = string(saved.actorCompanyId) ?? actorCompanyId;
|
||||
}
|
||||
const pluginOrigin = currentPluginInteractionOrigin();
|
||||
if (pluginOrigin && !string(saved.actorId)) { actorType = "agent"; actorId = pluginOrigin.agentId; actorCompanyId = pluginOrigin.companyId; }
|
||||
if (actorType && actorId) next[ORIGIN_KEY] = { actorType, actorId,
|
||||
...(actorCompanyId ? { actorCompanyId } : {}),
|
||||
...(saved.operation === "comment" ? { operation: "comment" } : {}),
|
||||
};
|
||||
return next;
|
||||
}
|
||||
|
||||
function commentIds(context: unknown): string[] {
|
||||
const value = record(context);
|
||||
return [...new Set([...ids(value.wakeCommentIds), string(value.wakeCommentId), string(value.commentId)]
|
||||
.filter((id): id is string => Boolean(id)))];
|
||||
}
|
||||
|
||||
function requiresWakeOriginCheck(wake: Wake): boolean {
|
||||
const payload = record(wake.payload);
|
||||
const context = record(payload._paperclipWakeContext);
|
||||
const reason = string(context.wakeReason) ?? wake.reason;
|
||||
const operation = record(payload[ORIGIN_KEY]).operation;
|
||||
if (operation === "assign" || operation === "comment") return true;
|
||||
if (["issue_commented", "issue_reopened_via_comment", "issue_comment_mentioned"].includes(reason ?? "")) return false;
|
||||
if (reason && reason !== "issue_execution_same_name") return true;
|
||||
return commentIds(payload).length === 0 && commentIds(context).length === 0;
|
||||
}
|
||||
|
||||
async function recheckAssignmentWakes(db: Executor, run: Run, wakes: Wake[], issueId: string | null, hasComments: boolean) {
|
||||
const reason = string(record(run.contextSnapshot).wakeReason);
|
||||
const runHasIndependentOrigin = Boolean(run.retryOfRunId || reason &&
|
||||
!["issue_commented", "issue_reopened_via_comment", "issue_comment_mentioned", "issue_execution_same_name"].includes(reason));
|
||||
const candidates: Array<Wake | undefined> = wakes.length
|
||||
? wakes.filter(requiresWakeOriginCheck) : hasComments && !runHasIndependentOrigin ? [] : [undefined];
|
||||
let denial: unknown;
|
||||
for (const wake of candidates) {
|
||||
const current = record(record(wake ? wake.payload : run.resultJson)[DENIALS_KEY]).assignment
|
||||
?? await assignmentDenial(db, run, wake, issueId);
|
||||
if (!current) continue;
|
||||
denial ??= current;
|
||||
if (wake) await db.update(agentWakeupRequests).set({ status: "skipped", finishedAt: new Date(),
|
||||
error: INTERACTION_DELIVERY_DENIED, updatedAt: new Date(),
|
||||
payload: sql`jsonb_set(coalesce(${agentWakeupRequests.payload}, '{}'::jsonb), '{interactionPermissionDenials}',
|
||||
coalesce(${agentWakeupRequests.payload}->'interactionPermissionDenials', '{}'::jsonb) || ${JSON.stringify({ assignment: current })}::jsonb)`,
|
||||
}).where(eq(agentWakeupRequests.id, wake.id));
|
||||
}
|
||||
return denial;
|
||||
}
|
||||
|
||||
async function unknownOriginDenial(db: Executor, companyId: string, targetAgentId: string,
|
||||
operation: "assign" | "comment", issueId: string | null): Promise<DeliveryDenial | null> {
|
||||
const [target] = await db.select({ permissions: agents.permissions }).from(agents)
|
||||
.where(and(eq(agents.id, targetAgentId), eq(agents.companyId, companyId)));
|
||||
const key = operation === "comment" ? "canReceiveCommentsFromAgents" : "canReceiveAssignmentsFromAgents";
|
||||
return record(record(target?.permissions).interactions)[key] === false
|
||||
? { code: "agent_interaction_provenance_unavailable", operation, targetAgentId, issueId } : null;
|
||||
}
|
||||
|
||||
export async function queuedCommentInteractionDenial(db: Executor, input: {
|
||||
companyId: string; issueId: string; agentId: string; commentId: string; lock?: boolean;
|
||||
}): Promise<DeliveryDenial | null> {
|
||||
const [comment] = await db.select().from(issueComments).where(and(
|
||||
eq(issueComments.companyId, input.companyId), eq(issueComments.issueId, input.issueId),
|
||||
eq(issueComments.id, input.commentId)));
|
||||
let actorAgentId = comment?.authorAgentId ?? comment?.derivedAuthorAgentId;
|
||||
if (!actorAgentId && comment?.createdByRunId) {
|
||||
const [run] = await db.select({ agentId: heartbeatRuns.agentId }).from(heartbeatRuns)
|
||||
.where(and(eq(heartbeatRuns.id, comment.createdByRunId), eq(heartbeatRuns.companyId, input.companyId)));
|
||||
actorAgentId = run?.agentId;
|
||||
}
|
||||
if (actorAgentId) return agentInteractionDeliveryDenial(db, {
|
||||
companyId: input.companyId, actorAgentId, operation: "comment", issueId: input.issueId,
|
||||
targetAgentId: input.agentId, texts: [comment!.body], delivery: true, lock: input.lock,
|
||||
});
|
||||
if (comment?.authorUserId || comment?.authorType === "system") return null;
|
||||
const [interaction] = comment ? [] : await db.select().from(issueThreadInteractions).where(and(
|
||||
eq(issueThreadInteractions.companyId, input.companyId), eq(issueThreadInteractions.issueId, input.issueId),
|
||||
eq(issueThreadInteractions.id, input.commentId)));
|
||||
if (interaction?.resolvedByAgentId) return agentInteractionDeliveryDenial(db, {
|
||||
companyId: input.companyId, actorAgentId: interaction.resolvedByAgentId, operation: "comment",
|
||||
issueId: input.issueId, targetAgentId: input.agentId, delivery: true, lock: input.lock,
|
||||
});
|
||||
if (interaction?.resolvedByUserId) return null;
|
||||
return unknownOriginDenial(db, input.companyId, input.agentId, "comment", input.issueId);
|
||||
}
|
||||
|
||||
export async function filterQueuedInteractionComments(db: Executor, input: {
|
||||
companyId: string; issueId: string; agentId: string; commentIds: string[];
|
||||
wake?: Wake | null; lock?: boolean;
|
||||
}): Promise<{ allowedIds: string[]; denials: Record<string, unknown> }> {
|
||||
const previous = record(record(input.wake?.payload)[DENIALS_KEY]);
|
||||
const denials = { ...previous };
|
||||
const allowedIds: string[] = [];
|
||||
for (const commentId of input.commentIds) {
|
||||
const denial = previous[commentId] ?? await queuedCommentInteractionDenial(db, { ...input, commentId });
|
||||
if (denial) denials[commentId] = denial;
|
||||
else allowedIds.push(commentId);
|
||||
}
|
||||
if (input.wake && allowedIds.length !== input.commentIds.length) {
|
||||
const payload = { ...withQueuedCommentIdsInWakePayload(input.wake.payload, allowedIds), [DENIALS_KEY]: denials };
|
||||
await db.update(agentWakeupRequests).set({ payload, updatedAt: new Date(),
|
||||
...(allowedIds.length ? {} : { status: "skipped", error: INTERACTION_DELIVERY_DENIED, finishedAt: new Date() }),
|
||||
}).where(eq(agentWakeupRequests.id, input.wake.id));
|
||||
}
|
||||
return { allowedIds, denials };
|
||||
}
|
||||
|
||||
async function assignmentDenial(db: Executor, run: Run, wake: Wake | undefined, issueId: string | null, depth = 0): Promise<DeliveryDenial | null> {
|
||||
const origin = record(record(wake?.payload)[ORIGIN_KEY]);
|
||||
const reason = wake?.reason ?? string(record(run.contextSnapshot).wakeReason);
|
||||
let operation: "assign" | "comment" = origin.operation === "comment" ? "comment" : "assign";
|
||||
const capturedAgent = origin.actorType === "agent" ? string(origin.actorId) : null;
|
||||
let actorType = capturedAgent ? "agent" : wake?.requestedByActorType;
|
||||
let actorId = capturedAgent ?? wake?.requestedByActorId;
|
||||
if (wake && (actorType === "system" || !actorType)) {
|
||||
const [handoff] = await db.select({ actorType: caseHandoffs.actorType, actorId: caseHandoffs.actorId })
|
||||
.from(caseHandoffs).where(and(eq(caseHandoffs.companyId, run.companyId), eq(caseHandoffs.wakeupRequestId, wake.id),
|
||||
eq(caseHandoffs.toAgentId, run.agentId)));
|
||||
if (handoff) { actorType = handoff.actorType; actorId = handoff.actorId; }
|
||||
}
|
||||
if (wake && (actorType === "system" || !actorType) && reason !== "issue_blockers_resolved") {
|
||||
const payload = record(wake.payload), context = record(payload._paperclipWakeContext);
|
||||
const decisionId = string(payload.nativeDecisionId) ?? string(payload.nativeReviewDecisionId)
|
||||
?? string(payload.nativeChildCompletionDecisionId) ?? string(context.nativeReviewDecisionId)
|
||||
?? string(context.nativeChildCompletionDecisionId);
|
||||
if (decisionId && /^[0-9a-f-]{36}$/i.test(decisionId)) {
|
||||
const [source] = await db.select({ agentId: heartbeatRuns.agentId }).from(statusDecisions)
|
||||
.innerJoin(heartbeatRuns, and(eq(heartbeatRuns.id, statusDecisions.runId), eq(heartbeatRuns.companyId, statusDecisions.companyId)))
|
||||
.where(and(eq(statusDecisions.id, decisionId), eq(statusDecisions.companyId, run.companyId)));
|
||||
if (source) { actorType = "agent"; actorId = source.agentId; }
|
||||
if (reason === "issue_children_completed") operation = "comment";
|
||||
}
|
||||
}
|
||||
if (actorType === "user") return null;
|
||||
if (actorType === "agent" && actorId) {
|
||||
const payload = record(wake?.payload);
|
||||
const texts = agentWakeInteractionTexts(payload);
|
||||
const reviewId = string(payload.nativeReviewInteractionId);
|
||||
if (reviewId && /^[0-9a-f-]{36}$/i.test(reviewId)) {
|
||||
const [review] = await db.select({ summary: issueThreadInteractions.summary, payload: issueThreadInteractions.payload })
|
||||
.from(issueThreadInteractions).where(and(eq(issueThreadInteractions.companyId, run.companyId), eq(issueThreadInteractions.id, reviewId)));
|
||||
for (const value of [review?.summary, record(review?.payload).prompt, record(review?.payload).detailsMarkdown]) {
|
||||
if (typeof value === "string") texts.push(value);
|
||||
}
|
||||
}
|
||||
return agentInteractionDeliveryDenial(db, {
|
||||
companyId: run.companyId, actorAgentId: actorId, operation, targetAgentId: run.agentId,
|
||||
actorCompanyId: string(origin.actorCompanyId) ?? undefined,
|
||||
issueId, texts, delivery: true, lock: true,
|
||||
});
|
||||
}
|
||||
if (run.retryOfRunId && (actorType === "system" || !actorType)) {
|
||||
if (depth >= 16) return unknownOriginDenial(db, run.companyId, run.agentId, operation, issueId);
|
||||
const [source] = await db.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.id, run.retryOfRunId),
|
||||
eq(heartbeatRuns.companyId, run.companyId), eq(heartbeatRuns.agentId, run.agentId)));
|
||||
if (source) {
|
||||
const [sourceWake] = source.wakeupRequestId ? await db.select().from(agentWakeupRequests)
|
||||
.where(and(eq(agentWakeupRequests.id, source.wakeupRequestId), eq(agentWakeupRequests.companyId, run.companyId))) : [];
|
||||
const savedDenial = record(record(source.resultJson)[DENIALS_KEY]).assignment;
|
||||
return savedDenial as DeliveryDenial | undefined ?? assignmentDenial(db, source, sourceWake, issueId, depth + 1);
|
||||
}
|
||||
}
|
||||
if (actorType === "system" && wake?.requestedByActorId?.startsWith("native-status") && reason !== "issue_blockers_resolved") {
|
||||
return unknownOriginDenial(db, run.companyId, run.agentId, reason === "issue_children_completed" ? "comment" : operation, issueId);
|
||||
}
|
||||
if (actorType === "system" && reason !== "issue_assigned" && reason !== "issue_created") return null;
|
||||
if (wake?.source !== "assignment" && run.invocationSource !== "assignment") return null;
|
||||
if (issueId) {
|
||||
if (!actorType || actorType === "system") {
|
||||
const requestedAt = wake
|
||||
? sql`(select ${agentWakeupRequests.requestedAt} from ${agentWakeupRequests} where ${agentWakeupRequests.id} = ${wake.id} and ${agentWakeupRequests.companyId} = ${run.companyId})`
|
||||
: sql`(select ${heartbeatRuns.createdAt} from ${heartbeatRuns} where ${heartbeatRuns.id} = ${run.id} and ${heartbeatRuns.companyId} = ${run.companyId})`;
|
||||
const [assignment] = await db.select({ actorType: activityLog.actorType, actorId: activityLog.actorId })
|
||||
.from(activityLog).where(and(eq(activityLog.companyId, run.companyId), eq(activityLog.entityType, "issue"),
|
||||
eq(activityLog.entityId, issueId), lte(activityLog.createdAt, requestedAt),
|
||||
or(eq(activityLog.action, "issue.created"), eq(activityLog.action, "issue.updated")),
|
||||
sql`${activityLog.details}->>'assigneeAgentId' = ${run.agentId}`))
|
||||
.orderBy(desc(activityLog.createdAt), desc(activityLog.id)).limit(1);
|
||||
if (assignment?.actorType === "agent") return agentInteractionDeliveryDenial(db, {
|
||||
companyId: run.companyId, actorAgentId: assignment.actorId, operation: "assign",
|
||||
targetAgentId: run.agentId, issueId, delivery: true, lock: true,
|
||||
});
|
||||
if (assignment?.actorType === "user") return null;
|
||||
}
|
||||
const [issue] = await db.select({ createdByAgentId: issues.createdByAgentId, createdByUserId: issues.createdByUserId })
|
||||
.from(issues).where(and(eq(issues.id, issueId), eq(issues.companyId, run.companyId)));
|
||||
if (reason === "issue_created" && issue?.createdByAgentId) return agentInteractionDeliveryDenial(db, {
|
||||
companyId: run.companyId, actorAgentId: issue.createdByAgentId, operation: "assign",
|
||||
targetAgentId: run.agentId, issueId, delivery: true, lock: true,
|
||||
});
|
||||
if (reason === "issue_created" && issue?.createdByUserId) return null;
|
||||
}
|
||||
return unknownOriginDenial(db, run.companyId, run.agentId, "assign", issueId);
|
||||
}
|
||||
|
||||
export async function recheckQueuedRunInteractions(db: Db, run: Run): Promise<{
|
||||
run: Run; denied: boolean; denials: Record<string, unknown>;
|
||||
}> {
|
||||
if (run.status !== "queued") return { run, denied: false, denials: {} };
|
||||
const issueId = run.nativeIssueId ?? run.issueId ?? string(record(run.contextSnapshot).issueId);
|
||||
return db.transaction(async tx => {
|
||||
if (issueId) await tx.select({ id: issues.id }).from(issues)
|
||||
.where(and(eq(issues.id, issueId), eq(issues.companyId, run.companyId))).for("update");
|
||||
const wakes = await tx.select().from(agentWakeupRequests).where(and(
|
||||
eq(agentWakeupRequests.companyId, run.companyId), eq(agentWakeupRequests.agentId, run.agentId),
|
||||
or(eq(agentWakeupRequests.runId, run.id), run.wakeupRequestId ? eq(agentWakeupRequests.id, run.wakeupRequestId) : undefined),
|
||||
)).orderBy(asc(agentWakeupRequests.id)).for("update");
|
||||
const [current] = await tx.select().from(heartbeatRuns).where(and(
|
||||
eq(heartbeatRuns.id, run.id), eq(heartbeatRuns.companyId, run.companyId))).for("update");
|
||||
if (!current || current.status !== "queued") return { run: current ?? run, denied: false, denials: {} };
|
||||
if (record(current.resultJson).interactionDeliveryDenied === true) {
|
||||
return { run: current, denied: true, denials: record(record(current.resultJson)[DENIALS_KEY]) };
|
||||
}
|
||||
const allIds = [...new Set([...commentIds(current.contextSnapshot), ...wakes.flatMap(wake =>
|
||||
[...commentIds(wake.payload), ...commentIds(record(wake.payload)._paperclipWakeContext)])])];
|
||||
const denials: Record<string, unknown> = {};
|
||||
const allowed = new Set(allIds);
|
||||
if (issueId && allIds.length) {
|
||||
for (const wake of wakes) {
|
||||
const wakeIds = [...new Set([...commentIds(wake.payload), ...commentIds(record(wake.payload)._paperclipWakeContext)])];
|
||||
const checked = await filterQueuedInteractionComments(tx, {
|
||||
companyId: run.companyId, issueId, agentId: run.agentId, commentIds: wakeIds, wake, lock: true,
|
||||
});
|
||||
Object.assign(denials, checked.denials);
|
||||
for (const id of wakeIds) if (!checked.allowedIds.includes(id)) allowed.delete(id);
|
||||
}
|
||||
const remaining = await filterQueuedInteractionComments(tx, {
|
||||
companyId: run.companyId, issueId, agentId: run.agentId, commentIds: [...allowed], lock: true,
|
||||
});
|
||||
Object.assign(denials, remaining.denials);
|
||||
for (const id of [...allowed]) if (!remaining.allowedIds.includes(id)) allowed.delete(id);
|
||||
}
|
||||
const assignment = await recheckAssignmentWakes(tx, current, wakes, issueId, allIds.length > 0);
|
||||
if (assignment) denials.assignment = assignment;
|
||||
if (!Object.keys(denials).length) return { run: current, denied: false, denials };
|
||||
if (!denials.assignment && allowed.size === allIds.length) return { run: current, denied: false, denials };
|
||||
const denied = Boolean(denials.assignment) || (allIds.length > 0 && allowed.size === 0);
|
||||
const primary = wakes.find(wake => wake.id === current.wakeupRequestId);
|
||||
if (!denied && primary && allIds.length) await tx.update(agentWakeupRequests).set({
|
||||
status: "queued", finishedAt: null, error: null,
|
||||
payload: { ...withQueuedCommentIdsInWakePayload(primary.payload, [...allowed]), [DENIALS_KEY]: denials },
|
||||
updatedAt: new Date(),
|
||||
}).where(eq(agentWakeupRequests.id, primary.id));
|
||||
const contextSnapshot = allIds.length
|
||||
? withQueuedCommentIdsInRunContext(current.contextSnapshot, [...allowed]) : record(current.contextSnapshot);
|
||||
if (allIds.length) delete contextSnapshot.executionContinuation;
|
||||
const [updated] = await tx.update(heartbeatRuns).set({ contextSnapshot,
|
||||
resultJson: { ...record(current.resultJson), [DENIALS_KEY]: denials, ...(denied ? { interactionDeliveryDenied: true } : {}) }, updatedAt: new Date(),
|
||||
}).where(eq(heartbeatRuns.id, current.id)).returning();
|
||||
if (assignment && issueId && allowed.size) await retainAllowedMessages(tx, current, issueId, [...allowed]);
|
||||
return { run: updated ?? current, denied, denials };
|
||||
});
|
||||
}
|
||||
|
||||
export async function assertQueuedCommentInteractionDelivery(db: Executor, input: {
|
||||
companyId: string; issueId: string; agentId: string; commentId: string; lock?: boolean;
|
||||
}) {
|
||||
const denial = await queuedCommentInteractionDenial(db, input);
|
||||
if (denial) throw forbidden("Queued agent input is no longer permitted.", { ...denial, deliveryCode: INTERACTION_DELIVERY_DENIED });
|
||||
}
|
||||
|
||||
export async function recheckQueuedCommentInteractionDelivery(db: Db, input: {
|
||||
companyId: string; issueId: string; agentId: string; commentId: string; queueId: string;
|
||||
}) {
|
||||
const denial = await db.transaction(async tx => {
|
||||
await tx.select({ id: issues.id }).from(issues).where(and(
|
||||
eq(issues.id, input.issueId), eq(issues.companyId, input.companyId))).for("update");
|
||||
const [wake] = await tx.select().from(agentWakeupRequests).where(and(
|
||||
eq(agentWakeupRequests.id, input.queueId), eq(agentWakeupRequests.companyId, input.companyId),
|
||||
eq(agentWakeupRequests.agentId, input.agentId))).for("update");
|
||||
if (!wake || string(record(wake.payload).issueId) !== input.issueId) return null;
|
||||
const previous = record(record(wake.payload)[DENIALS_KEY])[input.commentId];
|
||||
if (previous) return previous;
|
||||
const pendingIds = [...new Set([...commentIds(wake.payload), ...commentIds(record(wake.payload)._paperclipWakeContext)])];
|
||||
if (!pendingIds.includes(input.commentId)) return null;
|
||||
const checked = await filterQueuedInteractionComments(tx, { ...input, commentIds: pendingIds, wake, lock: true });
|
||||
return checked.denials[input.commentId] ?? null;
|
||||
});
|
||||
if (denial) throw forbidden("Queued agent input is no longer permitted.", {
|
||||
...record(denial), deliveryCode: INTERACTION_DELIVERY_DENIED,
|
||||
});
|
||||
}
|
||||
|
||||
export async function filterRunMessageInteractionDelivery(db: Executor, input: {
|
||||
companyId: string; runId: string; issueId: string; commentIds: string[];
|
||||
}): Promise<Set<string>> {
|
||||
if (!input.commentIds.length) return new Set();
|
||||
const [run] = await db.select({ agentId: heartbeatRuns.agentId, resultJson: heartbeatRuns.resultJson })
|
||||
.from(heartbeatRuns).where(and(eq(heartbeatRuns.id, input.runId), eq(heartbeatRuns.companyId, input.companyId)));
|
||||
if (!run) return new Set();
|
||||
const previous = record(record(run.resultJson)[DENIALS_KEY]);
|
||||
const allowed = new Set<string>();
|
||||
const denied: Record<string, unknown> = {};
|
||||
for (const commentId of input.commentIds) {
|
||||
const denial = previous[commentId] ?? await queuedCommentInteractionDenial(db, { ...input, agentId: run.agentId, commentId });
|
||||
if (denial) denied[commentId] = denial;
|
||||
else allowed.add(commentId);
|
||||
}
|
||||
if (Object.keys(denied).some(id => !previous[id])) await db.update(heartbeatRuns).set({
|
||||
resultJson: sql`jsonb_set(coalesce(${heartbeatRuns.resultJson}, '{}'::jsonb), '{interactionPermissionDenials}',
|
||||
coalesce(${heartbeatRuns.resultJson}->'interactionPermissionDenials', '{}'::jsonb) || ${JSON.stringify(denied)}::jsonb)`,
|
||||
updatedAt: new Date(),
|
||||
}).where(eq(heartbeatRuns.id, input.runId));
|
||||
return allowed;
|
||||
}
|
||||
|
||||
export async function preparedRunInteractionDeliveryDenied(db: Db, run: Run): Promise<boolean> {
|
||||
if (run.processPid) return false;
|
||||
const [accepted] = await db.select({ id: heartbeatRunEvents.id }).from(heartbeatRunEvents).where(and(
|
||||
eq(heartbeatRunEvents.companyId, run.companyId), eq(heartbeatRunEvents.runId, run.id),
|
||||
or(eq(heartbeatRunEvents.eventType, "turn.accepted"), eq(heartbeatRunEvents.eventType, "turn.started")),
|
||||
)).limit(1);
|
||||
if (accepted) return false;
|
||||
const issueId = run.nativeIssueId ?? run.issueId ?? string(record(run.contextSnapshot).issueId);
|
||||
const inputIds = commentIds(run.contextSnapshot);
|
||||
const allowed = issueId && inputIds.length ? await filterRunMessageInteractionDelivery(db,
|
||||
{ companyId: run.companyId, runId: run.id, issueId, commentIds: inputIds }) : new Set<string>();
|
||||
const wakes = await db.select().from(agentWakeupRequests).where(and(
|
||||
eq(agentWakeupRequests.companyId, run.companyId), eq(agentWakeupRequests.agentId, run.agentId),
|
||||
or(eq(agentWakeupRequests.runId, run.id), run.wakeupRequestId ? eq(agentWakeupRequests.id, run.wakeupRequestId) : undefined),
|
||||
)).orderBy(asc(agentWakeupRequests.id));
|
||||
const denial = await recheckAssignmentWakes(db, run, wakes, issueId, inputIds.length > 0);
|
||||
if (!denial && allowed.size === inputIds.length) return false;
|
||||
if (issueId && allowed.size) await db.transaction(tx => retainAllowedMessages(tx, run, issueId, [...allowed]));
|
||||
if (denial) await db.update(heartbeatRuns).set({
|
||||
resultJson: sql`jsonb_set(coalesce(${heartbeatRuns.resultJson}, '{}'::jsonb), '{interactionPermissionDenials}',
|
||||
coalesce(${heartbeatRuns.resultJson}->'interactionPermissionDenials', '{}'::jsonb) || ${JSON.stringify({ assignment: denial })}::jsonb)
|
||||
|| '{"interactionDeliveryDenied": true}'::jsonb`, updatedAt: new Date(),
|
||||
}).where(eq(heartbeatRuns.id, run.id));
|
||||
return true;
|
||||
}
|
||||
|
||||
async function retainAllowedMessages(tx: Executor, run: Run, issueId: string, allowedIds: string[]) {
|
||||
await tx.select({ id: issues.id }).from(issues).where(and(eq(issues.id, issueId), eq(issues.companyId, run.companyId))).for("update");
|
||||
const [current] = await tx.select({ resultJson: heartbeatRuns.resultJson }).from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, run.id)).for("update");
|
||||
if (!current) return;
|
||||
const retained = record(record(current.resultJson).interactionRetainedMessages);
|
||||
for (const commentId of allowedIds) {
|
||||
if (retained[commentId]) continue;
|
||||
const [comment] = await tx.select().from(issueComments).where(and(eq(issueComments.id, commentId),
|
||||
eq(issueComments.companyId, run.companyId), eq(issueComments.issueId, issueId)));
|
||||
if (!comment) continue;
|
||||
let actorAgentId = comment.authorAgentId ?? comment.derivedAuthorAgentId;
|
||||
if (!actorAgentId && comment.createdByRunId) {
|
||||
actorAgentId = await tx.select({ agentId: heartbeatRuns.agentId }).from(heartbeatRuns)
|
||||
.where(and(eq(heartbeatRuns.id, comment.createdByRunId), eq(heartbeatRuns.companyId, run.companyId)))
|
||||
.then(rows => rows[0]?.agentId ?? null);
|
||||
}
|
||||
const actorType = actorAgentId ? "agent" : comment.authorUserId ? "user" : "system";
|
||||
const actorId = actorAgentId ?? comment.authorUserId;
|
||||
const context = { issueId, taskId: issueId, wakeReason: "issue_commented", source: "interaction.permission_recheck",
|
||||
wakeCommentId: commentId, wakeCommentIds: [commentId] };
|
||||
const payload = withPluginInteractionOrigin(undefined, () =>
|
||||
withInteractionDeliveryOrigin({ issueId, commentId, _paperclipWakeContext: context }, actorType, actorId));
|
||||
const wakeId = randomUUID();
|
||||
await notifyDeferredExecutionWork(tx, { companyId: run.companyId, requestId: wakeId });
|
||||
const [wake] = await tx.insert(agentWakeupRequests).values({
|
||||
id: wakeId, companyId: run.companyId, agentId: run.agentId, source: "assignment", triggerDetail: "system",
|
||||
reason: "issue_commented", status: "deferred_issue_execution", requestedByActorType: actorType,
|
||||
requestedByActorId: actorId, payload, idempotencyKey: `interaction-retained:${run.id}:${commentId}`,
|
||||
}).returning({ id: agentWakeupRequests.id });
|
||||
if (wake) retained[commentId] = wake.id;
|
||||
}
|
||||
await tx.update(heartbeatRuns).set({ resultJson: sql`coalesce(${heartbeatRuns.resultJson}, '{}'::jsonb)
|
||||
|| ${JSON.stringify({ interactionRetainedMessages: retained })}::jsonb`, updatedAt: new Date() }).where(eq(heartbeatRuns.id, run.id));
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { currentPluginInteractionOrigin } from "./plugin-interaction-context.js";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { agents, type Db } from "@paperclipai/db";
|
||||
import {
|
||||
agentInteractionPermissionsSchema, resolveAgentInteractionPermissions, updateAgentInteractionPermissionsSchema,
|
||||
type AgentInteractionPermissionsPatch, type AgentInteractionPermissionsState,
|
||||
} from "@paperclipai/shared";
|
||||
import { conflict, forbidden, notFound } from "../errors.js";
|
||||
import { accessService } from "./access.js";
|
||||
import type { AuthorizationActor } from "./authorization.js";
|
||||
import { logActivity, publishActivity, type ActivityPublication } from "./activity-log.js";
|
||||
|
||||
function state(agent: { id: string; companyId: string; permissions: Record<string, unknown> }): AgentInteractionPermissionsState {
|
||||
const parsed = agentInteractionPermissionsSchema.safeParse(agent.permissions.interactions ?? {});
|
||||
if (!parsed.success) throw conflict("Stored interaction permissions are invalid", { code: "invalid_agent_interaction_permissions" });
|
||||
return { agentId: agent.id, companyId: agent.companyId, overrides: parsed.data,
|
||||
effective: resolveAgentInteractionPermissions(parsed.data) };
|
||||
}
|
||||
|
||||
export async function readAgentInteractionPermissions(db: Db, actor: AuthorizationActor, companyId: string, agentId: string) {
|
||||
const decision = await accessService(db).decide({ actor, action: "agent_config:read",
|
||||
resource: { type: "agent", companyId, agentId }, scope: { agentId, targetAgentId: agentId } });
|
||||
if (!decision.allowed) throw forbidden(decision.explanation);
|
||||
const [agent] = await db.select({ id: agents.id, companyId: agents.companyId, permissions: agents.permissions })
|
||||
.from(agents).where(and(eq(agents.id, agentId), eq(agents.companyId, companyId)));
|
||||
if (!agent) throw notFound("Agent not found");
|
||||
return state(agent);
|
||||
}
|
||||
|
||||
export async function updateAgentInteractionPermissions(db: Db, actor: AuthorizationActor, companyId: string,
|
||||
agentId: string, input: AgentInteractionPermissionsPatch) {
|
||||
if (actor.type !== "board" || currentPluginInteractionOrigin()) throw forbidden("Only human permission managers can change interaction permissions");
|
||||
const patch = updateAgentInteractionPermissionsSchema.parse(input);
|
||||
const publications: ActivityPublication[] = [];
|
||||
const result = await db.transaction(async tx => {
|
||||
const transactionDb = tx as unknown as Db;
|
||||
const decision = await accessService(transactionDb).decide({ actor, action: "users:manage_permissions",
|
||||
resource: { type: "company", companyId } });
|
||||
if (!decision.allowed) throw forbidden(decision.explanation);
|
||||
const [agent] = await tx.select().from(agents)
|
||||
.where(and(eq(agents.id, agentId), eq(agents.companyId, companyId))).for("update");
|
||||
if (!agent) throw notFound("Agent not found");
|
||||
if (agent.status === "pending_approval") throw conflict("Pending approval agent permissions cannot be changed before board approval");
|
||||
const before = state(agent).overrides;
|
||||
const overrides = { ...before };
|
||||
for (const key of Object.keys(patch) as Array<keyof typeof patch>) {
|
||||
const value = patch[key];
|
||||
if (value === null) delete overrides[key];
|
||||
else if (value !== undefined) overrides[key] = value;
|
||||
}
|
||||
const permissions = { ...agent.permissions };
|
||||
if (Object.keys(overrides).length) permissions.interactions = overrides;
|
||||
else delete permissions.interactions;
|
||||
await tx.update(agents).set({ permissions, updatedAt: new Date() }).where(eq(agents.id, agentId));
|
||||
await logActivity(transactionDb, { companyId, actorType: "user", actorId: actor.userId ?? "local-board",
|
||||
action: "agent.interaction_permissions_updated", entityType: "agent", entityId: agentId,
|
||||
details: { before, after: overrides } }, publications);
|
||||
return state({ ...agent, permissions });
|
||||
});
|
||||
for (const publication of publications) publishActivity(publication);
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { agents, issues, type Db } from "@paperclipai/db";
|
||||
import { extractAgentMentionIds, isUuidLike, type AgentInteractionPermissionKey } from "@paperclipai/shared";
|
||||
import { and, eq, inArray, or } from "drizzle-orm";
|
||||
import { forbidden, notFound } from "../errors.js";
|
||||
import { currentPluginInteractionOrigin } from "./plugin-interaction-context.js";
|
||||
|
||||
type DbTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
|
||||
|
||||
export type AgentInteractionOperation = "assign" | "comment" | "edit" | "configure" | "mention";
|
||||
|
||||
export interface AgentInteractionInput {
|
||||
companyId: string;
|
||||
actorAgentId?: string | null;
|
||||
actorCompanyId?: string;
|
||||
operation: AgentInteractionOperation;
|
||||
issueId?: string | null;
|
||||
targetAgentId?: string | null;
|
||||
assigneeUserId?: string | null;
|
||||
parentIssueId?: string | null;
|
||||
texts?: readonly string[];
|
||||
lock?: boolean | "update";
|
||||
lockAgentIds?: readonly string[];
|
||||
delivery?: boolean;
|
||||
}
|
||||
|
||||
export interface AgentInteractionDenial {
|
||||
code: "agent_interaction_denied";
|
||||
operation: AgentInteractionOperation;
|
||||
permission: AgentInteractionPermissionKey;
|
||||
direction: "incoming" | "outgoing";
|
||||
policyAgentId: string;
|
||||
actorAgentId: string;
|
||||
targetAgentId: string | null;
|
||||
issueId: string | null;
|
||||
}
|
||||
|
||||
export async function agentInteractionDenial(
|
||||
db: Db | DbTransaction,
|
||||
input: AgentInteractionInput,
|
||||
): Promise<AgentInteractionDenial | null> {
|
||||
const origin = currentPluginInteractionOrigin();
|
||||
const actorCompanyId = input.actorCompanyId ?? input.companyId;
|
||||
if (origin && origin.companyId !== actorCompanyId) throw notFound("Company resource not found");
|
||||
const actorAgentId = origin?.agentId ?? input.actorAgentId;
|
||||
if (!actorAgentId) return null;
|
||||
|
||||
const issueIds = [...new Set([input.issueId, input.parentIssueId].filter((id): id is string => Boolean(id)))].sort();
|
||||
const taskQuery = issueIds.length ? db.select({
|
||||
id: issues.id, assigneeAgentId: issues.assigneeAgentId, conversationAgentId: issues.conversationAgentId,
|
||||
}).from(issues).where(and(eq(issues.companyId, input.companyId), inArray(issues.id, issueIds))).orderBy(issues.id) : null;
|
||||
const tasks = taskQuery ? await (input.lock ? taskQuery.for("update") : taskQuery) : [];
|
||||
if (tasks.length !== issueIds.length) throw notFound("Task not found");
|
||||
const issue = tasks.find(task => task.id === input.issueId);
|
||||
const parent = tasks.find(task => task.id === input.parentIssueId);
|
||||
const owner = (task: typeof issue) => task?.conversationAgentId ?? task?.assigneeAgentId ?? null;
|
||||
const mentions = [...new Set((input.texts ?? []).flatMap(extractAgentMentionIds).filter(isUuidLike))];
|
||||
const agentIds = [...new Set([
|
||||
actorAgentId, input.targetAgentId, owner(issue), owner(parent), ...mentions, ...(input.lockAgentIds ?? []).filter(isUuidLike),
|
||||
].filter((id): id is string => Boolean(id)))].sort();
|
||||
const agentQuery = db.select({ id: agents.id, companyId: agents.companyId, permissions: agents.permissions }).from(agents)
|
||||
.where(or(and(eq(agents.companyId, input.companyId), inArray(agents.id, agentIds)),
|
||||
and(eq(agents.companyId, actorCompanyId), eq(agents.id, actorAgentId)))).orderBy(agents.id);
|
||||
const rows = await (input.lock ? agentQuery.for(input.lock === "update" ? "no key update" : "share") : agentQuery);
|
||||
const byId = new Map(rows.map(row => [row.id, row.permissions]));
|
||||
if (!rows.some(row => row.id === actorAgentId && row.companyId === actorCompanyId)
|
||||
|| (input.targetAgentId && !rows.some(row => row.id === input.targetAgentId && row.companyId === input.companyId))) {
|
||||
throw notFound("Agent not found");
|
||||
}
|
||||
|
||||
function denied(
|
||||
permission: AgentInteractionPermissionKey,
|
||||
policyAgentId: string,
|
||||
direction: AgentInteractionDenial["direction"],
|
||||
targetAgentId: string | null,
|
||||
operation = input.operation,
|
||||
issueId = input.issueId ?? null,
|
||||
): AgentInteractionDenial | null {
|
||||
const permissions = byId.get(policyAgentId);
|
||||
const interactions = permissions && typeof permissions === "object"
|
||||
? (permissions as Record<string, unknown>).interactions : null;
|
||||
if (!interactions || typeof interactions !== "object" ||
|
||||
(interactions as Record<string, unknown>)[permission] !== false) return null;
|
||||
return { code: "agent_interaction_denied", operation, permission, direction,
|
||||
policyAgentId, actorAgentId: actorAgentId!, targetAgentId, issueId };
|
||||
}
|
||||
|
||||
function peer(
|
||||
targetAgentId: string | null,
|
||||
outgoing: AgentInteractionPermissionKey,
|
||||
incoming: AgentInteractionPermissionKey,
|
||||
operation = input.operation,
|
||||
issueId = input.issueId ?? null,
|
||||
) {
|
||||
if (targetAgentId === actorAgentId) return null;
|
||||
return denied(outgoing, actorAgentId!, "outgoing", targetAgentId, operation, issueId)
|
||||
?? (targetAgentId ? denied(incoming, targetAgentId, "incoming", targetAgentId, operation, issueId) : null);
|
||||
}
|
||||
|
||||
let result: AgentInteractionDenial | null = null;
|
||||
switch (input.operation) {
|
||||
case "assign":
|
||||
if (!input.delivery) {
|
||||
if (issue) result = peer(owner(issue), "canEditOtherTasks", "canReceiveTaskEditsFromAgents", "edit");
|
||||
if (!result && parent) result = peer(owner(parent), "canEditOtherTasks", "canReceiveTaskEditsFromAgents", "edit", parent.id);
|
||||
}
|
||||
if (!result && input.targetAgentId) result = peer(input.targetAgentId,
|
||||
"canAssignTasksToAgents", "canReceiveAssignmentsFromAgents");
|
||||
if (!result && input.assigneeUserId) result = denied("canAssignTasksToHumans", actorAgentId, "outgoing", null);
|
||||
break;
|
||||
case "comment":
|
||||
result = peer(input.delivery && input.targetAgentId ? input.targetAgentId : owner(issue),
|
||||
"canCommentOnOtherTasks", "canReceiveCommentsFromAgents");
|
||||
break;
|
||||
case "edit":
|
||||
result = peer(owner(issue), "canEditOtherTasks", "canReceiveTaskEditsFromAgents");
|
||||
if (!result && parent) result = peer(owner(parent), "canEditOtherTasks", "canReceiveTaskEditsFromAgents", "edit", parent.id);
|
||||
break;
|
||||
case "configure":
|
||||
if (input.targetAgentId) result = peer(input.targetAgentId,
|
||||
"canConfigureOtherAgents", "canReceiveConfigurationChangesFromAgents");
|
||||
break;
|
||||
case "mention":
|
||||
if (input.targetAgentId) result = peer(input.targetAgentId, "canMentionAgents", "canReceiveMentionsFromAgents");
|
||||
break;
|
||||
}
|
||||
if (result) return result;
|
||||
for (const target of mentions) {
|
||||
if (!byId.has(target)) continue;
|
||||
result = peer(target, "canMentionAgents", "canReceiveMentionsFromAgents", "mention");
|
||||
if (result) return result;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function assertAgentInteraction(db: Db | DbTransaction, input: AgentInteractionInput): Promise<void> {
|
||||
const denial = await agentInteractionDenial(db, input);
|
||||
if (denial) throw forbidden(`Agent interaction is disabled by ${denial.permission}. A human permission manager can change this setting.`, denial);
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import { budgetServiceInTransaction, policyBlocks, type BudgetServiceHooks } fro
|
||||
import { recordAgentStatusEvent, recordResourceCreationEvent } from "./resource-lifecycle-events.js";
|
||||
import { clearPrimaryAgent } from "./primary-agent.js";
|
||||
import { deleteAgentDependencies } from "./agent-deletion.js";
|
||||
import { prepareAgentConfiguration, completeAgentConfiguration } from "./agent-configuration.js";
|
||||
import { lockAgentConfigurationInteractions, prepareAgentConfiguration, completeAgentConfiguration } from "./agent-configuration.js";
|
||||
import { prepareAgentHire, initializeAgentHire, prepareAgentHireApproval, completeAgentHireApproval } from "./agent-hiring.js";
|
||||
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
|
||||
import { fenceActiveCompanyAgentStopsInTransaction as fenceActiveCompanyStops } from "../modules/agent-lifecycle/index.js";
|
||||
@@ -86,6 +86,7 @@ export function createAgentLifecycleEffects(_hooks: BudgetServiceHooks = {}): li
|
||||
recordCreation: (db, companyId, agentId) => recordResourceCreationEvent(db, companyId, "agent", agentId),
|
||||
recordStatus: recordAgentStatusEvent,
|
||||
clearPrimary: clearPrimaryAgent,
|
||||
lockConfigurationInteractions: lockAgentConfigurationInteractions,
|
||||
prepareConfiguration: prepareAgentConfiguration,
|
||||
completeConfiguration: completeAgentConfiguration,
|
||||
prepareHire: prepareAgentHire,
|
||||
@@ -99,6 +100,16 @@ export function createAgentLifecycle(db: Db, hooks: BudgetServiceHooks = {}) {
|
||||
return lifecycle.createAgentLifecycle(db, createAgentLifecycleEffects(hooks));
|
||||
}
|
||||
|
||||
export function initializeImportedAgentConfigurationInTransaction(tx: Db, publications: ActivityPublication[],
|
||||
id: string, data: Parameters<typeof lifecycle.initializeImportedAgentConfigurationInTransaction>[4]) {
|
||||
return lifecycle.initializeImportedAgentConfigurationInTransaction(tx, createAgentLifecycleEffects(), publications, id, data);
|
||||
}
|
||||
|
||||
export function requestAgentHireInTransaction(tx: Db, publications: ActivityPublication[],
|
||||
...args: Parameters<ReturnType<typeof createAgentLifecycle>["requestHire"]>) {
|
||||
return lifecycle.requestAgentHireInTransaction(tx, createAgentLifecycleEffects(), publications, ...args);
|
||||
}
|
||||
|
||||
function trackedDriver(driver: lifecycle.LifecycleDriver): lifecycle.LifecycleDriver {
|
||||
return { ...driver, runHost: agent => trackIdleWork(driver.runHost(agent)),
|
||||
runPlugin: (agent, pluginId) => trackIdleWork(driver.runPlugin(agent, pluginId)) };
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { assertAgentInteraction } from "./agent-interaction-policy.js";
|
||||
import { notifyAttentionRetentionWork } from "./retention-work-notifications.js";
|
||||
import { type BudgetServiceHooks } from "./budgets.js";
|
||||
|
||||
@@ -8,6 +9,7 @@ import { isUuidLike, normalizeAgentApiKeyScope, normalizeAgentUrlKey, type Agent
|
||||
|
||||
import { conflict, notFound, unprocessable } from "../errors.js";
|
||||
|
||||
import { preserveAgentInteractionPermissions } from "../lib/agent-interaction-permissions.js";
|
||||
import { normalizeAgentPermissions } from "../lib/agent-permissions.js";
|
||||
|
||||
import {
|
||||
@@ -42,7 +44,7 @@ export function agentService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
|
||||
|
||||
getById,
|
||||
|
||||
updatePermissions: async (id: string, permissions: Record<string, unknown> & { canCreateAgents: boolean }) => {
|
||||
updatePermissions: async (id: string, permissions: Record<string, unknown> & { canCreateAgents: boolean }, options?: { actorAgentId?: string | null }) => {
|
||||
const existing = await getById(id);
|
||||
if (!existing) return null;
|
||||
if (existing.status === "pending_approval") {
|
||||
@@ -54,11 +56,17 @@ export function agentService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
|
||||
}
|
||||
|
||||
const updated = await db.transaction(async tx => {
|
||||
await notifyAttentionRetentionWork(tx, { companyId: existing.companyId });
|
||||
await assertAgentInteraction(tx, { companyId: existing.companyId, actorAgentId: options?.actorAgentId,
|
||||
operation: "configure", targetAgentId: id, lock: "update" });
|
||||
const [current] = await tx.select().from(agents).where(eq(agents.id, id)).for("update");
|
||||
if (!current) return null;
|
||||
if (current.status === "pending_approval") throw conflict("Pending approval agent permissions cannot be changed before board approval");
|
||||
const protectedPermissions = preserveAgentInteractionPermissions(current.permissions, permissions);
|
||||
await notifyAttentionRetentionWork(tx, { companyId: current.companyId });
|
||||
return tx
|
||||
.update(agents)
|
||||
.set({
|
||||
permissions: normalizeAgentPermissions({ ...existing.permissions, ...permissions }, { role: existing.role }),
|
||||
permissions: normalizeAgentPermissions({ ...current.permissions, ...protectedPermissions }, { role: current.role }),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(agents.id, id))
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { and, eq, inArray, isNull, or, sql, type SQL } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agentInteractionDenial, type AgentInteractionDenial, type AgentInteractionOperation } from "./agent-interaction-policy.js";
|
||||
import { currentPluginInteractionOrigin } from "./plugin-interaction-context.js";
|
||||
import { withHumanDirectedWork } from "./human-directed-work.js";
|
||||
import { hasOwnerChatInstructionAuthority } from "./owner-chat-instruction-authority.js";
|
||||
import { canonicalExecutionUuid } from "./execution-identity.js";
|
||||
@@ -135,7 +137,8 @@ export type AuthorizationDecision = {
|
||||
action: AuthorizationAction;
|
||||
explanation: string;
|
||||
inboxPolicyMode?: InboxAgentPolicyMode | "grant_override";
|
||||
code?: "RESPONSIBLE_USER_UNAUTHORIZED" | "RESPONSIBLE_USER_UNAVAILABLE";
|
||||
code?: "RESPONSIBLE_USER_UNAUTHORIZED" | "RESPONSIBLE_USER_UNAVAILABLE" | "agent_interaction_denied";
|
||||
interactionDenial?: AgentInteractionDenial;
|
||||
reason:
|
||||
| "allow_low_trust_boundary"
|
||||
| "allow_local_board"
|
||||
@@ -164,6 +167,7 @@ export type AuthorizationDecision = {
|
||||
| "deny_missing_consent"
|
||||
| "deny_no_grant"
|
||||
| "deny_policy_restricted"
|
||||
| "deny_agent_interaction"
|
||||
| "deny_low_trust_boundary"
|
||||
| "deny_issue_private"
|
||||
| "deny_project_private"
|
||||
@@ -762,6 +766,7 @@ function scopeBoolean(scope: Record<string, unknown> | null | undefined, key: st
|
||||
|
||||
export function authorizationDeniedDetails(decision: AuthorizationDecision) {
|
||||
return {
|
||||
...decision.interactionDenial,
|
||||
...(decision.code ? { code: decision.code } : {}),
|
||||
reason: decision.reason,
|
||||
};
|
||||
@@ -2861,10 +2866,33 @@ function createAuthorizationService(db: Db | DbTransaction, readSnapshot?: ReadA
|
||||
action: AuthorizationAction;
|
||||
resource: AuthorizationResource;
|
||||
scope?: Record<string, unknown> | null;
|
||||
}): Promise<AuthorizationDecision> {
|
||||
}, options?: { interactionPolicy: "defer_to_mutation" }): Promise<AuthorizationDecision> {
|
||||
const agentDecision = await measureWorkspaceAuthorization("authorization.base", () => decideBase(input));
|
||||
const responsibleDecision = await measureWorkspaceAuthorization("authorization.responsible_user", () => applyResponsibleUserIntersection(input, agentDecision));
|
||||
const intersectedDecision = await measureWorkspaceAuthorization("authorization.authorizing_user", () => applyAuthorizingUserIntersection(input, responsibleDecision));
|
||||
const interactionActorId = currentPluginInteractionOrigin()?.agentId
|
||||
?? (input.actor.type === "agent" ? input.actor.agentId : undefined);
|
||||
if (intersectedDecision.allowed && interactionActorId && options?.interactionPolicy !== "defer_to_mutation") {
|
||||
const operation: AgentInteractionOperation | null = input.action === "tasks:assign" && input.resource.type === "issue" ? "assign"
|
||||
: input.action === "issue:comment" && input.resource.type === "issue" ? "comment"
|
||||
: (input.action === "issue:mutate" || input.action === "project:write_workspace") && input.resource.type === "issue" ? "edit"
|
||||
: input.action === "agent_config:update" || input.action === "agent_instructions:update" ? "configure"
|
||||
: null;
|
||||
if (operation) {
|
||||
const resource = input.resource;
|
||||
const denial = await agentInteractionDenial(db, {
|
||||
companyId: resource.companyId, actorAgentId: interactionActorId, operation,
|
||||
...(resource.type === "issue" ? {
|
||||
issueId: resource.issueId, parentIssueId: operation === "assign" && !resource.issueId ? resource.parentIssueId : undefined,
|
||||
targetAgentId: operation === "assign" ? resource.assigneeAgentId : undefined,
|
||||
assigneeUserId: operation === "assign" ? resource.assigneeUserId : undefined,
|
||||
} : resource.type === "agent" ? { targetAgentId: resource.agentId } : {}),
|
||||
});
|
||||
if (denial) return { allowed: false, action: input.action, reason: "deny_agent_interaction",
|
||||
code: denial.code, interactionDenial: denial,
|
||||
explanation: `Agent interaction is disabled by ${denial.permission}. A human permission manager can change this setting.` };
|
||||
}
|
||||
}
|
||||
if (
|
||||
input.action === "project:read"
|
||||
&& input.resource.type === "project"
|
||||
|
||||
@@ -810,7 +810,7 @@ function isBuiltInAgentMarkerConflict(error: unknown): boolean {
|
||||
|
||||
export function builtInAgentService(db: Db) {
|
||||
const agentSvc = agentService(db);
|
||||
const agentSvcLifecycle = createAgentLifecycle(db);
|
||||
const agentSvcLifecycle = () => createAgentLifecycle(db);
|
||||
const accessSvc = accessService(db);
|
||||
const approvalSvc = approvalService(db);
|
||||
const instructionsSvc = agentInstructionsService(db);
|
||||
@@ -1412,7 +1412,7 @@ export function builtInAgentService(db: Db) {
|
||||
|
||||
async function ensureBuiltInAgentAssignable(agent: Agent) {
|
||||
if (agent.status !== "paused" || ["preparing", "verifying", "resuming"].includes(agent.lifecycleState ?? "")) return agent;
|
||||
const resumed = await agentSvcLifecycle.resumeAgent(agent.id);
|
||||
const resumed = await agentSvcLifecycle().resumeAgent(agent.id);
|
||||
if (!resumed) throw notFound("Built-in agent not found");
|
||||
return resumed as Agent;
|
||||
}
|
||||
@@ -1672,7 +1672,7 @@ export function builtInAgentService(db: Db) {
|
||||
const [keep, ...duplicates] = markedRows;
|
||||
for (const duplicate of duplicates) {
|
||||
const openApproval = await approvalSvc.findOpenHireApprovalForAgent(companyId, duplicate.id);
|
||||
await agentSvcLifecycle.terminateAgent(duplicate.id);
|
||||
await agentSvcLifecycle().terminateAgent(duplicate.id);
|
||||
if (openApproval) {
|
||||
await approvalSvc.cancel(
|
||||
openApproval.id,
|
||||
@@ -1816,7 +1816,7 @@ export function builtInAgentService(db: Db) {
|
||||
: null;
|
||||
let created: Agent;
|
||||
try {
|
||||
created = await agentSvcLifecycle.requestHire(companyId, {
|
||||
created = await agentSvcLifecycle().requestHire(companyId, {
|
||||
...definitionPatch(definition, resolvedInput),
|
||||
status: definition.defaultStatus ?? "idle",
|
||||
pauseReason: definition.defaultStatus === "paused"
|
||||
@@ -1928,7 +1928,7 @@ export function builtInAgentService(db: Db) {
|
||||
: null;
|
||||
let pending: Agent;
|
||||
try {
|
||||
pending = await agentSvcLifecycle.requestHire(companyId, {
|
||||
pending = await agentSvcLifecycle().requestHire(companyId, {
|
||||
...definitionPatch(definition, input),
|
||||
status: "pending_approval",
|
||||
reportsTo,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { assertAgentInteraction } from "./agent-interaction-policy.js";
|
||||
import { and, desc, eq, inArray, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agents, caseAccessSources, caseEvents, caseHandoffs, caseIssueLinks, cases, chatConversations, companies, heartbeatRuns, issues, projects } from "@paperclipai/db";
|
||||
@@ -9,7 +10,7 @@ import { withExternalAdmissionGuard, type ExternalAdmissionTransaction } from ".
|
||||
import { parseNativeExecutionInput } from "../vendor/paperclip-runner/index.js";
|
||||
import { assertAssignableAgent } from "./agent-assignability.js";
|
||||
import { persistActivity, publishActivity, type ActivityPublication } from "./activity-log.js";
|
||||
import { authorizationService, type AuthorizationActor } from "./authorization.js";
|
||||
import { authorizationDeniedDetails, authorizationService, type AuthorizationActor } from "./authorization.js";
|
||||
import { assertActorCanReadCase, assertCaseTrustedForExecution, canActorReceiveCase, retainCaseAccessSource } from "./case-access.js";
|
||||
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
|
||||
import { instanceSettingsService } from "./instance-settings.js";
|
||||
@@ -90,7 +91,7 @@ async function authorizeTask(tx: Db, actor: AuthorizationActor, companyId: strin
|
||||
const assignment = await authorizationService(tx).decide({ actor, action: "tasks:assign", resource, scope: {
|
||||
issueId: issue?.id ?? null, projectId, parentIssueId, assigneeAgentId: toAgentId, assigneeUserId: null,
|
||||
} });
|
||||
if (!assignment.allowed) throw forbidden(assignment.explanation);
|
||||
if (!assignment.allowed) throw forbidden(assignment.explanation, authorizationDeniedDetails(assignment));
|
||||
if (options.validateRecipient !== false) {
|
||||
try {
|
||||
await assertAssignableAgent(tx, companyId, toAgentId, { kind: "work" });
|
||||
@@ -113,7 +114,7 @@ async function authorizeTerminalTask(tx: Db, actor: AuthorizationActor, issue: I
|
||||
throw forbidden("Agents may complete only their own case work");
|
||||
}
|
||||
const mutation = await access.decide({ actor, action: "issue:mutate", resource });
|
||||
if (!mutation.allowed) throw forbidden(mutation.explanation);
|
||||
if (!mutation.allowed) throw forbidden(mutation.explanation, authorizationDeniedDetails(mutation));
|
||||
}
|
||||
|
||||
async function authorizeHandoffControl(tx: Db, actor: AuthorizationActor, row: CaseHandoffRow, issue: IssueRow, validateRecipient = true) {
|
||||
@@ -405,7 +406,7 @@ export function caseHandoffService(db: Db) {
|
||||
type: "issue", companyId, issueId: issue.id, projectId: issue.projectId, parentIssueId: issue.parentId,
|
||||
assigneeAgentId: issue.assigneeAgentId, assigneeUserId: issue.assigneeUserId, status: issue.status,
|
||||
} });
|
||||
if (!mutation.allowed) throw forbidden(mutation.explanation);
|
||||
if (!mutation.allowed) throw forbidden(mutation.explanation, authorizationDeniedDetails(mutation));
|
||||
}
|
||||
}
|
||||
// Lock task before case, matching task mutation admission and dispatch.
|
||||
@@ -446,6 +447,9 @@ export function caseHandoffService(db: Db) {
|
||||
await authorizeTask(tx, actor, companyId, issue, issue?.projectId ?? projectId, input.toAgentId, {
|
||||
invalidRecipientAsUnprocessable: true, ...(issue ? {} : { parentIssueId: parent?.id ?? null }),
|
||||
});
|
||||
await assertAgentInteraction(tx, { companyId, actorAgentId: actor.type === "agent" ? actor.agentId : null,
|
||||
operation: "assign", issueId: issue?.id, parentIssueId: parent?.id, targetAgentId: input.toAgentId,
|
||||
texts: [JSON.stringify(input)], lock: true });
|
||||
if (caseRow && (await tx.select({ id: caseHandoffs.id }).from(caseHandoffs).where(and(eq(caseHandoffs.caseId, caseRow.id), inArray(caseHandoffs.state, PENDING))).limit(1)).length) throw conflict("A handoff is already pending for this case");
|
||||
if (issue && (await tx.select({ id: cases.id }).from(cases).where(and(eq(cases.workIssueId, issue.id), caseRow ? sql`${cases.id} <> ${caseRow.id}` : sql`true`)).limit(1)).length) throw conflict("Task is already the primary task of another case");
|
||||
const id = randomUUID();
|
||||
@@ -489,7 +493,7 @@ export function caseHandoffService(db: Db) {
|
||||
const saved = await documentService(tx).upsertIssueDocument({ issueId: issue.id, ...initial, format: "markdown",
|
||||
baseRevisionId: null, createdByAgentId: actor.type === "agent" ? actor.agentId : null,
|
||||
createdByUserId: actor.type === "board" ? actor.userId ?? null : null, createdByRunId: source?.id ?? null,
|
||||
sourceTrust, lockedDocumentStrategy: "conflict" });
|
||||
sourceTrust, lockedDocumentStrategy: "conflict" }, { initialTaskCreation: !targetId });
|
||||
publications.push((await persistActivity(tx, { companyId, ...who, agentId: actor.type === "agent" ? actor.agentId : null,
|
||||
runId: source?.id ?? null, issueId: issue.id, action: "issue.document_created", entityType: "issue", entityId: issue.id,
|
||||
details: { key: saved.document.key, documentId: saved.document.id, title: saved.document.title,
|
||||
|
||||
@@ -19,6 +19,13 @@ export function agentProfileChangeTargetKey(agentId: string) {
|
||||
return `agent:${agentId}:profile`;
|
||||
}
|
||||
|
||||
/** Canonical and legacy consent targets that deliver a peer configuration proposal. */
|
||||
export function agentConfigurationChangeTargetId(key: string): string | null {
|
||||
return /^agent:([^:]+):(?:instructions|profile)$/.exec(key)?.[1]
|
||||
?? /^reflection-coach:agent-(?:instructions|description):([^:]+)$/.exec(key)?.[1]
|
||||
?? null;
|
||||
}
|
||||
|
||||
export function skillChangeTargetKey(skillId: string) {
|
||||
return `skill:${skillId}`;
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ describe("provider-owned interaction arbitration", () => {
|
||||
return { companyId, agentId, otherAgentId, issueId, runId, interactionId };
|
||||
}
|
||||
|
||||
it("does not suppress a chat final for a system or board completion review", async () => {
|
||||
it("does not suppress a chat final for a system, board, or originating agent completion review", async () => {
|
||||
const binding = await seed();
|
||||
expect(await hasChatRunOwnedProviderInteraction(db, binding)).toBe(false);
|
||||
await db
|
||||
@@ -99,13 +99,18 @@ describe("provider-owned interaction arbitration", () => {
|
||||
.set({ createdByUserId: "board-user" })
|
||||
.where(eq(issueThreadInteractions.id, binding.interactionId));
|
||||
expect(await hasChatRunOwnedProviderInteraction(db, binding)).toBe(false);
|
||||
await db
|
||||
.update(issueThreadInteractions)
|
||||
.set({ createdByUserId: null, createdByAgentId: binding.agentId })
|
||||
.where(eq(issueThreadInteractions.id, binding.interactionId));
|
||||
expect(await hasChatRunOwnedProviderInteraction(db, binding)).toBe(false);
|
||||
});
|
||||
|
||||
it("reserves the response slot only for a pending interaction authored by the source run agent", async () => {
|
||||
const binding = await seed();
|
||||
await db
|
||||
.update(issueThreadInteractions)
|
||||
.set({ createdByAgentId: binding.otherAgentId })
|
||||
.set({ createdByAgentId: binding.otherAgentId, payload: { version: 1, prompt: "Confirm the requested action" } })
|
||||
.where(eq(issueThreadInteractions.id, binding.interactionId));
|
||||
expect(await hasChatRunOwnedProviderInteraction(db, binding)).toBe(false);
|
||||
await db
|
||||
|
||||
@@ -51,6 +51,9 @@ export async function hasChatRunOwnedProviderInteraction(
|
||||
and(
|
||||
inArray(issueThreadInteractions.kind, ["ask_user_questions", "request_confirmation"]),
|
||||
eq(issueThreadInteractions.status, "pending"),
|
||||
sql`NOT (${issueThreadInteractions.kind} = 'request_confirmation'
|
||||
AND coalesce(${issueThreadInteractions.payload}->'target'->>'type', '') = 'custom'
|
||||
AND coalesce(${issueThreadInteractions.payload}->'target'->>'key', '') = 'native_completion_review')`,
|
||||
exists(
|
||||
db
|
||||
.select({ id: heartbeatRuns.id })
|
||||
|
||||
@@ -253,6 +253,9 @@ export async function enqueueIssueInteractionChatPublications(
|
||||
interaction: IssueThreadInteraction,
|
||||
) {
|
||||
if (interaction.status !== "pending") return [];
|
||||
if (interaction.kind === "request_confirmation"
|
||||
&& interaction.payload.target?.type === "custom"
|
||||
&& interaction.payload.target.key === "native_completion_review") return [];
|
||||
// An endpoint is one immutable provider bot identity. Never externalize a
|
||||
// user/system-authored interaction, or let one agent speak through another
|
||||
// agent's endpoint. If the interaction names a source run, verify that run's
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { publishActivity, type ActivityPublication } from "./activity-log.js";
|
||||
import { currentPluginInteractionOrigin } from "./plugin-interaction-context.js";
|
||||
import { assertAgentInteraction } from "./agent-interaction-policy.js";
|
||||
import { notifyAttentionRetentionWork } from "./retention-work-notifications.js";
|
||||
import { notifyIssueRecovery } from "./issue-recovery-notifications.js";
|
||||
import { createAgentLifecycle } from "./agent-lifecycle.js";
|
||||
import { createAgentLifecycle, requestAgentHireInTransaction, initializeImportedAgentConfigurationInTransaction, scheduleAgentLifecycle } from "./agent-lifecycle.js";
|
||||
import { agentAppearanceSchema, agentHarnessType, paperclipRunnerProfileForHarness, agentRunner, type AgentRunnerChoice } from "@paperclipai/shared";
|
||||
import { resolveNewAgentRunnerForCompany } from "./agent-runner-selection.js";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
@@ -10,6 +13,7 @@ import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import {
|
||||
companies as companiesTable,
|
||||
builtInManagedResources,
|
||||
issueRelations,
|
||||
principalPermissionGrants,
|
||||
@@ -761,6 +765,10 @@ type ImportPlanInternal = {
|
||||
type ImportMode = "board_full" | "agent_safe";
|
||||
|
||||
type ImportBehaviorOptions = {
|
||||
actorAgentId?: string | null;
|
||||
actorAgentCompanyId?: string | null;
|
||||
interactionTransaction?: boolean;
|
||||
interactionPublications?: ActivityPublication[];
|
||||
mode?: ImportMode;
|
||||
sourceCompanyId?: string | null;
|
||||
pauseAutomations?: boolean;
|
||||
@@ -3575,7 +3583,7 @@ export function parseGitHubSourceUrl(rawUrl: string) {
|
||||
export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
const companies = companyService(db);
|
||||
const agents = agentService(db);
|
||||
const agentsLifecycle = createAgentLifecycle(db);
|
||||
const agentsLifecycle = () => createAgentLifecycle(db);
|
||||
const assetRecords = assetService(db);
|
||||
const instructions = agentInstructionsService(db);
|
||||
const access = accessService(db);
|
||||
@@ -4268,6 +4276,10 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
},
|
||||
) as Record<string, unknown>;
|
||||
const portablePermissions = pruneDefaultLikeValue(agent.permissions ?? {}, { dropFalseBooleans: true }) as Record<string, unknown>;
|
||||
if (Object.hasOwn(agent.permissions ?? {}, "interactions")) {
|
||||
delete portablePermissions.interactions;
|
||||
warnings.push(`Agent ${slug} interaction restrictions are not copied by company packages; configure them through the interaction-permissions CLI after import.`);
|
||||
}
|
||||
// A CEO's recorded hiring denial is a board choice, so it survives the
|
||||
// round trip even though export otherwise drops false values.
|
||||
const exportedHiringDenial = agent.role === "ceo" ? ceoHiringDenial(agent.permissions) : null;
|
||||
@@ -5272,6 +5284,23 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
// the declared count is the only signal that distinguishes it from a
|
||||
// deliberately small bundle. Reject the fragment rather than importing it.
|
||||
assertInlineSourceComplete(input.source);
|
||||
const origin = currentPluginInteractionOrigin();
|
||||
const actorAgentId = origin?.agentId ?? options?.actorAgentId;
|
||||
const actorAgentCompanyId = origin?.companyId ?? options?.actorAgentCompanyId ?? options?.sourceCompanyId;
|
||||
if (actorAgentId && !options?.interactionTransaction) {
|
||||
const publications: ActivityPublication[] = [];
|
||||
const result = await db.transaction(async tx => {
|
||||
const companyIds = [actorAgentCompanyId, input.target.mode === "existing_company" ? input.target.companyId : null]
|
||||
.filter((id): id is string => Boolean(id));
|
||||
await tx.select({ id: companiesTable.id }).from(companiesTable).where(inArray(companiesTable.id, companyIds)).orderBy(companiesTable.id).for("no key update");
|
||||
return companyPortabilityService(tx as unknown as Db, storage).importBundle(input, actorUserId, {
|
||||
...options, actorAgentId, actorAgentCompanyId, interactionTransaction: true, interactionPublications: publications,
|
||||
});
|
||||
});
|
||||
for (const publication of publications) publishActivity(publication);
|
||||
for (const agent of result.agents) if (agent.action === "created" && agent.id) scheduleAgentLifecycle(db, agent.id);
|
||||
return result;
|
||||
}
|
||||
// The importing board user brings a CEO's recorded hiring denial with the
|
||||
// package; create and configuration writes never take one from input.
|
||||
// Like the permissions toggle, the denial also removes an explicit
|
||||
@@ -5306,7 +5335,32 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
throw unprocessable("Safe import routes only allow create or skip actions.");
|
||||
}
|
||||
|
||||
const updateImportedAgent = (...args: Parameters<typeof agents.update>) => options?.interactionTransaction
|
||||
? initializeImportedAgentConfigurationInTransaction(db, options.interactionPublications ?? [], args[0], args[1])
|
||||
: agents.update(...args);
|
||||
const sourceManifest = plan.source.manifest;
|
||||
if (actorAgentId && actorAgentCompanyId) {
|
||||
const texts: string[] = [];
|
||||
const paths = new Set<string>();
|
||||
if (plan.include.issues) for (const issue of sourceManifest.issues) {
|
||||
texts.push(JSON.stringify(issue));
|
||||
paths.add(issue.path);
|
||||
for (const document of issue.documents ?? []) paths.add(document.path);
|
||||
}
|
||||
if (plan.include.agents) for (const entry of plan.preview.plan.agentPlans) {
|
||||
if (entry.action === "skip") continue;
|
||||
const agent = plan.selectedAgents.find(agent => agent.slug === entry.slug);
|
||||
if (!agent) continue;
|
||||
texts.push(JSON.stringify(agent), JSON.stringify(input.adapterOverrides?.[entry.slug] ?? {}));
|
||||
paths.add(agent.path);
|
||||
for (const path of Object.keys(plan.source.files)) if (path.startsWith(`agents/${entry.slug}/`)) paths.add(path);
|
||||
}
|
||||
for (const path of paths) {
|
||||
const text = readPortableTextFile(plan.source.files, path);
|
||||
if (text !== null) texts.push(text);
|
||||
}
|
||||
await assertAgentInteraction(db, { companyId: actorAgentCompanyId, actorAgentId, operation: "mention", texts, lock: "update" });
|
||||
}
|
||||
const pauseAutomations = options?.pauseAutomations === true;
|
||||
const importedAutomationPausedAt = pauseAutomations ? new Date() : null;
|
||||
const warnings = [...plan.preview.warnings];
|
||||
@@ -5725,8 +5779,8 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
: {};
|
||||
|
||||
if (planAgent.action === "update" && planAgent.existingAgentId) {
|
||||
if (pauseAutomations) await agentsLifecycle.pauseAgent(planAgent.existingAgentId, "import");
|
||||
let updated = await agents.update(planAgent.existingAgentId, {
|
||||
if (pauseAutomations) await agentsLifecycle().pauseAgent(planAgent.existingAgentId, "import");
|
||||
let updated = await updateImportedAgent(planAgent.existingAgentId, {
|
||||
...patch,
|
||||
});
|
||||
if (updated) updated = await applyImportedCeoHiringDenial(targetCompany.id, updated, manifestAgent.permissions) ?? updated;
|
||||
@@ -5746,7 +5800,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
clearLegacyPromptTemplate: true,
|
||||
replaceExisting: true,
|
||||
});
|
||||
updated = await agents.update(updated.id, { adapterConfig: materialized.adapterConfig }) ?? updated;
|
||||
updated = await updateImportedAgent(updated.id, { adapterConfig: materialized.adapterConfig }) ?? updated;
|
||||
} catch (err) {
|
||||
warnings.push(`Failed to materialize instructions bundle for ${manifestAgent.slug}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
@@ -5774,12 +5828,15 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let created = await agentsLifecycle.requestHire(targetCompany.id, {
|
||||
const requestHire = options?.interactionTransaction
|
||||
? (...args: Parameters<ReturnType<typeof createAgentLifecycle>["requestHire"]>) => requestAgentHireInTransaction(db, options.interactionPublications ?? [], ...args)
|
||||
: (...args: Parameters<ReturnType<typeof createAgentLifecycle>["requestHire"]>) => agentsLifecycle().requestHire(...args);
|
||||
let created = await requestHire(targetCompany.id, {
|
||||
...patch,
|
||||
runner: agentRunner(patch.adapterType),
|
||||
...automationPausePatch,
|
||||
status: pauseAutomations ? "paused" : "idle",
|
||||
}, { createdByUserId: actorUserId });
|
||||
}, { createdByUserId: actorUserId, createdByAgentId: actorAgentId, createdByAgentCompanyId: actorAgentCompanyId ?? undefined });
|
||||
created = await applyImportedCeoHiringDenial(targetCompany.id, created, manifestAgent.permissions) ?? created;
|
||||
await access.ensureMembership(targetCompany.id, "agent", created.id, "member", "active");
|
||||
await access.setPrincipalPermission(
|
||||
@@ -5795,7 +5852,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
clearLegacyPromptTemplate: true,
|
||||
replaceExisting: true,
|
||||
});
|
||||
created = await agents.update(created.id, { adapterConfig: materialized.adapterConfig }) ?? created;
|
||||
created = await updateImportedAgent(created.id, { adapterConfig: materialized.adapterConfig }) ?? created;
|
||||
} catch (err) {
|
||||
warnings.push(`Failed to materialize instructions bundle for ${manifestAgent.slug}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
@@ -5845,7 +5902,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
: null);
|
||||
if (!managerId || managerId === agentId) continue;
|
||||
try {
|
||||
await agents.update(agentId, { reportsTo: managerId });
|
||||
await updateImportedAgent(agentId, { reportsTo: managerId });
|
||||
} catch {
|
||||
const managerRef =
|
||||
managerSlug
|
||||
@@ -6115,7 +6172,8 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
: "skip_missed",
|
||||
variables: routineDefinition.variables ?? [],
|
||||
}, {
|
||||
agentId: null,
|
||||
agentId: actorAgentId ?? null,
|
||||
agentCompanyId: actorAgentCompanyId ?? undefined,
|
||||
userId: actorUserId ?? null,
|
||||
});
|
||||
resultRoutines.push({
|
||||
@@ -6137,7 +6195,8 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
cronExpression: trigger.cronExpression!,
|
||||
timezone: trigger.timezone!,
|
||||
}, {
|
||||
agentId: null,
|
||||
agentId: actorAgentId ?? null,
|
||||
agentCompanyId: actorAgentCompanyId ?? undefined,
|
||||
userId: actorUserId ?? null,
|
||||
});
|
||||
continue;
|
||||
@@ -6153,7 +6212,8 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
: "bearer",
|
||||
replayWindowSec: trigger.replayWindowSec ?? 300,
|
||||
}, {
|
||||
agentId: null,
|
||||
agentId: actorAgentId ?? null,
|
||||
agentCompanyId: actorAgentCompanyId ?? undefined,
|
||||
userId: actorUserId ?? null,
|
||||
});
|
||||
continue;
|
||||
@@ -6163,7 +6223,8 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
label: trigger.label,
|
||||
enabled: trigger.enabled,
|
||||
}, {
|
||||
agentId: null,
|
||||
agentId: actorAgentId ?? null,
|
||||
agentCompanyId: actorAgentCompanyId ?? undefined,
|
||||
userId: actorUserId ?? null,
|
||||
});
|
||||
}
|
||||
@@ -6435,7 +6496,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
// statements, turning what used to be one round-trip per row into a
|
||||
// handful per table. Empty buffers are skipped so an issues-free import
|
||||
// (e.g. routines only) issues no writes at all.
|
||||
if (issueRows.length > 0) await issues.importIssues(targetCompany.id, issueRows);
|
||||
if (issueRows.length > 0) await issues.importIssues(targetCompany.id, issueRows, actorAgentId, actorAgentCompanyId);
|
||||
// Imported issues are historical work, not new inbox items. Seed a
|
||||
// per-user inbox archive for the importing board user so a large import
|
||||
// (a real 1,418-task company shipped every task to the inbox) does not
|
||||
@@ -6449,9 +6510,9 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
actorUserId,
|
||||
);
|
||||
}
|
||||
if (commentRows.length > 0) await issues.addImportedComments(commentRows);
|
||||
if (commentRows.length > 0) await issues.addImportedComments(commentRows, actorAgentId, actorAgentCompanyId);
|
||||
if (documentRows.length > 0) await documentsSvc.createIssueDocumentsForImport(documentRows);
|
||||
if (workProductRows.length > 0) await workProductsSvc.createManyForImport(workProductRows);
|
||||
if (workProductRows.length > 0) await workProductsSvc.createManyForImport(workProductRows, actorAgentId, actorAgentCompanyId);
|
||||
if (attachmentRows.length > 0) await issues.addImportedAttachments(attachmentRows);
|
||||
|
||||
if (blockedByBySlug.size > 0) {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { assertAgentInteraction } from "./agent-interaction-policy.js";
|
||||
import { and, eq, inArray, or } from "drizzle-orm";
|
||||
import { isUuidLike } from "@paperclipai/shared";
|
||||
import { agents, agentApiKeys, heartbeatRuns, runIdentityContexts, issues, projects, toolApplications, toolCatalogEntries, toolConnections, toolConnectionInstalls, toolProfiles, toolProfileEntries, toolProfileBindings, toolMcpGateways, aiConnectionPools, connectionGrants, routines, type Db } from "@paperclipai/db";
|
||||
import { forbidden } from "../errors.js";
|
||||
import { authorizationService, canActorReadIssuePrivacy, canActorReadProjectPrivacy, type AuthorizationActor, type AuthorizationDecision } from "./authorization.js";
|
||||
import { authorizationService, authorizationDeniedDetails, canActorReadIssuePrivacy, canActorReadProjectPrivacy, type AuthorizationActor, type AuthorizationDecision } from "./authorization.js";
|
||||
import { captureRunIdentity, runIdentityTaskFields, type RunIdentityCapture } from "./run-identity.js";
|
||||
import type { LogActivityInput } from "./activity-log.js";
|
||||
import type { ConnectionManagementAction, ConnectionManagementResource } from "./connection-management-policy.js";
|
||||
@@ -43,6 +44,7 @@ export function responsibleConnectionUser(actor: AuthorizationActor): string | n
|
||||
/** Installation changes require the same agent-edit authority at every entry point. */
|
||||
export async function assertAgentConnectionInstallAccess(db: Db, actor: AuthorizationActor, companyId: string, agentIds: string[]) {
|
||||
const authorization = authorizationService(db);
|
||||
await assertAgentToolAssignmentInteractions(db, actor.type === "agent" ? actor.agentId : null, companyId, agentIds);
|
||||
for (const agentId of new Set(agentIds)) {
|
||||
const [agent] = await db.select({ id: agents.id }).from(agents)
|
||||
.where(and(eq(agents.id, agentId), eq(agents.companyId, companyId))).limit(1);
|
||||
@@ -50,7 +52,16 @@ export async function assertAgentConnectionInstallAccess(db: Db, actor: Authoriz
|
||||
const decision = await authorization.decide({
|
||||
actor, action: "agent_config:update", resource: { type: "agent", companyId, agentId }, scope: { targetAgentId: agentId },
|
||||
});
|
||||
if (!decision.allowed) throw forbidden(`You cannot edit agent ${agentId}, so you cannot change its connection installs`);
|
||||
if (!decision.allowed) throw forbidden(`You cannot edit agent ${agentId}, so you cannot change its connection installs`, authorizationDeniedDetails(decision));
|
||||
}
|
||||
}
|
||||
|
||||
export async function assertAgentToolAssignmentInteractions(db: Db, actorAgentId: string | null | undefined,
|
||||
companyId: string, agentIds: readonly string[], lock = typeof (db as unknown as { nestedIndex?: number }).nestedIndex === "number") {
|
||||
const targets = [...new Set(agentIds)].sort();
|
||||
for (const [index, targetAgentId] of targets.entries()) {
|
||||
await assertAgentInteraction(db, { companyId, actorAgentId, operation: "configure", targetAgentId,
|
||||
lock: index === 0 && lock ? "update" : false, lockAgentIds: index === 0 ? targets : undefined });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,14 +286,17 @@ export function connectionManagementService(db: Db) {
|
||||
if (targetAgentId) {
|
||||
const [target] = await db.select({ id: agents.id }).from(agents).where(and(eq(agents.id, targetAgentId), eq(agents.companyId, resource.companyId)));
|
||||
if (!target) throw forbidden("Target agent is outside this company");
|
||||
if (targetAgentId === resource.targetAgentId && (prepared.options.mutation ?? action !== "connections:read")) {
|
||||
await assertAgentToolAssignmentInteractions(db, actor.type === "agent" ? actor.agentId : null, resource.companyId, [targetAgentId]);
|
||||
}
|
||||
// Explicitly enforce both principals even when unrelated authorization is in shadow mode.
|
||||
// Existing human routes retain their domain-specific target authority.
|
||||
// Delegation additionally checks both the acting agent and its human.
|
||||
const principals: AuthorizationActor[] = actor.type === "agent" || resource.selfServiceAgentInstalls ? [actor] : [];
|
||||
if (actor.type === "agent") principals.push({ type: "board", userId: responsibleUserId, source: "session", ignoreInstanceAdmin: true });
|
||||
for (const principal of principals) {
|
||||
const targetDecision = await authorization.decide({ actor: principal, action: "agent_config:update", resource: { type: "agent", companyId: resource.companyId, agentId: targetAgentId }, scope: { targetAgentId } });
|
||||
if (!targetDecision.allowed) throw forbidden(targetDecision.explanation);
|
||||
const targetDecision = await authorization.decide({ actor: principal, action: "agent_config:update", resource: { type: "agent", companyId: resource.companyId, agentId: targetAgentId }, scope: { targetAgentId } }, { interactionPolicy: "defer_to_mutation" });
|
||||
if (!targetDecision.allowed) throw forbidden(targetDecision.explanation, authorizationDeniedDetails(targetDecision));
|
||||
}
|
||||
}
|
||||
return decision;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { assertAgentInteraction } from "./agent-interaction-policy.js";
|
||||
import { decisionTargetService, notifyDecisionTargetWork } from "./decision-targets.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, asc, count, desc, eq, gte, inArray, sql } from "drizzle-orm";
|
||||
@@ -475,6 +476,27 @@ export function decisionService(db: Db, options: DecisionServiceOptions) {
|
||||
const deniedOrigin = originAccess.find((access) => !access.allowed);
|
||||
if (!userAccess.allowed || deniedOrigin) return finish("failed", "deny_decision_intersection",
|
||||
{ reason: "deny_decision_intersection", userReason: userAccess.reason, originReason: deniedOrigin?.reason ?? null });
|
||||
const assignment = effect.type === "assign_issue" ? effect : effect.type === "create_issue" ? effect.draft : null;
|
||||
await assertAgentInteraction(tx, {
|
||||
companyId: decision.companyId, actorAgentId: decision.originAgentId,
|
||||
operation: assignment ? "assign" : effect.type === "comment_on_issue" ? "comment" : "edit",
|
||||
issueId: effect.type === "create_issue" ? undefined : target.id,
|
||||
targetAgentId: assignment?.assigneeAgentId, assigneeUserId: assignment?.assigneeUserId,
|
||||
parentIssueId: effect.type === "create_issue" ? effect.draft.parentId ?? target.id : undefined,
|
||||
texts: [interpolate(JSON.stringify(effect), decision.inputValues ?? {})], lock: true,
|
||||
});
|
||||
const effectComment = effect.type === "cancel_issue_tree" ? effect.reasonComment
|
||||
: "comment" in effect ? effect.comment : undefined;
|
||||
if (effectComment) await assertAgentInteraction(tx, {
|
||||
companyId: decision.companyId, actorAgentId: decision.originAgentId,
|
||||
operation: "comment", issueId: target.id, texts: [interpolate(effectComment, decision.inputValues ?? {})], lock: true,
|
||||
});
|
||||
if (effect.type === "cancel_issue_tree") {
|
||||
for (const child of referencedIssues.filter(item => item.id !== target.id)) {
|
||||
await assertAgentInteraction(tx, { companyId: decision.companyId, actorAgentId: decision.originAgentId,
|
||||
operation: "edit", issueId: child.id, lock: true });
|
||||
}
|
||||
}
|
||||
if (effect.staleness === "strict") {
|
||||
const staleReference = referencedIssues.some((item) => directReferencedIds.has(item.id) &&
|
||||
snapshots[item.id]?.updatedAt !== item.updatedAt.toISOString());
|
||||
@@ -486,7 +508,7 @@ export function decisionService(db: Db, options: DecisionServiceOptions) {
|
||||
const values = decision.inputValues ?? {};
|
||||
let result: Record<string, unknown>;
|
||||
if (effect.type === "comment_on_issue") {
|
||||
const comment = await svc.addComment(target.id, interpolate(effect.bodyMarkdown, values), { userId: decidedByUserId }, undefined, tx);
|
||||
const comment = await svc.addComment(target.id, interpolate(effect.bodyMarkdown, values), { userId: decidedByUserId, runId: decision.originRunId }, { clientRequestId: lockKey }, tx);
|
||||
result = { commentId: comment.id };
|
||||
} else if (effect.type === "update_issue_status") {
|
||||
const updated = await svc.update(
|
||||
@@ -496,7 +518,7 @@ export function decisionService(db: Db, options: DecisionServiceOptions) {
|
||||
postCommitActivityPublications,
|
||||
postCommitIssueActions,
|
||||
);
|
||||
if (effect.comment) await svc.addComment(target.id, interpolate(effect.comment, values), { userId: decidedByUserId }, undefined, tx);
|
||||
if (effect.comment) await svc.addComment(target.id, interpolate(effect.comment, values), { userId: decidedByUserId, runId: decision.originRunId }, { clientRequestId: lockKey }, tx);
|
||||
result = { issueId: updated?.id, status: updated?.status };
|
||||
} else if (effect.type === "assign_issue") {
|
||||
const updated = await svc.update(
|
||||
@@ -510,7 +532,7 @@ export function decisionService(db: Db, options: DecisionServiceOptions) {
|
||||
postCommitActivityPublications,
|
||||
postCommitIssueActions,
|
||||
);
|
||||
if (effect.comment) await svc.addComment(target.id, interpolate(effect.comment, values), { userId: decidedByUserId }, undefined, tx);
|
||||
if (effect.comment) await svc.addComment(target.id, interpolate(effect.comment, values), { userId: decidedByUserId, runId: decision.originRunId }, { clientRequestId: lockKey }, tx);
|
||||
result = { issueId: updated?.id };
|
||||
} else if (effect.type === "resolve_blocker") {
|
||||
const current = await tx.select({ id: issueRelations.issueId }).from(issueRelations).where(and(eq(issueRelations.companyId, decision.companyId), eq(issueRelations.relatedIssueId, target.id), eq(issueRelations.type, "blocks")));
|
||||
@@ -543,7 +565,7 @@ export function decisionService(db: Db, options: DecisionServiceOptions) {
|
||||
postCommitIssueActions,
|
||||
);
|
||||
}
|
||||
await svc.addComment(target.id, interpolate(effect.reasonComment, values), { userId: decidedByUserId }, undefined, tx);
|
||||
await svc.addComment(target.id, interpolate(effect.reasonComment, values), { userId: decidedByUserId, runId: decision.originRunId }, { clientRequestId: lockKey }, tx);
|
||||
result = { cancelledIssueIds: cancelled };
|
||||
}
|
||||
const activity = await effectAudit(tx as unknown as Db, decision, execution.id, effect, "executed", decidedByUserId, originResponsibleUserId, result);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { and, asc, desc, eq, inArray, isNull, sql } from "drizzle-orm";
|
||||
import { assertAgentInteraction } from "./agent-interaction-policy.js";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
documentAnnotationAnchorSnapshots,
|
||||
@@ -423,6 +424,8 @@ export function documentAnnotationService(db: Db) {
|
||||
`);
|
||||
const doc = await getIssueDocument(issueId, key, tx);
|
||||
if (!doc) throw notFound("Document not found");
|
||||
await assertAgentInteraction(tx, { companyId: doc.companyId, actorAgentId: actor.agentId,
|
||||
operation: "comment", issueId, texts: [input.body], lock: true });
|
||||
if (
|
||||
input.baseRevisionId !== doc.latestRevisionId
|
||||
|| input.baseRevisionNumber !== doc.latestRevisionNumber
|
||||
@@ -682,6 +685,8 @@ export function documentAnnotationService(db: Db) {
|
||||
) => db.transaction(async (tx) => {
|
||||
const thread = await getThreadForIssue(issueId, key, threadId, tx);
|
||||
if (!thread) throw notFound("Annotation thread not found");
|
||||
await assertAgentInteraction(tx, { companyId: thread.companyId, actorAgentId: actor.agentId,
|
||||
operation: "comment", issueId, texts: [input.body], lock: true });
|
||||
const now = new Date();
|
||||
const linkedIssueComment = await assertLinkedIssueComment(issueId, input.issueCommentId, tx);
|
||||
const [comment] = await tx
|
||||
@@ -857,6 +862,8 @@ export function documentAnnotationService(db: Db) {
|
||||
) => db.transaction(async (tx) => {
|
||||
const thread = await getThreadForIssue(issueId, key, threadId, tx);
|
||||
if (!thread) throw notFound("Annotation thread not found");
|
||||
await assertAgentInteraction(tx, { companyId: thread.companyId, actorAgentId: actor.agentId,
|
||||
operation: "edit", issueId, lock: true });
|
||||
if (!input.status || input.status === thread.status) return thread;
|
||||
|
||||
const now = new Date();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
|
||||
import { assertAgentInteraction } from "./agent-interaction-policy.js";
|
||||
import { notifyIssueRecovery } from "./issue-recovery-notifications.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, asc, desc, eq, sql } from "drizzle-orm";
|
||||
@@ -228,7 +229,7 @@ export function documentService(db: Db) {
|
||||
createdByRunId?: string | null;
|
||||
sourceTrust?: typeof documents.$inferInsert.sourceTrust;
|
||||
lockedDocumentStrategy?: "conflict" | "create_new_document";
|
||||
}) => {
|
||||
}, options: { initialTaskCreation?: boolean } = {}) => {
|
||||
const key = normalizeDocumentKey(input.key);
|
||||
const issue = await db
|
||||
.select({ id: issues.id, companyId: issues.companyId })
|
||||
@@ -270,6 +271,16 @@ export function documentService(db: Db) {
|
||||
.where(and(eq(issueDocuments.issueId, issue.id), eq(issueDocuments.key, key)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
await assertAgentInteraction(tx, {
|
||||
companyId: issue.companyId, actorAgentId: input.createdByAgentId,
|
||||
operation: options.initialTaskCreation && !existing ? "mention" : "edit", issueId: issue.id,
|
||||
texts: [
|
||||
...(!existing || input.body !== existing.latestBody ? [input.body] : []),
|
||||
...(input.title !== undefined && input.title !== existing?.title ? [input.title ?? ""] : []),
|
||||
input.changeSummary ?? "",
|
||||
], lock: true,
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
if (existing.lockedAt) {
|
||||
if (input.lockedDocumentStrategy === "create_new_document") {
|
||||
@@ -649,6 +660,10 @@ export function documentService(db: Db) {
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!revision) throw notFound("Document revision not found");
|
||||
await assertAgentInteraction(tx, {
|
||||
companyId: existing.companyId, actorAgentId: input.createdByAgentId,
|
||||
operation: "edit", issueId: input.issueId, texts: [revision.body, revision.title ?? ""], lock: true,
|
||||
});
|
||||
if (existing.latestRevisionId === revision.id) {
|
||||
throw conflict("Selected revision is already the latest revision", {
|
||||
currentRevisionId: existing.latestRevisionId,
|
||||
@@ -728,6 +743,10 @@ export function documentService(db: Db) {
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!existing) throw notFound("Document not found");
|
||||
await assertAgentInteraction(tx, {
|
||||
companyId: existing.companyId, actorAgentId: input.lockedByAgentId,
|
||||
operation: "edit", issueId: input.issueId, lock: true,
|
||||
});
|
||||
if (existing.lockedAt) {
|
||||
return {
|
||||
changed: false as const,
|
||||
@@ -765,7 +784,7 @@ export function documentService(db: Db) {
|
||||
});
|
||||
},
|
||||
|
||||
unlockIssueDocument: async (issueId: string, rawKey: string) => {
|
||||
unlockIssueDocument: async (issueId: string, rawKey: string, actorAgentId?: string | null) => {
|
||||
const key = normalizeDocumentKey(rawKey);
|
||||
return db.transaction(async (tx) => {
|
||||
const existing = await tx
|
||||
@@ -776,6 +795,9 @@ export function documentService(db: Db) {
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!existing) throw notFound("Document not found");
|
||||
await assertAgentInteraction(tx, {
|
||||
companyId: existing.companyId, actorAgentId, operation: "edit", issueId, lock: true,
|
||||
});
|
||||
if (!existing.lockedAt) {
|
||||
return {
|
||||
changed: false as const,
|
||||
@@ -813,7 +835,7 @@ export function documentService(db: Db) {
|
||||
});
|
||||
},
|
||||
|
||||
deleteIssueDocument: async (issueId: string, rawKey: string) => {
|
||||
deleteIssueDocument: async (issueId: string, rawKey: string, actorAgentId?: string | null) => {
|
||||
const key = normalizeDocumentKey(rawKey);
|
||||
return db.transaction(async (tx) => {
|
||||
const existing = await tx
|
||||
@@ -824,6 +846,9 @@ export function documentService(db: Db) {
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!existing) return null;
|
||||
await assertAgentInteraction(tx, {
|
||||
companyId: existing.companyId, actorAgentId, operation: "edit", issueId, lock: true,
|
||||
});
|
||||
if (existing.lockedAt) {
|
||||
throw conflict("Document is locked", {
|
||||
key: existing.key,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { verifyBlockerStallWake } from "../recovery/blocker-stall-wakeups.js";
|
||||
import { CONTEXT_COMPACTION_WAKE_REASON, ContextCompactionError, isContextCompactionRun, readContextCompactionRequest } from "../context-compaction.js";
|
||||
import { isProcessRunAdmissionClosed } from "./run-admission.js";
|
||||
import { deliveredQueuedCommentIds } from "../queued-comment-delivery.js";
|
||||
import { hasAutomaticInteractionDeliveryHold, INTERACTION_DELIVERY_DENIED, recheckQueuedRunInteractions, withInteractionDeliveryOrigin } from "../agent-interaction-delivery.js";
|
||||
import { deferredWorkIntentWait } from "../work-intent-outbox.js";
|
||||
import { notifyIssueRecovery } from "../issue-recovery-notifications.js";
|
||||
import { createHeartbeatTimerPolicy } from "./timer-policy.js";
|
||||
@@ -860,6 +861,23 @@ export function createHeartbeatQueue(db: Db, dependencies: HeartbeatQueueDepende
|
||||
deferredCancellations?: Array<() => Promise<void>>,
|
||||
) {
|
||||
if (run.status !== "queued") return run;
|
||||
const interactionDelivery = await recheckQueuedRunInteractions(db, run);
|
||||
if (interactionDelivery.run.status !== "queued") return null;
|
||||
run = { ...run, contextSnapshot: interactionDelivery.run.contextSnapshot,
|
||||
resultJson: interactionDelivery.run.resultJson, updatedAt: interactionDelivery.run.updatedAt };
|
||||
if (interactionDelivery.denied) {
|
||||
const cancelled = await setRunStatus(run.id, "cancelled", {
|
||||
finishedAt: new Date(), error: "Queued agent input is no longer permitted.",
|
||||
errorCode: INTERACTION_DELIVERY_DENIED,
|
||||
});
|
||||
if (cancelled) {
|
||||
await setWakeupStatus(run.wakeupRequestId, "skipped", { finishedAt: new Date(), error: INTERACTION_DELIVERY_DENIED });
|
||||
await appendRunEvent(cancelled, { eventType: "lifecycle", stream: "system", level: "warn",
|
||||
message: "Queued agent input was refused after its interaction permission changed.", payload: { code: INTERACTION_DELIVERY_DENIED, denials: interactionDelivery.denials } });
|
||||
await releaseIssueExecutionAndPromote(cancelled, { suppressImmediateRecovery: true, deferredPostCommitEffects });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const [caseHandoffReceipt] = run.wakeupRequestId ? await db.select().from(caseHandoffs).where(and(
|
||||
eq(caseHandoffs.wakeupRequestId, run.wakeupRequestId), eq(caseHandoffs.companyId, run.companyId),
|
||||
)).limit(1) : [];
|
||||
@@ -1917,7 +1935,7 @@ export function createHeartbeatQueue(db: Db, dependencies: HeartbeatQueueDepende
|
||||
reason: wake.reason ?? undefined, payload, contextSnapshot: context,
|
||||
requestedByActorType: wake.requestedByActorType as WakeupOptions["requestedByActorType"],
|
||||
requestedByActorId: wake.requestedByActorId ?? undefined,
|
||||
}, wake.id).catch(err => {
|
||||
}, wake.id, wake.payload?.interactionOrigin).catch(err => {
|
||||
if (err instanceof PersistedExecutionWaitRefusal) return;
|
||||
scope.onError?.(err);
|
||||
logger.warn({ err, requestId: wake.id }, "a message saved during a pause could not resume");
|
||||
@@ -2332,7 +2350,7 @@ export function createHeartbeatQueue(db: Db, dependencies: HeartbeatQueueDepende
|
||||
return promise;
|
||||
}
|
||||
|
||||
async function enqueueWakeup(agentId: string, opts: WakeupOptions = {}, executionWaitRequestId?: string) {
|
||||
async function enqueueWakeup(agentId: string, opts: WakeupOptions = {}, executionWaitRequestId?: string, savedInteractionOrigin?: unknown) {
|
||||
const source = opts.source ?? "on_demand";
|
||||
const triggerDetail = opts.triggerDetail ?? null;
|
||||
const contextSnapshot: Record<string, unknown> = {
|
||||
@@ -2384,7 +2402,10 @@ export function createHeartbeatQueue(db: Db, dependencies: HeartbeatQueueDepende
|
||||
// Keep each request's own server-derived origin, including coalesced wakes.
|
||||
// A run's merged context cannot establish which caller authored one receipt.
|
||||
// Overwrite caller-supplied nested context rather than trusting it.
|
||||
payload = { ...payload, [DEFERRED_WAKE_CONTEXT_KEY]: { ...enrichedContextSnapshot } };
|
||||
payload = withInteractionDeliveryOrigin(
|
||||
{ ...payload, [DEFERRED_WAKE_CONTEXT_KEY]: { ...enrichedContextSnapshot } },
|
||||
opts.requestedByActorType, opts.requestedByActorId, savedInteractionOrigin, opts.requestedByActorCompanyId,
|
||||
);
|
||||
let issueId =
|
||||
readNonEmptyString(enrichedContextSnapshot.issueId) ?? issueIdFromPayload;
|
||||
if (executionReconciliationWake && !issueId) return null;
|
||||
@@ -2415,6 +2436,10 @@ export function createHeartbeatQueue(db: Db, dependencies: HeartbeatQueueDepende
|
||||
|
||||
let agent = await getAgent(agentId);
|
||||
if (!agent) throw notFound("Agent not found");
|
||||
if (issueId && await hasAutomaticInteractionDeliveryHold(db, {
|
||||
companyId: agent.companyId, issueId, agentId, payload, context: enrichedContextSnapshot,
|
||||
actorType: opts.requestedByActorType, actorId: opts.requestedByActorId,
|
||||
})) return null;
|
||||
if (issueId) {
|
||||
const [pending] = await db.select().from(caseHandoffs).where(and(eq(caseHandoffs.companyId, agent.companyId), eq(caseHandoffs.issueId, issueId),
|
||||
inArray(caseHandoffs.state, ["awaiting_release", "pending_dispatch"]))).limit(1);
|
||||
@@ -5243,7 +5268,7 @@ export function createHeartbeatQueue(db: Db, dependencies: HeartbeatQueueDepende
|
||||
statusDecisionSource: "native_status_decision",
|
||||
nativeStatusWakeIntentId: candidate.id,
|
||||
},
|
||||
});
|
||||
}, undefined, candidate.payload?.interactionOrigin);
|
||||
|
||||
const delivered = await db
|
||||
.select()
|
||||
|
||||
@@ -828,6 +828,7 @@ export interface WakeupOptions {
|
||||
idempotencyKey?: string | null;
|
||||
requestedByActorType?: "user" | "agent" | "system";
|
||||
requestedByActorId?: string | null;
|
||||
requestedByActorCompanyId?: string;
|
||||
contextSnapshot?: Record<string, unknown>;
|
||||
issueStateGuard?: {
|
||||
statuses: string[];
|
||||
|
||||
@@ -8,6 +8,7 @@ import { NativeControllerRecoveryWaitingError } from "../native-runtime/native-c
|
||||
import { describeFailedAttachment } from "../native-runtime/remote-warm-session-recovery.js";
|
||||
|
||||
import { usesNativeUsageDelivery } from "../native-runtime/native-usage-accounting.js";
|
||||
import { INTERACTION_DELIVERY_DENIED, preparedRunInteractionDeliveryDenied } from "../agent-interaction-delivery.js";
|
||||
import { DELIVERY_QUEUES } from "../delivery-work-notifications.js";
|
||||
import { readRemoteWorkspaceAuthority, saveRemoteWorkspace } from "../remote-workspaces.js";
|
||||
import { environmentService } from "../environments.js";
|
||||
@@ -250,7 +251,13 @@ export async function executeHeartbeatRuntime(db: Db, input: HeartbeatRuntimeExe
|
||||
const { nativeInstructionWorkingCopy, collectStoppedInstructions, nativeInstructionReservation, releaseInstructionCopy } = input.instructions;
|
||||
const { providerTraceCapture, nativeRunnerPreparationSpans, attestedQuestionResponseAtMs, attemptStartedAtMs, environmentAcquireStartedAtMs, environmentRealizeEndedAtMs } = input.trace;
|
||||
const { executionControl, executionPhaseContext, dispatchResolvedInteractionContinuationWithAtomicGate } = input.control;
|
||||
const dispatchWithTiming: HeartbeatRuntimeExecutionInput["control"]["dispatchResolvedInteractionContinuationWithAtomicGate"] = dispatch => {
|
||||
const dispatchWithTiming: HeartbeatRuntimeExecutionInput["control"]["dispatchResolvedInteractionContinuationWithAtomicGate"] = async dispatch => {
|
||||
if (await preparedRunInteractionDeliveryDenied(db, run)) {
|
||||
await input.services.cancelRunInternal(run.id, "Queued agent input is no longer permitted.", {
|
||||
errorCode: INTERACTION_DELIVERY_DENIED, suppressImmediateRecovery: true,
|
||||
});
|
||||
return { dispatched: false };
|
||||
}
|
||||
const providerDispatch = AsyncLocalStorage.bind(dispatch);
|
||||
return measureRunPhase("execution.dispatch_admission", () => dispatchResolvedInteractionContinuationWithAtomicGate(providerDispatch));
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface IssueAssignmentWakeupDeps {
|
||||
allowRunCoalescing?: boolean;
|
||||
requestedByActorType?: "user" | "agent" | "system";
|
||||
requestedByActorId?: string | null;
|
||||
requestedByActorCompanyId?: string;
|
||||
contextSnapshot?: Record<string, unknown>;
|
||||
durableChatRequest?: DurableChatWakeupRequest;
|
||||
},
|
||||
@@ -30,6 +31,7 @@ export function queueIssueAssignmentWakeup(input: {
|
||||
contextSource: string;
|
||||
requestedByActorType?: "user" | "agent" | "system";
|
||||
requestedByActorId?: string | null;
|
||||
requestedByActorCompanyId?: string;
|
||||
taskKey?: string | null;
|
||||
/** Latest issue comment that caused this wakeup. Included in both payload
|
||||
* and context so the heartbeat can build the exact turn that was requested. */
|
||||
@@ -58,6 +60,7 @@ export function queueIssueAssignmentWakeup(input: {
|
||||
},
|
||||
requestedByActorType: input.requestedByActorType,
|
||||
requestedByActorId: input.requestedByActorId ?? null,
|
||||
...(input.requestedByActorCompanyId ? { requestedByActorCompanyId: input.requestedByActorCompanyId } : {}),
|
||||
...(input.durableChatRequest
|
||||
? { durableChatRequest: input.durableChatRequest }
|
||||
: {}),
|
||||
|
||||
@@ -23,6 +23,9 @@ function createSelectChain(rows: SelectRow[] | ((table: unknown) => SelectRow[])
|
||||
where() {
|
||||
return query;
|
||||
},
|
||||
orderBy() {
|
||||
return query;
|
||||
},
|
||||
for() {
|
||||
return query;
|
||||
},
|
||||
@@ -64,7 +67,16 @@ function createFakeDb(args: {
|
||||
select: vi.fn(() => {
|
||||
selectCallCount += 1;
|
||||
const rows = selectCallCount === 1 ? [interactionRow] : (args.parentRows ?? []);
|
||||
return createSelectChain(table => getTableName(table as never) === "issues" ? [{ status: "in_progress" }] : rows);
|
||||
return createSelectChain(table => {
|
||||
if (getTableName(table as never) === "issues") return [{
|
||||
id: interactionRow.issueId, companyId: interactionRow.companyId, status: "in_progress",
|
||||
assigneeAgentId: interactionRow.createdByAgentId, conversationAgentId: null,
|
||||
}];
|
||||
if (getTableName(table as never) === "agents") return [{
|
||||
id: interactionRow.createdByAgentId, companyId: interactionRow.companyId, permissions: {},
|
||||
}];
|
||||
return rows;
|
||||
});
|
||||
}),
|
||||
update: vi.fn((table: unknown) => ({
|
||||
set(values: Record<string, unknown>) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { agentConfigurationChangeTargetId } from "./change-consent-gate.js";
|
||||
import { assertAgentInteraction } from "./agent-interaction-policy.js";
|
||||
import { resolveNewTaskParent } from "./issue-hierarchy.js";
|
||||
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
|
||||
import { notifyMergeConfirmationWork, type MergeConfirmationScope } from "./merged-pr-confirmation-notifications.js";
|
||||
@@ -177,7 +179,11 @@ type InteractionActor = {
|
||||
relayedHumanUserId?: string | null;
|
||||
};
|
||||
|
||||
async function assertInteractionRunWriteAllowed(tx: Db, issue: { id: string; companyId: string }, actor: InteractionActor) {
|
||||
async function assertInteractionRunWriteAllowed(tx: Db, issue: { id: string; companyId: string }, actor: InteractionActor, texts: string[] = []) {
|
||||
await assertAgentInteraction(tx, { companyId: issue.companyId, actorAgentId: actor.agentId,
|
||||
operation: "edit", issueId: issue.id, lock: true });
|
||||
await assertAgentInteraction(tx, { companyId: issue.companyId, actorAgentId: actor.agentId,
|
||||
operation: "comment", issueId: issue.id, texts, lock: true });
|
||||
if (!actor.agentId || !actor.runId) return;
|
||||
// Keep the same issue -> run lock order as task mutation and checkout.
|
||||
await tx.select({ id: issues.id }).from(issues)
|
||||
@@ -2433,7 +2439,7 @@ export function issueThreadInteractionService(
|
||||
const now = new Date();
|
||||
const postCommitActivityPublications: ActivityPublication[] = [];
|
||||
const result = await db.transaction(async (tx) => {
|
||||
await assertInteractionRunWriteAllowed(tx as unknown as Db, args.issue, args.actor);
|
||||
await assertInteractionRunWriteAllowed(tx as unknown as Db, args.issue, args.actor, [JSON.stringify(args.input)]);
|
||||
await notifyChatConfirmationResolution(tx, args.current);
|
||||
if (args.current.kind === "request_confirmation") await notifyChatRunMilestones(tx, args.current);
|
||||
else await notifyIssueReviewState(tx, args.current);
|
||||
@@ -2732,7 +2738,7 @@ export function issueThreadInteractionService(
|
||||
|
||||
const now = new Date();
|
||||
const updated = await db.transaction(async (tx) => {
|
||||
await assertInteractionRunWriteAllowed(tx as unknown as Db, args.issue, args.actor);
|
||||
await assertInteractionRunWriteAllowed(tx as unknown as Db, args.issue, args.actor, [JSON.stringify(args.input)]);
|
||||
await notifyChatConfirmationResolution(tx, args.current);
|
||||
if (args.current.kind === "request_confirmation") await notifyChatRunMilestones(tx, args.current);
|
||||
else await notifyIssueReviewState(tx, args.current);
|
||||
@@ -3639,7 +3645,7 @@ export function issueThreadInteractionService(
|
||||
replacementInteractionId: string;
|
||||
}> = [];
|
||||
for (const row of rows) {
|
||||
if (!row.createdByAgentId) continue;
|
||||
if (!row.createdByAgentId || isNativeCompletionReview(row)) continue;
|
||||
const groupKey = `${row.companyId}:${row.issueId}:${row.kind}:${row.createdByAgentId}`;
|
||||
const newest = newestByGroup.get(groupKey);
|
||||
if (!newest) {
|
||||
@@ -3934,7 +3940,22 @@ export function issueThreadInteractionService(
|
||||
// Idempotent reuse above stays allowed so retries of a pre-close
|
||||
// create keep returning the (by now expired) original.
|
||||
const result = await db.transaction(async (tx) => {
|
||||
await assertInteractionRunWriteAllowed(tx as unknown as Db, issue, actor);
|
||||
await assertInteractionRunWriteAllowed(tx as unknown as Db, issue, actor, [JSON.stringify(input)]);
|
||||
await assertAgentInteraction(tx, { companyId: issue.companyId, actorAgentId: actor.agentId,
|
||||
operation: "mention", issueId: issue.id, texts: [JSON.stringify(normalizedData)], lock: true });
|
||||
const configurationTarget = normalizedData.kind === "request_confirmation" || normalizedData.kind === "request_checkbox_confirmation"
|
||||
? normalizedData.payload.target : null;
|
||||
const configurationAgentId = configurationTarget?.type === "custom"
|
||||
? agentConfigurationChangeTargetId(configurationTarget.key) : null;
|
||||
if (configurationAgentId) await assertAgentInteraction(tx, {
|
||||
companyId: issue.companyId, actorAgentId: actor.agentId, operation: "configure",
|
||||
targetAgentId: configurationAgentId, lock: true,
|
||||
});
|
||||
if (normalizedData.addresseeAgentId || normalizedData.addresseeUserId) {
|
||||
await assertAgentInteraction(tx, { companyId: issue.companyId, actorAgentId: actor.agentId,
|
||||
operation: "assign", targetAgentId: normalizedData.addresseeAgentId,
|
||||
assigneeUserId: normalizedData.addresseeUserId, lock: true });
|
||||
}
|
||||
const [issueRow] = await tx
|
||||
.select({ executionPolicy: issues.executionPolicy, status: issues.status, assigneeAgentId: issues.assigneeAgentId, assigneeUserId: issues.assigneeUserId, conversationAgentId: issues.conversationAgentId, conversationUserId: issues.conversationUserId, identifier: issues.identifier })
|
||||
.from(issues)
|
||||
@@ -4192,6 +4213,8 @@ export function issueThreadInteractionService(
|
||||
eq(issueThreadInteractions.status, "pending"),
|
||||
ne(issueThreadInteractions.id, row.id),
|
||||
sql`${issueThreadInteractions.payload}->'qualityReview' IS NULL`,
|
||||
sql`not coalesce(${issueThreadInteractions.payload}->'target'->>'type' = 'custom'
|
||||
and ${issueThreadInteractions.payload}->'target'->>'key' = 'native_completion_review', false)`,
|
||||
);
|
||||
if (data.kind === "request_confirmation") {
|
||||
// The issue lock above excludes concurrent interaction creation.
|
||||
@@ -4462,7 +4485,7 @@ export function issueThreadInteractionService(
|
||||
await db.transaction(async (tx) => {
|
||||
await notifyChatConfirmationResolution(tx, current);
|
||||
await mutationOptions.beforeResolveInTransaction?.(tx);
|
||||
await assertInteractionRunWriteAllowed(tx as unknown as Db, issue, actor);
|
||||
await assertInteractionRunWriteAllowed(tx as unknown as Db, issue, actor, [JSON.stringify(input)]);
|
||||
if (sourceConversation) {
|
||||
await lockAcceptedChatHandoffSource(tx, {
|
||||
companyId: issue.companyId, conversationId: sourceConversation.id,
|
||||
@@ -4658,7 +4681,7 @@ export function issueThreadInteractionService(
|
||||
const data = submitIssueThreadInteractionVerdictsSchema.parse(input);
|
||||
const submission = await db.transaction(async (tx) => {
|
||||
await mutationOptions.beforeResolveInTransaction?.(tx);
|
||||
await assertInteractionRunWriteAllowed(tx as unknown as Db, issue, actor);
|
||||
await assertInteractionRunWriteAllowed(tx as unknown as Db, issue, actor, [JSON.stringify(input)]);
|
||||
// Match completion/confirmation ordering: issue -> interaction -> output.
|
||||
// A pending multi-item review can contain already-approved output slots,
|
||||
// so a replay must not lock those outputs ahead of task completion.
|
||||
@@ -4818,7 +4841,7 @@ export function issueThreadInteractionService(
|
||||
const [updated] = await db.transaction(async (tx) => {
|
||||
await notifyChatConfirmationResolution(tx, current);
|
||||
await mutationOptions.beforeResolveInTransaction?.(tx);
|
||||
await assertInteractionRunWriteAllowed(tx as unknown as Db, issue, actor);
|
||||
await assertInteractionRunWriteAllowed(tx as unknown as Db, issue, actor, [JSON.stringify(input)]);
|
||||
await notifyIssueReviewState(tx, { companyId: issue.companyId, issueId: issue.id });
|
||||
const rows = await tx.update(issueThreadInteractions)
|
||||
.set({
|
||||
@@ -5456,7 +5479,7 @@ export function issueThreadInteractionService(
|
||||
// review queue while the card is still pending, and an executable
|
||||
// request must not outlive a withdrawn card.
|
||||
const updated = await db.transaction(async (tx) => {
|
||||
await assertInteractionRunWriteAllowed(tx as unknown as Db, issue, actor);
|
||||
await assertInteractionRunWriteAllowed(tx as unknown as Db, issue, actor, [JSON.stringify(input)]);
|
||||
await notifyChatConfirmationResolution(tx, current);
|
||||
if (["ask_user_questions", "request_confirmation"].includes(current.kind)) await notifyChatRunMilestones(tx, current);
|
||||
else await notifyIssueReviewState(tx, current);
|
||||
@@ -5594,7 +5617,7 @@ export function issueThreadInteractionService(
|
||||
}
|
||||
if (!historicalAnswer) throw interactionIssueClosedError();
|
||||
}
|
||||
await assertInteractionRunWriteAllowed(tx as unknown as Db, issue, actor);
|
||||
await assertInteractionRunWriteAllowed(tx as unknown as Db, issue, actor, [JSON.stringify(input)]);
|
||||
await notifyChatRunMilestones(tx, current);
|
||||
await mutationOptions.beforeResolveInTransaction?.(tx);
|
||||
const resolvedAt = new Date();
|
||||
@@ -5719,7 +5742,7 @@ export function issueThreadInteractionService(
|
||||
const reason = data.reason?.trim() || null;
|
||||
const now = new Date();
|
||||
const updated = await db.transaction(async (tx) => {
|
||||
await assertInteractionRunWriteAllowed(tx as unknown as Db, issue, actor);
|
||||
await assertInteractionRunWriteAllowed(tx as unknown as Db, issue, actor, [JSON.stringify(input)]);
|
||||
await notifyChatConfirmationResolution(tx, current);
|
||||
if (["ask_user_questions", "request_confirmation"].includes(current.kind)) await notifyChatRunMilestones(tx, current);
|
||||
else await notifyIssueReviewState(tx, current);
|
||||
@@ -5819,7 +5842,7 @@ export function issueThreadInteractionService(
|
||||
|
||||
const reason = data.reason?.trim() || null;
|
||||
const updated = await db.transaction(async (tx) => {
|
||||
await assertInteractionRunWriteAllowed(tx as unknown as Db, issue, actor);
|
||||
await assertInteractionRunWriteAllowed(tx as unknown as Db, issue, actor, [JSON.stringify(input)]);
|
||||
await notifyChatRunMilestones(tx, current);
|
||||
const resolvedAt = new Date();
|
||||
const [row] = await tx
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
|
||||
import { assertAgentInteraction } from "./agent-interaction-policy.js";
|
||||
import { notifyAttentionRetentionWork } from "./retention-work-notifications.js";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { heartbeatRuns, issues, type Db } from "@paperclipai/db";
|
||||
@@ -79,6 +80,8 @@ export async function setIssueTitle(
|
||||
return { result: await saveReceipt({ id: issue.id, title: issue.title, titleNeedsGeneration: issue.titleNeedsGeneration, changed: false }), publication: null };
|
||||
}
|
||||
await notifyStatusCardIssueChange(tx, companyId);
|
||||
await assertAgentInteraction(tx, { companyId, actorAgentId: actor.actorType === "agent" ? actor.actorId : null,
|
||||
operation: "edit", issueId, texts: title === issue.title ? [] : [title], lock: true });
|
||||
await notifyAttentionRetentionWork(tx, { companyId });
|
||||
await tx.update(issues).set({ title, titleNeedsGeneration: false, updatedAt: new Date() }).where(predicate);
|
||||
await issueReferenceService(tx).syncIssue(issueId, tx);
|
||||
|
||||
+108
-17
@@ -1,4 +1,6 @@
|
||||
import { recordMcpAttachmentCleanup, type McpAttachmentCleanup } from "./public-mcp/upload-cleanup.js";
|
||||
import { assertAgentInteraction } from "./agent-interaction-policy.js";
|
||||
import { currentPluginInteractionOrigin } from "./plugin-interaction-context.js";
|
||||
import type { IssueDisplayStatus } from "@paperclipai/shared";
|
||||
import { issueDisplayStatusSql } from "./issue-display-status.js";
|
||||
import { notifyStrandedAgentStatus, notifyActiveRunSilence } from "./live-run-recovery-notifications.js";
|
||||
@@ -9747,18 +9749,19 @@ export function issueService(db: Db) {
|
||||
};
|
||||
},
|
||||
|
||||
createChild: async (parentIssueId: string, data: IssueChildCreateInput) => {
|
||||
const parent = await db
|
||||
createChild: async (parentIssueId: string, data: IssueChildCreateInput) => db.transaction(async childTx => {
|
||||
const childDb = childTx as unknown as Db;
|
||||
const parent = await childDb
|
||||
.select()
|
||||
.from(issues)
|
||||
.where(eq(issues.id, parentIssueId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!parent) throw notFound("Parent issue not found");
|
||||
await assertExecutionTaskParent(db, parent.companyId, parent.id);
|
||||
await assertExecutionTaskParent(childDb, parent.companyId, parent.id);
|
||||
|
||||
const idempotencyKey = data.idempotencyKey?.trim();
|
||||
if (idempotencyKey) {
|
||||
const existingChild = await db
|
||||
const existingChild = await childDb
|
||||
.select({ issue: issues })
|
||||
.from(issueCreateIdempotencyKeys)
|
||||
.innerJoin(issues, eq(issueCreateIdempotencyKeys.issueId, issues.id))
|
||||
@@ -9780,11 +9783,11 @@ export function issueService(db: Db) {
|
||||
await data.assertCanReuseIssue?.(existingChild);
|
||||
assertQualityReviewPolicyReuse(data.executionPolicy, existingChild.executionPolicy);
|
||||
data.onDeduplicated?.("idempotency_key");
|
||||
const [enriched] = await withIssueLabels(db, [existingChild]);
|
||||
const [enriched] = await withIssueLabels(childDb, [existingChild]);
|
||||
const [withRelations] = await withIssueRelationSummaries(
|
||||
parent.companyId,
|
||||
[enriched],
|
||||
db,
|
||||
childDb,
|
||||
);
|
||||
return {
|
||||
issue: withParentAdjustment(withRelations, null),
|
||||
@@ -9796,10 +9799,10 @@ export function issueService(db: Db) {
|
||||
// Under the agent depth limit a child of a child lands beside it; count
|
||||
// the requester's own fan-out either way.
|
||||
const { effectiveParentId } = confirmPlacement(
|
||||
await resolveNewTaskParent(db, parent.companyId, parent.id, data.parentDepthPolicy ?? "flatten"),
|
||||
await resolveNewTaskParent(childDb, parent.companyId, parent.id, data.parentDepthPolicy ?? "flatten"),
|
||||
data.expectedParentId,
|
||||
);
|
||||
const [{ childCount }] = await db
|
||||
const [{ childCount }] = await childDb
|
||||
.select({ childCount: sql<number>`count(*)::int` })
|
||||
.from(issues)
|
||||
.where(
|
||||
@@ -9858,8 +9861,9 @@ export function issueService(db: Db) {
|
||||
issueData.executionWorkspaceSettings as Record<string, unknown>,
|
||||
)
|
||||
: null;
|
||||
let child = await issueService(db).create(parent.companyId, {
|
||||
let child = await issueService(childDb).create(parent.companyId, {
|
||||
...issueData,
|
||||
createdByAgentId: issueData.createdByAgentId ?? actorAgentId,
|
||||
parentId: parent.id,
|
||||
projectId: childProjectId,
|
||||
projectWorkspaceId:
|
||||
@@ -9892,9 +9896,11 @@ export function issueService(db: Db) {
|
||||
});
|
||||
|
||||
if (blockParentUntilDone) {
|
||||
await db.transaction(async tx => {
|
||||
await childDb.transaction(async tx => {
|
||||
await tx.select({ id: issues.id }).from(issues).where(and(
|
||||
eq(issues.companyId, parent.companyId), eq(issues.id, parent.id))).for("update");
|
||||
await assertAgentInteraction(tx, { companyId: parent.companyId, actorAgentId: actorAgentId ?? issueData.createdByAgentId,
|
||||
operation: "edit", issueId: parent.id, lock: true });
|
||||
const existingBlockers = await tx
|
||||
.select({ blockerIssueId: issueRelations.issueId })
|
||||
.from(issueRelations)
|
||||
@@ -9922,7 +9928,7 @@ export function issueService(db: Db) {
|
||||
[child] = await withIssueRelationSummaries(
|
||||
parent.companyId,
|
||||
[child],
|
||||
db,
|
||||
childDb,
|
||||
);
|
||||
child = withParentAdjustment(child, parentAdjusted ?? null);
|
||||
}
|
||||
@@ -9931,7 +9937,7 @@ export function issueService(db: Db) {
|
||||
issue: child,
|
||||
parentBlockerAdded: Boolean(blockParentUntilDone),
|
||||
};
|
||||
},
|
||||
}),
|
||||
|
||||
decomposeAcceptedPlan: async (
|
||||
sourceIssueId: string,
|
||||
@@ -10303,6 +10309,9 @@ export function issueService(db: Db) {
|
||||
data: IssueCreateInput,
|
||||
dbOrTx: Db | DbTransaction = db,
|
||||
) => {
|
||||
const pluginOrigin = currentPluginInteractionOrigin();
|
||||
if (pluginOrigin) data = { ...data, createdByAgentId: pluginOrigin.agentId,
|
||||
createdByUserId: null, actorRunId: pluginOrigin.runId ?? data.actorRunId };
|
||||
const {
|
||||
workspaceSelection, workspaceSelectionSource = "explicit", workspaceSelectionActor,
|
||||
initialPlan,
|
||||
@@ -10365,7 +10374,14 @@ export function issueService(db: Db) {
|
||||
throw unprocessable("in_progress issues require an assignee");
|
||||
}
|
||||
const persist = async (tx: DbTransaction) => {
|
||||
await tx.select({ id: companies.id }).from(companies).where(eq(companies.id, companyId)).for("no key update");
|
||||
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`issue-privacy-tree:${companyId}`}, 0))`);
|
||||
await assertAgentInteraction(tx as unknown as Db, {
|
||||
companyId, actorAgentId: issueData.createdByAgentId,
|
||||
operation: "assign", targetAgentId: issueData.assigneeAgentId,
|
||||
assigneeUserId: issueData.assigneeUserId, parentIssueId: requestedParentId,
|
||||
texts: [resolvedTitle, issueData.description ?? "", initialPlan ?? ""], lock: true,
|
||||
});
|
||||
const parentResolution = confirmPlacement(
|
||||
await resolveNewTaskParent(tx, companyId, requestedParentId, parentDepthPolicy),
|
||||
expectedParentId,
|
||||
@@ -10377,6 +10393,10 @@ export function issueService(db: Db) {
|
||||
}
|
||||
parentAdjustment = parentResolution.adjustment;
|
||||
issueData.parentId = parentResolution.effectiveParentId;
|
||||
if (issueData.parentId && issueData.parentId !== requestedParentId) {
|
||||
await assertAgentInteraction(tx, { companyId, actorAgentId: issueData.createdByAgentId,
|
||||
operation: "edit", issueId: issueData.parentId, lock: true });
|
||||
}
|
||||
await assertParentInCompany(tx, companyId, issueData.parentId);
|
||||
await assertExecutionTaskParent(tx as unknown as Db, companyId, issueData.parentId);
|
||||
if (issueData.conversationAgentId && issueData.conversationUserId) {
|
||||
@@ -10965,7 +10985,7 @@ export function issueService(db: Db) {
|
||||
createdByAgentId: issueData.createdByAgentId, createdByUserId: issueData.createdByUserId,
|
||||
createdByRunId: actorRunId,
|
||||
sourceTrust: issue.sourceTrust,
|
||||
});
|
||||
}, { initialTaskCreation: true });
|
||||
}
|
||||
const [enriched] = await withIssueLabels(tx, [issue]);
|
||||
const [withRelations] = await withIssueRelationSummaries(
|
||||
@@ -11005,12 +11025,21 @@ export function issueService(db: Db) {
|
||||
importIssues: async (
|
||||
companyId: string,
|
||||
rows: ImportIssueRow[],
|
||||
actorAgentId?: string | null,
|
||||
actorCompanyId?: string | null,
|
||||
): Promise<void> => {
|
||||
if (rows.length === 0) return;
|
||||
const isolatedWorkspacesEnabled = (
|
||||
await instanceSettings.getExperimental()
|
||||
).enableIsolatedWorkspaces;
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.select({ id: companies.id }).from(companies).where(eq(companies.id, companyId)).for("no key update");
|
||||
const newIssueIds = new Set(rows.map(row => row.id));
|
||||
for (const row of rows) await assertAgentInteraction(tx, {
|
||||
companyId, actorAgentId, actorCompanyId: actorCompanyId ?? undefined, operation: "assign", targetAgentId: row.assigneeAgentId,
|
||||
parentIssueId: row.parentId && !newIssueIds.has(row.parentId) ? row.parentId : undefined,
|
||||
texts: [row.title, row.description ?? "", row.monitorNotes ?? ""], lock: true,
|
||||
});
|
||||
// Self-correcting counter: seed from max(issue_number) so a drifted
|
||||
// company counter cannot mint colliding identifiers, then reserve the
|
||||
// whole range in one bump instead of one-per-issue.
|
||||
@@ -11210,11 +11239,15 @@ export function issueService(db: Db) {
|
||||
*/
|
||||
addImportedComments: async (
|
||||
rows: ImportIssueCommentRow[],
|
||||
actorAgentId?: string | null,
|
||||
actorCompanyId?: string | null,
|
||||
): Promise<void> => {
|
||||
if (rows.length === 0) return;
|
||||
const censorUsernameInLogs = (await instanceSettings.getGeneral())
|
||||
.censorUsernameInLogs;
|
||||
await db.transaction(async (tx) => {
|
||||
for (const row of rows) await assertAgentInteraction(tx, { companyId: row.companyId, actorAgentId, actorCompanyId: actorCompanyId ?? undefined,
|
||||
operation: "mention", issueId: row.issueId, texts: [row.body], lock: true });
|
||||
for (const companyId of new Set(rows.map(row => row.companyId))) await notifyStatusCardIssueChange(tx, companyId);
|
||||
const commentRows = rows.map((row) => {
|
||||
const createdAt = row.createdAt ? new Date(row.createdAt) : null;
|
||||
@@ -11702,6 +11735,39 @@ export function issueService(db: Db) {
|
||||
.for("update")
|
||||
.then((rows: Array<typeof issues.$inferSelect>) => rows[0] ?? null);
|
||||
if (!receiptExisting) return null;
|
||||
if (Object.keys(issueData).length || nextLabelIds !== undefined || blockedByIssueIds !== undefined) await assertAgentInteraction(tx as unknown as Db, {
|
||||
companyId: receiptExisting.companyId, actorAgentId, operation: "edit", issueId: id,
|
||||
texts: [
|
||||
...(issueData.title !== undefined && issueData.title !== receiptExisting.title ? [issueData.title] : []),
|
||||
...(issueData.description !== undefined && issueData.description !== receiptExisting.description ? [issueData.description ?? ""] : []),
|
||||
], lock: true,
|
||||
});
|
||||
if (patch.assigneeAgentId !== undefined || patch.assigneeUserId !== undefined) {
|
||||
await assertAgentInteraction(tx as unknown as Db, {
|
||||
companyId: receiptExisting.companyId, actorAgentId, operation: "assign", issueId: id,
|
||||
targetAgentId: patch.assigneeAgentId === undefined ? receiptExisting.assigneeAgentId : patch.assigneeAgentId,
|
||||
assigneeUserId: patch.assigneeUserId === undefined ? receiptExisting.assigneeUserId : patch.assigneeUserId,
|
||||
lock: true,
|
||||
});
|
||||
}
|
||||
if (issueData.executionState !== undefined) {
|
||||
const previousParticipant = parseObject(parseObject(receiptExisting.executionState).currentParticipant);
|
||||
const nextParticipant = parseObject(parseObject(issueData.executionState).currentParticipant);
|
||||
if ((nextParticipant.agentId || nextParticipant.userId)
|
||||
&& (nextParticipant.agentId !== previousParticipant.agentId || nextParticipant.userId !== previousParticipant.userId
|
||||
|| parseObject(issueData.executionState).status !== parseObject(receiptExisting.executionState).status)) {
|
||||
await assertAgentInteraction(tx, { companyId: receiptExisting.companyId, actorAgentId, operation: "assign",
|
||||
targetAgentId: typeof nextParticipant.agentId === "string" ? nextParticipant.agentId : null,
|
||||
assigneeUserId: typeof nextParticipant.userId === "string" ? nextParticipant.userId : null, lock: true });
|
||||
}
|
||||
}
|
||||
if (issueData.parentId !== undefined && issueData.parentId !== receiptExisting.parentId) {
|
||||
for (const parentId of new Set([receiptExisting.parentId, issueData.parentId, privacySourceParentId])) {
|
||||
if (parentId) await assertAgentInteraction(tx as unknown as Db, {
|
||||
companyId: receiptExisting.companyId, actorAgentId, operation: "edit", issueId: parentId, lock: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Only a run the caller did not check is protected. A lock released in
|
||||
// between (for example by the route's own confirmed Stop) is fine.
|
||||
if (expectedExecutionLock && (
|
||||
@@ -12348,8 +12414,11 @@ export function issueService(db: Db) {
|
||||
return cleared;
|
||||
},
|
||||
|
||||
remove: (id: string) =>
|
||||
remove: (id: string, actorAgentId?: string | null) =>
|
||||
db.transaction(async (tx) => {
|
||||
const [task] = await tx.select({ companyId: issues.companyId }).from(issues).where(eq(issues.id, id));
|
||||
if (task) await assertAgentInteraction(tx, { companyId: task.companyId, actorAgentId,
|
||||
operation: "edit", issueId: id, lock: true });
|
||||
const attachmentAssetIds = await tx
|
||||
.select({ assetId: issueAttachments.assetId })
|
||||
.from(issueAttachments)
|
||||
@@ -12513,6 +12582,10 @@ export function issueService(db: Db) {
|
||||
)
|
||||
: isNull(issues.executionRunId);
|
||||
const updated = await db.transaction(async tx => {
|
||||
await assertAgentInteraction(tx as unknown as Db, {
|
||||
companyId: issueCompany.companyId, actorAgentId: agentId, operation: "assign",
|
||||
issueId: id, targetAgentId: agentId, lock: true,
|
||||
});
|
||||
await notifyStatusCardIssueChange(tx, issueCompany.companyId);
|
||||
await notifyIssueAttentionRetentionWork(tx, { companyId: issueCompany.companyId, issueId: id });
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.issueMonitors);
|
||||
@@ -13171,7 +13244,7 @@ export function issueService(db: Db) {
|
||||
);
|
||||
},
|
||||
|
||||
removeComment: async (commentId: string) => {
|
||||
removeComment: async (commentId: string, actorAgentId?: string | null) => {
|
||||
const currentUserRedactionOptions = {
|
||||
enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs,
|
||||
};
|
||||
@@ -13181,6 +13254,7 @@ export function issueService(db: Db) {
|
||||
const [source] = await tx.select({ companyId: issueComments.companyId, issueId: issueComments.issueId })
|
||||
.from(issueComments).where(eq(issueComments.id, commentId));
|
||||
if (!source) return null;
|
||||
await assertAgentInteraction(tx as unknown as Db, { ...source, actorAgentId, operation: "comment", lock: true });
|
||||
await notifyExternalConversationSettlement(tx, source);
|
||||
await notifyStatusCardIssueChange(tx, source.companyId);
|
||||
const [comment] = await tx
|
||||
@@ -13221,6 +13295,9 @@ export function issueService(db: Db) {
|
||||
.from(issueComments).where(and(eq(issueComments.id, commentId), isNull(issueComments.deletedAt)))
|
||||
.for("update");
|
||||
if (!source) return null;
|
||||
await assertAgentInteraction(tx as unknown as Db, {
|
||||
...source, actorAgentId: actor.actorType === "agent" ? actor.agentId : null, operation: "comment", lock: true,
|
||||
});
|
||||
await notifyExternalConversationSettlement(tx, source);
|
||||
await notifyStatusCardIssueChange(tx, source.companyId);
|
||||
await notifyChatInboundWakeNotices(tx, source);
|
||||
@@ -13291,6 +13368,10 @@ export function issueService(db: Db) {
|
||||
},
|
||||
dbOrTx: any = db,
|
||||
): Promise<IssueComment> {
|
||||
const pluginOrigin = currentPluginInteractionOrigin();
|
||||
if (pluginOrigin) {
|
||||
actor = { ...actor, agentId: pluginOrigin.agentId, runId: pluginOrigin.runId ?? undefined, userId: undefined };
|
||||
}
|
||||
if (dbOrTx === db) {
|
||||
const append = () =>
|
||||
measureRunTransaction(db, "response", async (tx) => {
|
||||
@@ -13326,6 +13407,10 @@ export function issueService(db: Db) {
|
||||
if (issue.conversationAgentId && actor.userId && !(await instanceSettingsService(dbOrTx).getExperimental()).enableAgentChat) {
|
||||
throw unprocessable("Agent Chat is disabled in Experimental settings");
|
||||
}
|
||||
await assertAgentInteraction(dbOrTx, {
|
||||
companyId: issue.companyId, actorAgentId: actor.agentId, operation: "comment",
|
||||
issueId, texts: [body], lock: true,
|
||||
});
|
||||
const currentUserRedactionOptions = {
|
||||
// Keep every read on the caller's transaction connection. Re-entering
|
||||
// the outer pool here can deadlock when concurrent transactions fill
|
||||
@@ -13498,7 +13583,7 @@ export function issueService(db: Db) {
|
||||
: (options?.metadata ?? null),
|
||||
);
|
||||
let comment: typeof issueComments.$inferSelect | null = null;
|
||||
if (createdByRunId && !options?.proactiveChatMessage) {
|
||||
if (createdByRunId && !options?.proactiveChatMessage && !(actor.userId && options?.clientRequestId)) {
|
||||
const existing = await dbOrTx
|
||||
.select()
|
||||
.from(issueComments)
|
||||
@@ -13978,6 +14063,10 @@ export function issueService(db: Db) {
|
||||
.for("update");
|
||||
if (!lockedIssue) throw notFound("Issue not found");
|
||||
}
|
||||
await assertAgentInteraction(tx as unknown as Db, {
|
||||
companyId: issue.companyId, actorAgentId: input.createdByAgentId,
|
||||
operation: "edit", issueId: issue.id, lock: true,
|
||||
});
|
||||
const registeredRun =
|
||||
input.createdByRunId && isUuidLike(input.createdByRunId)
|
||||
? await tx
|
||||
@@ -14239,7 +14328,7 @@ export function issueService(db: Db) {
|
||||
.where(eq(issueAttachments.id, id))
|
||||
.then((rows) => rows[0] ?? null),
|
||||
|
||||
removeAttachment: async (id: string, options?: { mcpCleanup?: McpAttachmentCleanup | null }) =>
|
||||
removeAttachment: async (id: string, options?: { mcpCleanup?: McpAttachmentCleanup | null; actorAgentId?: string | null }) =>
|
||||
db.transaction(async (tx) => {
|
||||
const existing = await tx
|
||||
.select({
|
||||
@@ -14266,6 +14355,8 @@ export function issueService(db: Db) {
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!existing) return null;
|
||||
|
||||
await assertAgentInteraction(tx, { companyId: existing.companyId, actorAgentId: options?.actorAgentId,
|
||||
operation: "edit", issueId: existing.issueId, lock: true });
|
||||
await notifyIssueRecovery(tx, { companyId: existing.companyId });
|
||||
await tx.delete(issueAttachments).where(eq(issueAttachments.id, id));
|
||||
await tx.delete(assets).where(eq(assets.id, existing.assetId));
|
||||
|
||||
@@ -3,9 +3,10 @@ import type { AutomaticCompletionReviewScope } from "../automatic-completion-rev
|
||||
import { DELIVERY_QUEUES } from "../delivery-work-notifications.js";
|
||||
import type { createDeliveryWorkCoordinator } from "../delivery-work-coordinator.js";
|
||||
import { registerScopedDeadlineWork } from "../scoped-deadline-work.js";
|
||||
import { and, asc, eq, gt, inArray, isNull, sql } from "drizzle-orm";
|
||||
import { and, asc, eq, gt, inArray, isNull, or, sql } from "drizzle-orm";
|
||||
import {
|
||||
completionContracts,
|
||||
heartbeatRuns,
|
||||
issueThreadInteractions,
|
||||
issues,
|
||||
nativeRunFinalizations,
|
||||
@@ -52,6 +53,11 @@ function findAutomaticReviewCandidates(db: Db, options: { issueId?: string; comp
|
||||
eq(statusDecisions.runId, issueThreadInteractions.sourceRunId),
|
||||
),
|
||||
)
|
||||
.innerJoin(
|
||||
heartbeatRuns,
|
||||
and(eq(heartbeatRuns.id, issueThreadInteractions.sourceRunId),
|
||||
eq(heartbeatRuns.companyId, issueThreadInteractions.companyId)),
|
||||
)
|
||||
.innerJoin(
|
||||
workAssessments,
|
||||
and(
|
||||
@@ -72,7 +78,8 @@ function findAutomaticReviewCandidates(db: Db, options: { issueId?: string; comp
|
||||
and(
|
||||
eq(issueThreadInteractions.status, "pending"),
|
||||
eq(issueThreadInteractions.kind, "request_confirmation"),
|
||||
isNull(issueThreadInteractions.createdByAgentId),
|
||||
or(isNull(issueThreadInteractions.createdByAgentId),
|
||||
eq(issueThreadInteractions.createdByAgentId, heartbeatRuns.agentId)),
|
||||
isNull(issueThreadInteractions.createdByUserId),
|
||||
eq(statusDecisions.applicationState, "applied"),
|
||||
eq(statusDecisions.toStatus, "in_review"),
|
||||
@@ -162,7 +169,10 @@ async function dismissCandidates(db: Db, candidates: Awaited<ReturnType<typeof f
|
||||
eq(issueThreadInteractions.kind, interaction.kind),
|
||||
eq(issueThreadInteractions.sourceRunId, decision.runId),
|
||||
eq(issueThreadInteractions.idempotencyKey, interaction.idempotencyKey!),
|
||||
isNull(issueThreadInteractions.createdByAgentId), isNull(issueThreadInteractions.createdByUserId),
|
||||
interaction.createdByAgentId
|
||||
? eq(issueThreadInteractions.createdByAgentId, interaction.createdByAgentId)
|
||||
: isNull(issueThreadInteractions.createdByAgentId),
|
||||
isNull(issueThreadInteractions.createdByUserId),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
|
||||
@@ -2952,7 +2952,7 @@ describe("native external-chat response wait", () => {
|
||||
expect(gate).toMatchObject({
|
||||
kind: "request_confirmation",
|
||||
status: "pending",
|
||||
createdByAgentId: null,
|
||||
createdByAgentId: fixture.agentId,
|
||||
createdByUserId: null,
|
||||
});
|
||||
await db
|
||||
@@ -3629,11 +3629,19 @@ describe("native external-chat response wait", () => {
|
||||
} finally { await coordinator.stop(); vi.useRealTimers(); }
|
||||
});
|
||||
|
||||
it.each(["telegram", "discord"] as const)(
|
||||
"presents the exact %s response without resolving a pre-existing native completion review",
|
||||
async (provider) => {
|
||||
it.each([
|
||||
["telegram", "agent"],
|
||||
["discord", "agent"],
|
||||
["telegram", "legacy_system"],
|
||||
] as const)(
|
||||
"presents the exact %s response without resolving a pre-existing native completion review with %s provenance",
|
||||
async (provider, provenance) => {
|
||||
const fixture = await seedWaitTurn(provider);
|
||||
const gate = await seedPriorCompletionReview(fixture);
|
||||
if (provenance === "legacy_system") {
|
||||
await db.update(issueThreadInteractions).set({ createdByAgentId: null })
|
||||
.where(eq(issueThreadInteractions.id, gate.id));
|
||||
}
|
||||
const selectedComment = await issueService(db).addComment(
|
||||
fixture.issueId,
|
||||
"Prepared exactly these files for this chat response.",
|
||||
@@ -3796,9 +3804,14 @@ describe("native external-chat response wait", () => {
|
||||
approvalId: approval!.id,
|
||||
});
|
||||
} else if (kind === "agent_gate") {
|
||||
const [otherAgent] = await db.insert(agents).values({
|
||||
companyId: fixture.companyId,
|
||||
name: "Unrelated review author",
|
||||
adapterType: "process",
|
||||
}).returning();
|
||||
await db
|
||||
.update(issueThreadInteractions)
|
||||
.set({ createdByAgentId: fixture.agentId })
|
||||
.set({ createdByAgentId: otherAgent!.id })
|
||||
.where(eq(issueThreadInteractions.id, gate.id));
|
||||
} else if (kind === "new_attention") {
|
||||
await db.insert(issueThreadInteractions).values({
|
||||
|
||||
@@ -181,7 +181,6 @@ async function reviewPresentationEvidence(
|
||||
if (
|
||||
!gate ||
|
||||
gate.kind !== "request_confirmation" ||
|
||||
gate.createdByAgentId ||
|
||||
gate.createdByUserId ||
|
||||
!gate.sourceRunId ||
|
||||
gate.sourceRunId === run.id ||
|
||||
@@ -197,8 +196,16 @@ async function reviewPresentationEvidence(
|
||||
return null;
|
||||
const [origin, competingInteraction, competingApproval] = await Promise.all([
|
||||
db
|
||||
.select({ id: statusDecisions.id, decisionJson: statusDecisions.decisionJson })
|
||||
.select({ id: statusDecisions.id, decisionJson: statusDecisions.decisionJson, agentId: heartbeatRuns.agentId })
|
||||
.from(statusDecisions)
|
||||
.innerJoin(
|
||||
heartbeatRuns,
|
||||
and(
|
||||
eq(heartbeatRuns.id, statusDecisions.runId),
|
||||
eq(heartbeatRuns.companyId, input.companyId),
|
||||
eq(heartbeatRuns.nativeIssueId, input.issueId),
|
||||
),
|
||||
)
|
||||
.innerJoin(
|
||||
statusDecisionEffects,
|
||||
and(
|
||||
@@ -255,7 +262,8 @@ async function reviewPresentationEvidence(
|
||||
.limit(1)
|
||||
.then((rows) => rows[0]),
|
||||
]);
|
||||
if (!origin || competingInteraction || competingApproval) return null;
|
||||
if (!origin || competingInteraction || competingApproval
|
||||
|| (gate.createdByAgentId !== null && gate.createdByAgentId !== origin.agentId)) return null;
|
||||
const reviewEffects = Array.isArray(origin.decisionJson.effects) ? origin.decisionJson.effects : [];
|
||||
const matchesReviewRequest = reviewEffects.some((value) => {
|
||||
const effect = record(value);
|
||||
|
||||
@@ -2,12 +2,13 @@ import { resolveExternalTestDatabaseUrl } from "../../__tests__/helpers/external
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { agents, companies, completionContracts, createDb, heartbeatRuns, issues, nativeRunFinalizations, nativeRunResults, pluginManagedResources, plugins, routineRuns, routines, workAssessments } from "@paperclipai/db";
|
||||
import { agentWakeupRequests, agents, companies, completionContracts, createDb, heartbeatRuns, issueComments, issueRelations, issueThreadInteractions, issues, nativeRunFinalizations, nativeRunResults, pluginManagedResources, plugins, routineRuns, routines, statusDecisionEffects, statusDecisions, workAssessments } from "@paperclipai/db";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
|
||||
import { routineService } from "../routines.js";
|
||||
import { commitNativeStatusDecision } from "./status-decision-committer.js";
|
||||
import { NATIVE_STATUS_ARBITER_POLICY_VERSION, type NativeStatusDecision } from "./status-arbiter.js";
|
||||
import { INTERACTION_DELIVERY_DENIED } from "../agent-interaction-delivery.js";
|
||||
|
||||
const external = resolveExternalTestDatabaseUrl("PAPERCLIP_NATIVE_ROUTINE_STATUS_TEST_DATABASE_URL");
|
||||
const support = external ? { supported: true, reason: undefined } : await getEmbeddedPostgresTestSupport();
|
||||
@@ -73,6 +74,66 @@ if (!support.supported) console.warn(`Skipping native routine status tests: ${su
|
||||
const replay = await commit(f, status); expect(replay.replayed).toBe(true); expect(replay.decision.id).toBe(first.decision.id);
|
||||
expect((await db.select().from(routineRuns).where(eq(routineRuns.id, f.invocationId)))[0]).toEqual(saved);
|
||||
});
|
||||
it("retains the native decision author when requesting a protected agent reviewer", async () => {
|
||||
const f = await seed(), reviewer = randomUUID();
|
||||
await db.insert(agents).values({ id: reviewer, companyId, name: "Protected reviewer", adapterType: "process", status: "active",
|
||||
permissions: { interactions: { canReceiveAssignmentsFromAgents: false } } });
|
||||
const requestReview = () => commitNativeStatusDecision({ db, companyId, issueId: f.issueId, runId: f.runId, assessmentId: f.assessmentId,
|
||||
priorStatus: "in_progress", priorStatusVersion: f.priorStatusVersion, priorDecisionId: f.priorDecisionId,
|
||||
decision: { policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION, statusAction: "preserve", toStatus: "in_progress",
|
||||
reasonCode: "review_requested", unblockDescriptor: null, effects: [{ kind: "bind_reviewer", ownerAgentId: reviewer,
|
||||
ownerUserId: null, prompt: "Review this work" }] },
|
||||
});
|
||||
await expect(requestReview()).rejects.toMatchObject({ status: 403, details: expect.objectContaining({ actorAgentId: agentId,
|
||||
permission: "canReceiveAssignmentsFromAgents" }) });
|
||||
expect(await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.issueId, f.issueId))).toEqual([]);
|
||||
expect(await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.agentId, reviewer))).toEqual([]);
|
||||
expect(await db.select().from(statusDecisions).where(eq(statusDecisions.issueId, f.issueId))).toEqual([]);
|
||||
expect((await db.select().from(issues).where(eq(issues.id, f.issueId)))[0].status).toBe("in_progress");
|
||||
expect((await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.runId)))[0])
|
||||
.toMatchObject({ phase: "terminal_failure", failureCode: "agent_interaction_denied", nextAttemptAt: null });
|
||||
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)))[0].status).toBe("running");
|
||||
await db.update(agents).set({ permissions: {} }).where(eq(agents.id, reviewer));
|
||||
await expect(requestReview()).rejects.toMatchObject({ status: 403,
|
||||
details: expect.objectContaining({ permission: "canReceiveAssignmentsFromAgents" }) });
|
||||
});
|
||||
it.each([false, true])("commits child completion while permanently refusing its protected parent report (dependency: %s)", async dependency => {
|
||||
const f = await seed(), parentId = randomUUID(), parentAgentId = randomUUID();
|
||||
const report = "PRIVATE_REPORT_TEXT: agent-authored completion instructions";
|
||||
await db.insert(agents).values({ id: parentAgentId, companyId, name: "Protected parent", adapterType: "process", status: "active",
|
||||
permissions: { interactions: { canReceiveCommentsFromAgents: false } } });
|
||||
await db.insert(issues).values({ id: parentId, companyId, title: "Parent task", status: dependency ? "blocked" : "in_progress", assigneeAgentId: parentAgentId });
|
||||
await db.update(issues).set({ parentId }).where(eq(issues.id, f.issueId));
|
||||
await db.update(nativeRunResults).set({ resultJson: { result: { summary: report } } }).where(eq(nativeRunResults.runId, f.runId));
|
||||
if (dependency) await db.insert(issueRelations).values({ companyId, issueId: f.issueId, relatedIssueId: parentId, type: "blocks" });
|
||||
|
||||
const completed = await commit(f, "done");
|
||||
expect((await db.select().from(issues).where(eq(issues.id, f.issueId)))[0].status).toBe("done");
|
||||
expect((await db.select().from(routineRuns).where(eq(routineRuns.id, f.invocationId)))[0].status).toBe("completed");
|
||||
expect((await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.runId)))[0])
|
||||
.toMatchObject({ phase: "committed", failureCode: null, nextAttemptAt: null });
|
||||
expect((await db.select().from(issues).where(eq(issues.id, parentId)))[0].status).toBe(dependency ? "blocked" : "in_progress");
|
||||
expect(await db.select().from(issueComments).where(eq(issueComments.issueId, parentId))).toEqual([]);
|
||||
|
||||
const wakes = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.agentId, parentAgentId)).orderBy(agentWakeupRequests.id);
|
||||
const reportWake = wakes.find(wake => wake.reason === "issue_children_completed");
|
||||
expect(reportWake).toMatchObject({ status: "skipped", error: INTERACTION_DELIVERY_DENIED, finishedAt: expect.any(Date), runId: null,
|
||||
payload: { interactionOrigin: { actorType: "agent", actorId: agentId, operation: "comment" },
|
||||
interactionPermissionDenials: { assignment: { permission: "canReceiveCommentsFromAgents", targetAgentId: parentAgentId } } } });
|
||||
expect(JSON.stringify(wakes)).not.toContain(report);
|
||||
expect(wakes).toHaveLength(dependency ? 2 : 1);
|
||||
const dependencyWake = wakes.find(wake => wake.reason === "issue_blockers_resolved");
|
||||
if (dependency) expect(dependencyWake).toMatchObject({ status: "queued", requestedByActorType: "system", error: null,
|
||||
payload: { resolvedBlockerIssueId: f.issueId, blockerIssueIds: [f.issueId], interactionOrigin: { actorType: "system" } } });
|
||||
|
||||
const effects = await db.select().from(statusDecisionEffects).where(eq(statusDecisionEffects.decisionId, completed.decision.id));
|
||||
const reportEffect = effects.find(effect => effect.targetId === reportWake!.id);
|
||||
expect(reportEffect).toMatchObject({ deliveryState: "skipped", lastError: INTERACTION_DELIVERY_DENIED, deliveredAt: null, nextAttemptAt: null });
|
||||
expect(JSON.stringify(reportEffect)).not.toContain(report);
|
||||
await db.update(agents).set({ permissions: {} }).where(eq(agents.id, parentAgentId));
|
||||
expect((await commit(f, "done")).replayed).toBe(true);
|
||||
expect(await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.agentId, parentAgentId)).orderBy(agentWakeupRequests.id)).toEqual(wakes);
|
||||
});
|
||||
it.each(["ordinary", "wrong-invocation", "wrong-issue", "foreign-company"])("does not settle an invocation with %s custody", async mode => {
|
||||
const f = await seed(mode); const [before] = await db.select().from(routineRuns).where(eq(routineRuns.id, f.invocationId));
|
||||
await commit(f, "done");
|
||||
|
||||
@@ -2,9 +2,9 @@ import type { ObsoletePolicyReviewScope } from "../obsolete-policy-review-notifi
|
||||
import { DELIVERY_QUEUES } from "../delivery-work-notifications.js";
|
||||
import type { createDeliveryWorkCoordinator } from "../delivery-work-coordinator.js";
|
||||
import { registerScopedDeadlineWork } from "../scoped-deadline-work.js";
|
||||
import { and, asc, eq, gt, inArray, isNull, sql } from "drizzle-orm";
|
||||
import { and, asc, eq, gt, inArray, isNull, or, sql } from "drizzle-orm";
|
||||
import {
|
||||
approvals, issueApprovals, issueThreadInteractions, issues,
|
||||
approvals, heartbeatRuns, issueApprovals, issueThreadInteractions, issues,
|
||||
nativeRunFinalizations, statusDecisionEffects, statusDecisions, workAssessments,
|
||||
type Db,
|
||||
} from "@paperclipai/db";
|
||||
@@ -37,6 +37,8 @@ function findObsoletePolicyReviews(db: Db, options: { runIds?: string[]; scope?:
|
||||
eq(statusDecisions.issueId, issueThreadInteractions.issueId),
|
||||
eq(statusDecisions.runId, issueThreadInteractions.sourceRunId),
|
||||
))
|
||||
.innerJoin(heartbeatRuns, and(eq(heartbeatRuns.id, issueThreadInteractions.sourceRunId),
|
||||
eq(heartbeatRuns.companyId, issueThreadInteractions.companyId)))
|
||||
.innerJoin(workAssessments, and(
|
||||
eq(workAssessments.id, statusDecisions.assessmentId),
|
||||
eq(workAssessments.companyId, statusDecisions.companyId),
|
||||
@@ -45,7 +47,8 @@ function findObsoletePolicyReviews(db: Db, options: { runIds?: string[]; scope?:
|
||||
.where(and(
|
||||
eq(issueThreadInteractions.status, "pending"),
|
||||
eq(issueThreadInteractions.kind, "request_confirmation"),
|
||||
isNull(issueThreadInteractions.createdByAgentId),
|
||||
or(isNull(issueThreadInteractions.createdByAgentId),
|
||||
eq(issueThreadInteractions.createdByAgentId, heartbeatRuns.agentId)),
|
||||
isNull(issueThreadInteractions.createdByUserId),
|
||||
eq(statusDecisions.applicationState, "applied"),
|
||||
eq(statusDecisions.reasonCode, "completion_review_required"),
|
||||
@@ -97,7 +100,10 @@ async function dismissCandidates(db: Db, candidates: Awaited<ReturnType<typeof f
|
||||
eq(issueThreadInteractions.kind, interaction.kind),
|
||||
eq(issueThreadInteractions.sourceRunId, decision.runId),
|
||||
eq(issueThreadInteractions.idempotencyKey, interaction.idempotencyKey!),
|
||||
isNull(issueThreadInteractions.createdByAgentId), isNull(issueThreadInteractions.createdByUserId),
|
||||
interaction.createdByAgentId
|
||||
? eq(issueThreadInteractions.createdByAgentId, interaction.createdByAgentId)
|
||||
: isNull(issueThreadInteractions.createdByAgentId),
|
||||
isNull(issueThreadInteractions.createdByUserId),
|
||||
)).returning({ id: issueThreadInteractions.id });
|
||||
if (!cancelled) return;
|
||||
const terminalInteraction = await issueThreadInteractionService(tx as unknown as Db).getById(cancelled.id);
|
||||
|
||||
@@ -1945,6 +1945,13 @@ describe("PaperclipControlPlanePort conformance", () => {
|
||||
const agentReview = (await db.select().from(issueThreadInteractions)
|
||||
.where(eq(issueThreadInteractions.issueId, issueId)))
|
||||
.find((entry) => entry.addresseeAgentId === reviewerAgentId)!;
|
||||
await issueThreadInteractionService(db).sweepSupersededPendingRequestConfirmations();
|
||||
expect(await db.select({ status: issueThreadInteractions.status, createdByAgentId: issueThreadInteractions.createdByAgentId })
|
||||
.from(issueThreadInteractions).where(eq(issueThreadInteractions.issueId, issueId)))
|
||||
.toEqual([
|
||||
{ status: "pending", createdByAgentId: identity.agentId },
|
||||
{ status: "pending", createdByAgentId: identity.agentId },
|
||||
]);
|
||||
expect(reviewWakes[0]).toMatchObject({
|
||||
companyId: identity.companyId, status: "queued", reason: "native_completion_review",
|
||||
payload: { issueId, nativeReviewInteractionId: agentReview.id },
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { assertAgentInteraction } from "../agent-interaction-policy.js";
|
||||
import { authorizationDeniedDetails } from "../authorization.js";
|
||||
import { budgetServiceInTransaction } from "../budgets.js";
|
||||
import { resolveTaskWatchdogMutationScope, taskWatchdogScopeAllowsIssueMutation } from "../task-watchdog-scope.js";
|
||||
import { resolveNewTaskParent } from "../issue-hierarchy.js";
|
||||
@@ -1215,7 +1217,7 @@ export class PaperclipRunnerToolAuthority {
|
||||
if (!this.definitions().some(tool => tool.name === DOWNLOAD_ARTIFACT) || current.issue.workMode !== "standard") throw forbidden("Artifact installation is unavailable to this run or provider");
|
||||
const decision = await authorizationService(this.db).decide({ actor: this.#privacyActor(current.run), action: "issue:mutate",
|
||||
resource: { type: "issue", companyId: this.binding.companyId, issueId: current.issue.id } });
|
||||
if (!decision.allowed) throw forbidden(decision.explanation);
|
||||
if (!decision.allowed) throw forbidden(decision.explanation, authorizationDeniedDetails(decision));
|
||||
const freshAsset = await assetService(this.db).getById(input.artifactId);
|
||||
if (!freshAsset || freshAsset.companyId !== asset.companyId || freshAsset.objectKey !== asset.objectKey || freshAsset.byteSize !== input.byteSize || freshAsset.sha256 !== input.sha256
|
||||
|| !(await canActorReadAssetContent(this.db, { ...this.#privacyActor(current.run), keyScope: this.#apiScope(current) }, freshAsset))) throw forbidden("Artifact is not available to this actor");
|
||||
@@ -1279,7 +1281,7 @@ export class PaperclipRunnerToolAuthority {
|
||||
if (!this.#imageGenerationAdvertised(current.issue.workMode)) throw forbidden("Image generation is unavailable to this run or provider");
|
||||
const decision = await authorizationService(this.db).decide({ actor: this.#privacyActor(current.run), action: "issue:mutate",
|
||||
resource: { type: "issue", companyId: this.binding.companyId, issueId: current.issue.id } });
|
||||
if (!decision.allowed) throw forbidden(decision.explanation);
|
||||
if (!decision.allowed) throw forbidden(decision.explanation, authorizationDeniedDetails(decision));
|
||||
// #boundContext checks company and agent budgets; spending also needs the task's project.
|
||||
if (current.issue.projectId && await budgetServiceInTransaction(this.db).getInvocationBlock(this.binding.companyId, this.binding.agentId,
|
||||
{ issueId: current.issue.id, projectId: current.issue.projectId })) throw forbidden("Company or budget authority unavailable");
|
||||
@@ -1940,7 +1942,10 @@ export class PaperclipRunnerToolAuthority {
|
||||
type: "issue", companyId: this.binding.companyId, issueId: parent.id, projectId: parent.projectId,
|
||||
parentIssueId: parent.parentId, assigneeAgentId: parent.assigneeAgentId, assigneeUserId: parent.assigneeUserId, status: parent.status,
|
||||
}, scope: { issueId: parent.id, projectId: parent.projectId, parentIssueId: parent.parentId } });
|
||||
if (!decision.allowed) return decision.explanation;
|
||||
if (!decision.allowed) {
|
||||
if (decision.interactionDenial) throw forbidden(decision.explanation, authorizationDeniedDetails(decision));
|
||||
return decision.explanation;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
@@ -2014,7 +2019,7 @@ export class PaperclipRunnerToolAuthority {
|
||||
resource: { type: "issue", companyId: this.binding.companyId, ...scope },
|
||||
scope,
|
||||
});
|
||||
if (!decision.allowed) throw forbidden(decision.explanation);
|
||||
if (!decision.allowed) throw forbidden(decision.explanation, authorizationDeniedDetails(decision));
|
||||
if (assigneeAgentId) await assertAssignableAgent(tx, this.binding.companyId, assigneeAgentId, { kind: "work" });
|
||||
if (assigneeUserId) await this.#assertCompanyPerson(tx, assigneeUserId);
|
||||
const project = projectId
|
||||
@@ -2231,7 +2236,7 @@ export class PaperclipRunnerToolAuthority {
|
||||
parentIssueId: target.parentId, assigneeAgentId: action === "tasks:assign" ? assigneeAgentId : target.assigneeAgentId,
|
||||
assigneeUserId: action === "tasks:assign" ? assigneeUserId : target.assigneeUserId, status: target.status,
|
||||
}, scope: { issueId: taskId, projectId: target.projectId, parentIssueId: target.parentId, assigneeAgentId, assigneeUserId } });
|
||||
if (!decision.allowed) throw forbidden(decision.explanation);
|
||||
if (!decision.allowed) throw forbidden(decision.explanation, authorizationDeniedDetails(decision));
|
||||
}
|
||||
if (assigneeAgentId) await assertAssignableAgent(tx, this.binding.companyId, assigneeAgentId, { kind: "work" });
|
||||
if (assigneeUserId) await this.#assertCompanyPerson(tx, assigneeUserId);
|
||||
@@ -2250,6 +2255,13 @@ export class PaperclipRunnerToolAuthority {
|
||||
throw new Error("paperclip_runner_reassignment_conflict: refresh search_tasks before deciding again");
|
||||
}
|
||||
};
|
||||
const assertReassignmentComment = async (tx: Db, target: typeof issues.$inferSelect) => {
|
||||
if (target.assigneeAgentId === assigneeAgentId && target.assigneeUserId === assigneeUserId) return;
|
||||
await assertAgentInteraction(tx, { companyId: this.binding.companyId, actorAgentId: this.binding.agentId,
|
||||
operation: "comment", issueId: taskId, texts: [reason], lock: true });
|
||||
await assertAgentInteraction(tx, { companyId: this.binding.companyId, actorAgentId: this.binding.agentId,
|
||||
operation: "comment", targetAgentId: assigneeAgentId, delivery: true, lock: true });
|
||||
};
|
||||
// Stop outside row locks: the runtime needs those locks to acknowledge cancellation.
|
||||
// A second locked check below prevents any intervening ownership/version change.
|
||||
const prepared = receiptOnly ? null : await this.db.transaction(async (tx) => {
|
||||
@@ -2258,6 +2270,7 @@ export class PaperclipRunnerToolAuthority {
|
||||
const target = await authorize(tx as unknown as Db, context.run);
|
||||
if (await priorReceipt(tx as unknown as Db)) return null;
|
||||
validate(target);
|
||||
await assertReassignmentComment(tx as unknown as Db, target);
|
||||
return target;
|
||||
});
|
||||
// Cancellation and ownership commit cannot share locks. If the second
|
||||
@@ -2319,6 +2332,7 @@ export class PaperclipRunnerToolAuthority {
|
||||
if (run && ["running", "queued", "scheduled_retry"].includes(run.status)) throw new Error("paperclip_runner_reassignment_stop_unconfirmed");
|
||||
}
|
||||
const changed = target.assigneeAgentId !== assigneeAgentId || target.assigneeUserId !== assigneeUserId;
|
||||
await assertReassignmentComment(tx, target);
|
||||
const updated = changed ? await issueService(tx).update(taskId, {
|
||||
companyGuard: this.binding.companyId, assigneeAgentId, assigneeUserId,
|
||||
status: target.status === "in_progress" ? "todo" : target.status,
|
||||
@@ -2420,7 +2434,7 @@ export class PaperclipRunnerToolAuthority {
|
||||
type: "issue", companyId: this.binding.companyId, issueId: target.id,
|
||||
status: target.status, assigneeAgentId: target.assigneeAgentId, assigneeUserId: target.assigneeUserId,
|
||||
} });
|
||||
if (!decision.allowed) throw forbidden(decision.explanation);
|
||||
if (!decision.allowed) throw forbidden(decision.explanation, authorizationDeniedDetails(decision));
|
||||
}
|
||||
await assertCanManageIssueMonitor(access, { actor }, target.companyId, target.assigneeAgentId, true);
|
||||
if (target.assigneeAgentId !== this.binding.agentId || target.assigneeUserId ||
|
||||
@@ -3070,6 +3084,7 @@ export class PaperclipRunnerToolAuthority {
|
||||
};
|
||||
return this.db.transaction(async tx => {
|
||||
if (operationId === "create_task") {
|
||||
await tx.select({ id: companies.id }).from(companies).where(eq(companies.id, this.binding.companyId)).for("no key update");
|
||||
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`issue-privacy-tree:${this.binding.companyId}`}, 0))`);
|
||||
}
|
||||
return mutate(tx);
|
||||
@@ -3133,7 +3148,7 @@ export class PaperclipRunnerToolAuthority {
|
||||
projectId: context.issue.projectId, parentIssueId: context.issue.parentId,
|
||||
status: context.issue.status, assigneeAgentId: context.issue.assigneeAgentId, assigneeUserId: context.issue.assigneeUserId },
|
||||
});
|
||||
if (!decision.allowed) throw forbidden(decision.explanation);
|
||||
if (!decision.allowed) throw forbidden(decision.explanation, authorizationDeniedDetails(decision));
|
||||
await this.binding.assertBridgeAuthority?.(tx);
|
||||
}
|
||||
await assertNoCaseHandoffMutation(tx, this.binding.runId, this.binding.issueId);
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { notifyNativeStatusWakeWork } from "../deferred-execution-notifications.js";
|
||||
import { assertAgentInteraction } from "../agent-interaction-policy.js";
|
||||
import { forbidden, HttpError } from "../../errors.js";
|
||||
import { agentInteractionDeliveryDenial, agentWakeInteractionTexts, INTERACTION_DELIVERY_DENIED, withInteractionDeliveryOrigin } from "../agent-interaction-delivery.js";
|
||||
import { notifyExternalAgentSettlement } from "../../modules/external-agents/index.js";
|
||||
import { lockCurrentNativeWorkAssessmentEvidence } from "./work-assessments.js";
|
||||
import { hasNativeWorkspaceFinalizationOwner } from "./native-workspace-finalization-recovery-state.js";
|
||||
@@ -25,7 +28,7 @@ import { readNativePlanWait, type NativePlanWaitSource } from "./native-plan-wai
|
||||
import { activeIssueInteractionCondition } from "../issue-question-context.js";
|
||||
import { isConversation } from "../agent-conversations.js";
|
||||
import { hasPendingNativeChildCompletion, type NativeChildCompletionRecipient } from "./native-child-completion-delivery.js";
|
||||
import { and, asc, desc, eq, inArray, notInArray, sql } from "drizzle-orm";
|
||||
import { and, asc, desc, eq, inArray, notInArray, or, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import type { CreateIssueThreadInteraction } from "@paperclipai/shared";
|
||||
import {
|
||||
@@ -208,6 +211,7 @@ export type NativeMaterializedStatusEffect = {
|
||||
targetType: string;
|
||||
targetId: string | null;
|
||||
payload: Record<string, unknown>;
|
||||
deliveryState?: "skipped";
|
||||
};
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
@@ -463,6 +467,8 @@ async function enqueueWake(input: {
|
||||
idempotencyKey: string;
|
||||
payload: Record<string, unknown>;
|
||||
contextSnapshot?: Record<string, unknown>;
|
||||
originRunId?: string;
|
||||
originOperation?: "assign" | "comment";
|
||||
}) {
|
||||
const existing = await input.tx
|
||||
.select({ id: agentWakeupRequests.id })
|
||||
@@ -471,18 +477,32 @@ async function enqueueWake(input: {
|
||||
and(
|
||||
eq(agentWakeupRequests.companyId, input.companyId),
|
||||
eq(agentWakeupRequests.idempotencyKey, input.idempotencyKey),
|
||||
inArray(agentWakeupRequests.status, [
|
||||
or(inArray(agentWakeupRequests.status, [
|
||||
"queued",
|
||||
"deferred_issue_execution",
|
||||
"claimed",
|
||||
"completed",
|
||||
]),
|
||||
]), eq(agentWakeupRequests.error, INTERACTION_DELIVERY_DENIED)),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (existing) return existing.id;
|
||||
await notifyNativeStatusWakeWork(input.tx, { companyId: input.companyId });
|
||||
const [origin] = input.originRunId ? await input.tx.select({ agentId: heartbeatRuns.agentId }).from(heartbeatRuns)
|
||||
.where(and(eq(heartbeatRuns.id, input.originRunId), eq(heartbeatRuns.companyId, input.companyId))) : [];
|
||||
const actorType = origin ? "agent" : "system";
|
||||
const actorId = origin?.agentId ?? "native-status-committer";
|
||||
const originPayload = withInteractionDeliveryOrigin({}, actorType, actorId, { actorType, actorId });
|
||||
if (originPayload.interactionOrigin) {
|
||||
originPayload.interactionOrigin = { ...record(originPayload.interactionOrigin), operation: input.originOperation ?? "assign" };
|
||||
}
|
||||
const denial = origin ? await agentInteractionDeliveryDenial(input.tx, {
|
||||
companyId: input.companyId, actorAgentId: origin.agentId, targetAgentId: input.agentId, issueId: input.issueId,
|
||||
operation: input.originOperation ?? "assign", texts: agentWakeInteractionTexts(input.payload), delivery: true, lock: true,
|
||||
}) : null;
|
||||
const refusedParentReport = denial && input.reason === "issue_children_completed" && input.originOperation === "comment";
|
||||
if (denial && !refusedParentReport) throw forbidden("Agent interaction is not permitted.", denial);
|
||||
if (!denial) await notifyNativeStatusWakeWork(input.tx, { companyId: input.companyId });
|
||||
const inserted = await input.tx
|
||||
.insert(agentWakeupRequests)
|
||||
.values({
|
||||
@@ -491,10 +511,18 @@ async function enqueueWake(input: {
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
reason: input.reason,
|
||||
payload: {
|
||||
...(denial ? { status: "skipped", error: INTERACTION_DELIVERY_DENIED, finishedAt: new Date() } : {}),
|
||||
payload: denial ? {
|
||||
issueId: input.issueId,
|
||||
taskId: input.issueId,
|
||||
sourceRunId: input.originRunId,
|
||||
...originPayload,
|
||||
interactionPermissionDenials: { assignment: denial },
|
||||
} : {
|
||||
issueId: input.issueId,
|
||||
taskId: input.issueId,
|
||||
...input.payload,
|
||||
...originPayload,
|
||||
_paperclipWakeContext: {
|
||||
issueId: input.issueId,
|
||||
taskId: input.issueId,
|
||||
@@ -514,6 +542,13 @@ async function enqueueWake(input: {
|
||||
return inserted.id;
|
||||
}
|
||||
|
||||
async function refusedWakePolicy(tx: Db, companyId: string, wakeId: string) {
|
||||
const [wake] = await tx.select({ error: agentWakeupRequests.error, payload: agentWakeupRequests.payload })
|
||||
.from(agentWakeupRequests).where(and(eq(agentWakeupRequests.id, wakeId), eq(agentWakeupRequests.companyId, companyId)));
|
||||
return wake?.error === INTERACTION_DELIVERY_DENIED
|
||||
? record(record(wake.payload).interactionPermissionDenials).assignment : null;
|
||||
}
|
||||
|
||||
async function validateGovernanceGate(
|
||||
tx: Db,
|
||||
input: {
|
||||
@@ -578,6 +613,7 @@ async function materializeDecisionEffect(input: {
|
||||
companyId: string;
|
||||
issue: typeof issues.$inferSelect;
|
||||
runId: string;
|
||||
actorAgentId: string | null;
|
||||
decisionId: string;
|
||||
effect: NativeStatusEffect;
|
||||
failpoint?: NativeStatusCommitFailpoint;
|
||||
@@ -625,7 +661,7 @@ async function materializeDecisionEffect(input: {
|
||||
],
|
||||
},
|
||||
},
|
||||
{ systemId: "native-status-committer", runId: input.runId },
|
||||
{ agentId: input.actorAgentId, systemId: "native-status-committer", runId: input.runId },
|
||||
);
|
||||
return {
|
||||
effectKind: effect.kind,
|
||||
@@ -673,7 +709,8 @@ async function materializeDecisionEffect(input: {
|
||||
const interaction = await issueThreadInteractionService(input.tx).create(
|
||||
input.issue,
|
||||
reviewInput,
|
||||
{ systemId: "native-status-committer", runId: input.runId },
|
||||
{ agentId: input.actorAgentId, systemId: "native-status-committer", runId: input.runId },
|
||||
{ supersedePendingSiblingInteractions: false },
|
||||
);
|
||||
// The card and its next actor commit together. The post-commit dispatcher
|
||||
// revalidates this exact review before granting a scoped reviewer run.
|
||||
@@ -684,6 +721,7 @@ async function materializeDecisionEffect(input: {
|
||||
const reviewerWakeId = effect.ownerAgentId && interaction.effectiveResolverPolicy !== "human_only"
|
||||
? await enqueueWake({
|
||||
tx: input.tx,
|
||||
originRunId: input.runId,
|
||||
companyId: input.companyId,
|
||||
issueId: input.issue.id,
|
||||
agentId: effect.ownerAgentId,
|
||||
@@ -709,6 +747,7 @@ async function materializeDecisionEffect(input: {
|
||||
if (effect.kind === "notify_owner") {
|
||||
const wakeId = await enqueueWake({
|
||||
tx: input.tx,
|
||||
originRunId: input.runId,
|
||||
companyId: input.companyId,
|
||||
issueId: input.issue.id,
|
||||
agentId: effect.agentId,
|
||||
@@ -736,7 +775,7 @@ async function materializeDecisionEffect(input: {
|
||||
status: "todo",
|
||||
priority: input.issue.priority,
|
||||
assigneeAgentId: effect.agentId,
|
||||
createdByAgentId: input.issue.assigneeAgentId,
|
||||
createdByAgentId: input.actorAgentId,
|
||||
workMode: "standard",
|
||||
idempotencyKey: `native-delegation:${input.decisionId}`,
|
||||
inheritExecutionWorkspaceFromIssueId: input.issue.id,
|
||||
@@ -786,6 +825,7 @@ async function materializeDecisionEffect(input: {
|
||||
if (effect.continuationKind === "monitor") throw new Error("Monitor delivery belongs to the issue monitor scheduler");
|
||||
const wakeId = await enqueueWake({
|
||||
tx: input.tx,
|
||||
originRunId: input.runId,
|
||||
companyId: input.companyId,
|
||||
issueId: input.issue.id,
|
||||
agentId: effect.agentId,
|
||||
@@ -810,6 +850,9 @@ async function materializeDecisionEffect(input: {
|
||||
}
|
||||
if (effect.kind === "bind_blocker") {
|
||||
failAt("blocker_materialization", input.failpoint);
|
||||
await assertAgentInteraction(input.tx, { companyId: input.companyId, actorAgentId: input.actorAgentId,
|
||||
operation: "assign", issueId: input.issue.id, targetAgentId: typeof effect.owner === "object" ? effect.owner.agentId : null,
|
||||
texts: [effect.action], lock: true });
|
||||
const [bound] = await input.tx
|
||||
.update(issues)
|
||||
.set({
|
||||
@@ -828,6 +871,7 @@ async function materializeDecisionEffect(input: {
|
||||
if (effect.owner !== "board") {
|
||||
wakeId = await enqueueWake({
|
||||
tx: input.tx,
|
||||
originRunId: input.runId,
|
||||
companyId: input.companyId,
|
||||
issueId: input.issue.id,
|
||||
agentId: effect.owner.agentId,
|
||||
@@ -863,6 +907,7 @@ async function materializeDecisionEffect(input: {
|
||||
agentId: effect.agentId,
|
||||
reason: "issue_status_changed",
|
||||
idempotencyKey: `native-status:${input.decisionId}:retry`,
|
||||
originRunId: input.runId,
|
||||
payload: {
|
||||
nativeDecisionId: input.decisionId,
|
||||
retryCause: effect.cause,
|
||||
@@ -1677,6 +1722,10 @@ export async function commitNativeStatusDecision(input: {
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!coordinator)
|
||||
throw new Error("native_finalization_coordinator_missing");
|
||||
if (coordinator.failureCode === "agent_interaction_denied") {
|
||||
throw forbidden("This native interaction was permanently refused by agent permissions.",
|
||||
record(record(coordinator.failureDetail).interactionDenial));
|
||||
}
|
||||
const issue = await tx
|
||||
.select()
|
||||
.from(issues)
|
||||
@@ -1690,6 +1739,8 @@ export async function commitNativeStatusDecision(input: {
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!issue) throw new NativeStatusRaceError();
|
||||
const [originRun] = await tx.select({ agentId: heartbeatRuns.agentId }).from(heartbeatRuns)
|
||||
.where(and(eq(heartbeatRuns.id, input.runId), eq(heartbeatRuns.companyId, input.companyId)));
|
||||
if (input.requireReassessmentSource) {
|
||||
const expected = input.requireReassessmentSource;
|
||||
const now = Date.now();
|
||||
@@ -2260,6 +2311,7 @@ export async function commitNativeStatusDecision(input: {
|
||||
issue,
|
||||
runId: input.runId,
|
||||
decisionId: decisionRow.id,
|
||||
actorAgentId: originRun?.agentId ?? null,
|
||||
effect,
|
||||
failpoint: input.failpoint,
|
||||
preMaterializedEffects,
|
||||
@@ -2291,7 +2343,7 @@ export async function commitNativeStatusDecision(input: {
|
||||
statusVersion: input.priorStatusVersion + 1,
|
||||
lastStatusDecisionId: decisionRow.id,
|
||||
unblockDescriptor: input.decision.unblockDescriptor,
|
||||
actorAgentId: null,
|
||||
actorAgentId: originRun?.agentId ?? null,
|
||||
actorUserId: null,
|
||||
},
|
||||
tx,
|
||||
@@ -2400,6 +2452,8 @@ export async function commitNativeStatusDecision(input: {
|
||||
: null;
|
||||
const wakeId = await enqueueWake({
|
||||
tx: tx as unknown as Db,
|
||||
originRunId: isCompletedChildParent ? input.runId : undefined,
|
||||
originOperation: isCompletedChildParent ? "comment" : undefined,
|
||||
companyId: input.companyId,
|
||||
issueId: dependent.id,
|
||||
agentId: dependent.assigneeAgentId,
|
||||
@@ -2414,22 +2468,35 @@ export async function commitNativeStatusDecision(input: {
|
||||
},
|
||||
contextSnapshot: childCompletionContext ?? undefined,
|
||||
});
|
||||
const reportDenial = isCompletedChildParent ? await refusedWakePolicy(tx as unknown as Db, input.companyId, wakeId) : null;
|
||||
materialized.push({
|
||||
effectKind: isCompletedChildParent
|
||||
? "parent_dependency_wake"
|
||||
: "dependency_wake",
|
||||
targetType: "agent_wakeup_request",
|
||||
targetId: wakeId,
|
||||
...(reportDenial ? { deliveryState: "skipped" as const } : {}),
|
||||
payload: {
|
||||
dependentIssueId: dependent.id,
|
||||
resolvedBlockerIssueId: input.issueId,
|
||||
...(childCompletionContext ?? {}),
|
||||
...(reportDenial ? { interactionPermissionDenials: { assignment: reportDenial } } : childCompletionContext ?? {}),
|
||||
},
|
||||
});
|
||||
if (reportDenial) {
|
||||
const dependencyWakeId = await enqueueWake({ tx: tx as unknown as Db, companyId: input.companyId,
|
||||
issueId: dependent.id, agentId: dependent.assigneeAgentId, reason: "issue_blockers_resolved",
|
||||
idempotencyKey: buildIssueBlockersResolvedWakeIdempotencyKey({ dependentIssueId: dependent.id, resolvedBlockerIssueId: input.issueId }),
|
||||
payload: { resolvedBlockerIssueId: input.issueId, blockerIssueIds: dependent.blockerIssueIds },
|
||||
});
|
||||
materialized.push({ effectKind: "dependency_wake", targetType: "agent_wakeup_request", targetId: dependencyWakeId,
|
||||
payload: { dependentIssueId: dependent.id, resolvedBlockerIssueId: input.issueId } });
|
||||
}
|
||||
}
|
||||
if (parent && !parentIsDependent) {
|
||||
const wakeId = await enqueueWake({
|
||||
tx: tx as unknown as Db,
|
||||
originRunId: input.runId,
|
||||
originOperation: "comment",
|
||||
companyId: input.companyId,
|
||||
issueId: parent.id,
|
||||
agentId: parent.assigneeAgentId,
|
||||
@@ -2452,17 +2519,21 @@ export async function commitNativeStatusDecision(input: {
|
||||
childIssueSummaryTruncated: parent.childIssueSummaryTruncated,
|
||||
},
|
||||
});
|
||||
const reportDenial = await refusedWakePolicy(tx as unknown as Db, input.companyId, wakeId);
|
||||
materialized.push({
|
||||
effectKind: "parent_wake",
|
||||
targetType: "agent_wakeup_request",
|
||||
targetId: wakeId,
|
||||
...(reportDenial ? { deliveryState: "skipped" as const } : {}),
|
||||
payload: {
|
||||
parentIssueId: parent.id,
|
||||
completedChildIssueId: input.issueId,
|
||||
...childCompletionDelivery,
|
||||
childIssueSummaries: parent.childIssueSummaries,
|
||||
onboardingCompletion: parent.onboardingCompletion,
|
||||
childIssueSummaryTruncated: parent.childIssueSummaryTruncated,
|
||||
...(reportDenial ? { interactionPermissionDenials: { assignment: reportDenial } } : {
|
||||
childIssueSummaries: parent.childIssueSummaries,
|
||||
onboardingCompletion: parent.onboardingCompletion,
|
||||
childIssueSummaryTruncated: parent.childIssueSummaryTruncated,
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -2481,9 +2552,10 @@ export async function commitNativeStatusDecision(input: {
|
||||
targetId: effect.targetId,
|
||||
idempotencyKey: `native-status:${decisionRow.id}:${index + 1}`,
|
||||
payload: effect.payload,
|
||||
deliveryState: "delivered",
|
||||
deliveryState: effect.deliveryState ?? "delivered",
|
||||
attemptCount: 1,
|
||||
deliveredAt: new Date(),
|
||||
deliveredAt: effect.deliveryState === "skipped" ? null : new Date(),
|
||||
lastError: effect.deliveryState === "skipped" ? INTERACTION_DELIVERY_DENIED : null,
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
@@ -2540,6 +2612,22 @@ export async function commitNativeStatusDecision(input: {
|
||||
issue: updated,
|
||||
replayed: false,
|
||||
};
|
||||
}).catch(async (error: unknown) => {
|
||||
if (error instanceof HttpError && error.status === 403 && record(error.details).code === "agent_interaction_denied") {
|
||||
await input.db.transaction(async tx => {
|
||||
const [coordinator] = await tx.select().from(nativeRunFinalizations).where(and(
|
||||
eq(nativeRunFinalizations.runId, input.runId), eq(nativeRunFinalizations.companyId, input.companyId),
|
||||
eq(nativeRunFinalizations.issueId, input.issueId))).for("update");
|
||||
if (!coordinator || coordinator.phase === "committed" || coordinator.failureCode === "agent_interaction_denied") return;
|
||||
await notifyNativeFinalizationWork(tx, { companyId: input.companyId, runId: input.runId });
|
||||
await tx.update(nativeRunFinalizations).set({ phase: "terminal_failure", failureCode: "agent_interaction_denied",
|
||||
failureDetail: { ...record(coordinator.failureDetail), interactionDenial: error.details,
|
||||
recoveryOwner: { kind: "none", reason: "agent_interaction_denied" } },
|
||||
nextAttemptAt: null, leaseOwner: null, leaseExpiresAt: null, updatedAt: new Date() })
|
||||
.where(and(eq(nativeRunFinalizations.runId, input.runId), eq(nativeRunFinalizations.companyId, input.companyId)));
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
|
||||
for (const publication of publications) publishActivity(publication);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { notifyAttentionRetentionWork } from "../retention-work-notifications.js";
|
||||
import { and, eq, isNull, ne, sql } from "drizzle-orm";
|
||||
import { and, eq, isNull, ne, or, sql } from "drizzle-orm";
|
||||
import { completionContracts, heartbeatRuns, issues, issueThreadInteractions, statusDecisionEffects, statusDecisions, workAssessments, type Db } from "@paperclipai/db";
|
||||
import { hasPublishedGitHubReview } from "../chat-channels/github/review-wait.js";
|
||||
import { issueThreadInteractionService } from "../issue-thread-interactions.js";
|
||||
@@ -47,7 +47,8 @@ export async function findSupersededExternalReviewRequests(db: Db, binding: Revi
|
||||
eq(issueThreadInteractions.companyId, binding.companyId), eq(issueThreadInteractions.issueId, binding.issueId),
|
||||
eq(issueThreadInteractions.status, "pending"), eq(issueThreadInteractions.kind, "request_confirmation"),
|
||||
ne(issueThreadInteractions.sourceRunId, binding.runId),
|
||||
isNull(issueThreadInteractions.createdByAgentId), isNull(issueThreadInteractions.createdByUserId),
|
||||
or(isNull(issueThreadInteractions.createdByAgentId), eq(issueThreadInteractions.createdByAgentId, heartbeatRuns.agentId)),
|
||||
isNull(issueThreadInteractions.createdByUserId),
|
||||
isNull(issueThreadInteractions.addresseeAgentId), eq(issueThreadInteractions.effectiveResolverPolicy, "human_only"),
|
||||
sql`exists (select 1 from jsonb_array_elements(${statusDecisions.decisionJson}->'effects') effect
|
||||
where effect->>'kind' = 'bind_reviewer'
|
||||
|
||||
@@ -2,6 +2,7 @@ import { and, asc, eq, inArray, isNotNull, or, sql } from "drizzle-orm";
|
||||
import { heartbeatRunEvents, issueComments, issueThreadInteractions, type Db } from "@paperclipai/db";
|
||||
import { unreadSteeredMessageIds } from "../../vendor/paperclip-runner/index.js";
|
||||
import { queuedInteractionSteeringBody } from "../queued-interaction-response.js";
|
||||
import { filterRunMessageInteractionDelivery } from "../agent-interaction-delivery.js";
|
||||
|
||||
const kind = sql<string | null>`${heartbeatRunEvents.payload}->'prpEvent'->'payload'->>'kind'`;
|
||||
|
||||
@@ -38,6 +39,7 @@ export async function readUnreadSteeredMessages(
|
||||
if (ids.length === 0) return [];
|
||||
const uuids = ids.filter((id) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id));
|
||||
if (uuids.length === 0) return [];
|
||||
const allowedIds = await filterRunMessageInteractionDelivery(db, { ...input, commentIds: uuids });
|
||||
const texts = new Map<string, string>();
|
||||
const comments = await db
|
||||
.select({ id: issueComments.id, body: issueComments.body })
|
||||
@@ -62,6 +64,7 @@ export async function readUnreadSteeredMessages(
|
||||
for (const interaction of interactions) texts.set(interaction.id, queuedInteractionSteeringBody(interaction));
|
||||
}
|
||||
return ids.flatMap((id) => {
|
||||
if (!allowedIds.has(id)) return [];
|
||||
const text = texts.get(id);
|
||||
return text === undefined ? [] : [{ id, text }];
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { assertAgentInteraction } from "./agent-interaction-policy.js";
|
||||
import { currentPluginInteractionOrigin } from "./plugin-interaction-context.js";
|
||||
import { projectGitAuthorizationService } from "./project-git-authorization.js";
|
||||
import { createLiveEventDelivery } from "../realtime/live-event-delivery.js";
|
||||
import { createLiveRunEventRedactor } from "./live-run-event-redaction.js";
|
||||
@@ -3103,6 +3105,9 @@ export function buildHostServices(
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
const agent = await agents.getById(params.agentId);
|
||||
requireInCompany("Agent", agent, companyId);
|
||||
const origin = currentPluginInteractionOrigin();
|
||||
await assertAgentInteraction(db, { companyId, actorAgentId: origin?.agentId,
|
||||
operation: "assign", targetAgentId: params.agentId, texts: params.prompt ? [params.prompt] : [] });
|
||||
const run = await heartbeat.wakeup(params.agentId, {
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
@@ -3116,8 +3121,8 @@ export function buildHostServices(
|
||||
pluginKey,
|
||||
},
|
||||
},
|
||||
requestedByActorType: "system",
|
||||
requestedByActorId: pluginId,
|
||||
requestedByActorType: origin ? "agent" : "system",
|
||||
requestedByActorId: origin?.agentId ?? pluginId,
|
||||
});
|
||||
if (!run) throw new Error("Agent wakeup was skipped by heartbeat policy");
|
||||
return { runId: run.id };
|
||||
@@ -3397,15 +3402,14 @@ export function buildHostServices(
|
||||
const policy = params.policy ? sanitizeRecord(params.policy) : null;
|
||||
if (params.resourceType === "agent") {
|
||||
const agent = requireInCompany("Agent", await agents.getById(params.resourceId), companyId);
|
||||
const permissions = agent.permissions && typeof agent.permissions === "object"
|
||||
? { ...(agent.permissions as Record<string, unknown>) }
|
||||
: {};
|
||||
if (policy) permissions.authorizationPolicy = policy;
|
||||
else delete permissions.authorizationPolicy;
|
||||
await db
|
||||
.update(agentsTable)
|
||||
.set({ permissions, updatedAt: new Date() })
|
||||
.where(eq(agentsTable.id, agent.id));
|
||||
await db.transaction(async tx => {
|
||||
await assertAgentInteraction(tx, { companyId, operation: "configure", targetAgentId: agent.id, lock: "update" });
|
||||
await tx.update(agentsTable)
|
||||
.set({ permissions: policy
|
||||
? sql`jsonb_set(${agentsTable.permissions}, '{authorizationPolicy}', ${JSON.stringify(policy)}::jsonb)`
|
||||
: sql`${agentsTable.permissions} - 'authorizationPolicy'`, updatedAt: new Date() })
|
||||
.where(eq(agentsTable.id, agent.id));
|
||||
});
|
||||
} else if (params.resourceType === "project") {
|
||||
const project = requireInCompany("Project", await projects.getById(params.resourceId), companyId);
|
||||
const executionWorkspacePolicy = project.executionWorkspacePolicy && typeof project.executionWorkspacePolicy === "object"
|
||||
@@ -3418,16 +3422,18 @@ export function buildHostServices(
|
||||
.set({ executionWorkspacePolicy, updatedAt: new Date() })
|
||||
.where(eq(projectsTable.id, project.id));
|
||||
} else if (params.resourceType === "issue") {
|
||||
const issue = requireInCompany("Issue", await issues.getById(params.resourceId), companyId);
|
||||
const executionPolicy = issue.executionPolicy && typeof issue.executionPolicy === "object"
|
||||
? { ...(issue.executionPolicy as Record<string, unknown>) }
|
||||
: {};
|
||||
if (policy) executionPolicy.authorizationPolicy = policy;
|
||||
else delete executionPolicy.authorizationPolicy;
|
||||
await db
|
||||
.update(issuesTable)
|
||||
.set({ executionPolicy, updatedAt: new Date() })
|
||||
.where(eq(issuesTable.id, issue.id));
|
||||
await db.transaction(async tx => {
|
||||
const [locked] = await tx.select().from(issuesTable).where(and(
|
||||
eq(issuesTable.id, params.resourceId), eq(issuesTable.companyId, companyId))).for("update");
|
||||
const issue = requireInCompany("Issue", locked ?? null, companyId);
|
||||
await assertAgentInteraction(tx, { companyId, operation: "edit", issueId: issue.id, lock: true });
|
||||
const executionPolicy = issue.executionPolicy && typeof issue.executionPolicy === "object"
|
||||
? { ...(issue.executionPolicy as Record<string, unknown>) } : {};
|
||||
if (policy) executionPolicy.authorizationPolicy = policy;
|
||||
else delete executionPolicy.authorizationPolicy;
|
||||
await tx.update(issuesTable).set({ executionPolicy, updatedAt: new Date() })
|
||||
.where(and(eq(issuesTable.id, issue.id), eq(issuesTable.companyId, companyId)));
|
||||
});
|
||||
} else {
|
||||
const company = await companies.getById(params.resourceId);
|
||||
if (!company || company.id !== companyId) throw new Error("Company not found");
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
|
||||
export interface PluginInteractionOrigin {
|
||||
companyId: string;
|
||||
agentId: string;
|
||||
runId?: string | null;
|
||||
}
|
||||
|
||||
const origins = new AsyncLocalStorage<Readonly<PluginInteractionOrigin> | undefined>();
|
||||
|
||||
export function withPluginInteractionOrigin<T>(origin: PluginInteractionOrigin | undefined, fn: () => T): T {
|
||||
return origins.run(origin ? Object.freeze({ ...origin }) : undefined, fn);
|
||||
}
|
||||
|
||||
export function currentPluginInteractionOrigin(): Readonly<PluginInteractionOrigin> | undefined {
|
||||
return origins.getStore();
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { withPluginInteractionOrigin } from "./plugin-interaction-context.js";
|
||||
import { pluginIdleManifestGeneration, type IdlePluginCatalogEntry } from "./plugin-idle-catalog.js";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { PluginWorkerUnavailableError } from "./plugin-worker-unavailable-error.js";
|
||||
@@ -1266,6 +1267,19 @@ export function createPluginWorkerHandle(
|
||||
): PluginInvocationScope | null {
|
||||
if (!isRecord(params)) return null;
|
||||
|
||||
if (method === "executeTool" && isRecord(params.runContext)) {
|
||||
const companyId = readNonEmptyString(params.runContext.companyId);
|
||||
const agentId = readNonEmptyString(params.runContext.agentId);
|
||||
const runId = readNonEmptyString(params.runContext.runId);
|
||||
return companyId ? { companyId, ...(agentId ? { agentOrigin: { agentId, runId } } : {}) } : null;
|
||||
}
|
||||
if (method === "performAction" && isRecord(params.actorContext)) {
|
||||
const companyId = readNonEmptyString(params.actorContext.companyId);
|
||||
const agentId = params.actorContext.type === "agent" ? readNonEmptyString(params.actorContext.agentId) : null;
|
||||
const runId = readNonEmptyString(params.actorContext.runId);
|
||||
return companyId ? { companyId, ...(agentId ? { agentOrigin: { agentId, runId } } : {}) } : null;
|
||||
}
|
||||
|
||||
const directCompanyId = readNonEmptyString(params.companyId);
|
||||
if (directCompanyId) {
|
||||
if (method === "resourceLifecycle" && params.resourceType === "project" && readNonEmptyString(params.resourceId)) {
|
||||
@@ -1288,16 +1302,6 @@ export function createPluginWorkerHandle(
|
||||
return { companyId: directCompanyId };
|
||||
}
|
||||
|
||||
if (method === "performAction" && isRecord(params.actorContext)) {
|
||||
const companyId = readNonEmptyString(params.actorContext.companyId);
|
||||
return companyId ? { companyId } : null;
|
||||
}
|
||||
|
||||
if (method === "executeTool" && isRecord(params.runContext)) {
|
||||
const companyId = readNonEmptyString(params.runContext.companyId);
|
||||
return companyId ? { companyId } : null;
|
||||
}
|
||||
|
||||
if (method === "onEvent" && isRecord(params.event)) {
|
||||
const companyId = readNonEmptyString(params.event.companyId);
|
||||
return companyId ? { companyId } : null;
|
||||
@@ -2843,6 +2847,9 @@ export function createPluginWorkerHandle(
|
||||
(message as { paperclipInvocationId?: unknown }).paperclipInvocationId,
|
||||
);
|
||||
if (!invocationId) {
|
||||
if (Array.from(activeInvocations.values()).some(entry => entry.scope.agentOrigin)) {
|
||||
return { invalidInvocationScope: true };
|
||||
}
|
||||
// No host-issued invocation is being echoed. This is a genuinely
|
||||
// proactive worker→host call (timer/loop). If it references a company the
|
||||
// plugin is authorized to act on proactively, resolve it to that
|
||||
@@ -2923,7 +2930,13 @@ export function createPluginWorkerHandle(
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await handler(request.params, contextForWorkerMessage(request));
|
||||
const context = contextForWorkerMessage(request);
|
||||
if (context.invalidInvocationScope) throw Object.assign(new Error("Missing or unknown invocation scope"), {
|
||||
code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED,
|
||||
});
|
||||
const source = context.invocationScope?.agentOrigin;
|
||||
const origin = source && context.invocationScope ? { ...source, companyId: context.invocationScope.companyId } : undefined;
|
||||
const result = await withPluginInteractionOrigin(origin, () => handler(request.params, context));
|
||||
sendMessage({
|
||||
jsonrpc: JSONRPC_VERSION,
|
||||
id: request.id,
|
||||
|
||||
@@ -10,6 +10,10 @@ import { legacyExecutionNeedsReconciliation } from "../legacy-execution-recovery
|
||||
import { isConfigurationIncompleteFailedRun } from "../../modules/wake-queue/domain/values.js";
|
||||
|
||||
describe("classifyAdapterFailureForRecovery", () => {
|
||||
it("does not retry a permanently refused agent interaction delivery", () => {
|
||||
expect(classifyContinuationFailure({ errorCode: "agent_interaction_delivery_denied", resultJson: null } as never))
|
||||
.toMatchObject({ kind: "non_retryable", maxAttempts: 0 });
|
||||
});
|
||||
it("does not automatically replay a project access denial", () => {
|
||||
expect(classifyContinuationFailure({ errorCode: "project_access_denied", resultJson: null } as never))
|
||||
.toMatchObject({ kind: "non_retryable", maxAttempts: 0 });
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { wakeWaitersOfStalledIssues, type BlockerStallWakeup } from "./blocker-stall-wakeups.js";
|
||||
import { INTERACTION_DELIVERY_DENIED, isInteractionDeliveryDeniedRun } from "../agent-interaction-delivery.js";
|
||||
import { notifyScheduledRetryDispatch } from "../run-dispatch-notifications.js";
|
||||
import { OWNER_UNPROVABLE_CAUSE } from "./owner-gone-attestation.js";
|
||||
import { promoteDeferredWorkIntents } from "../work-intent-outbox.js";
|
||||
@@ -547,6 +548,7 @@ const TRANSIENT_INFRA_CONTINUATION_ERROR_CODES = new Set<string>([
|
||||
]);
|
||||
|
||||
const NON_RETRYABLE_CONTINUATION_ERROR_CODES = new Set<string>([
|
||||
INTERACTION_DELIVERY_DENIED,
|
||||
"project_access_denied",
|
||||
"native_provider_model_rejected",
|
||||
"provider_tool_definition_invalid",
|
||||
@@ -4660,6 +4662,10 @@ export function recoveryService(
|
||||
}
|
||||
|
||||
let latestRun = await getLatestIssueRun(issue.companyId, issue.id);
|
||||
if (latestRun?.agentId === agentId && isInteractionDeliveryDeniedRun(latestRun)) {
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
// Terminal run/wake rows survive a restart even if finalization never
|
||||
// reached the escalation transaction. Retry that idempotent disposition
|
||||
// before the participant-only review branch can skip pathless reviews.
|
||||
@@ -4829,6 +4835,10 @@ export function recoveryService(
|
||||
issue.status === "in_review"
|
||||
? participantLatestRunForRecovery
|
||||
: latestRun;
|
||||
if (isInteractionDeliveryDeniedRun(executionRecoverySource)) {
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
executionRecoverySource?.agentId === agentId &&
|
||||
(
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { assertAgentInteraction } from "./agent-interaction-policy.js";
|
||||
import { currentPluginInteractionOrigin } from "./plugin-interaction-context.js";
|
||||
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
|
||||
import { notifyApprovalWork } from "./approval-work-notifications.js";
|
||||
import { notifyDecisionTargetIssueRemoval } from "./decision-targets.js";
|
||||
@@ -156,6 +158,7 @@ async function resolveRoutineResponsibleUserId(db: Db, companyId: string, actorU
|
||||
|
||||
type Actor = {
|
||||
agentId?: string | null;
|
||||
agentCompanyId?: string;
|
||||
userId?: string | null;
|
||||
actorResponsibleUserId?: string | null;
|
||||
runId?: string | null;
|
||||
@@ -970,6 +973,10 @@ export function routineService(
|
||||
restoredFromRevisionId?: string | null;
|
||||
} = {},
|
||||
) {
|
||||
const pluginOrigin = currentPluginInteractionOrigin();
|
||||
if (pluginOrigin) actor = { ...actor, agentId: pluginOrigin.agentId, userId: null, runId: pluginOrigin.runId };
|
||||
await assertAgentInteraction(executor, { companyId: routine.companyId, actorAgentId: actor.agentId, actorCompanyId: actor.agentCompanyId,
|
||||
operation: "assign", targetAgentId: routine.assigneeAgentId, parentIssueId: routine.parentIssueId, lock: true });
|
||||
const snapshot = await buildRoutineRevisionSnapshot(executor, routine);
|
||||
const nextRevisionNumber = routine.latestRevisionId ? routine.latestRevisionNumber + 1 : 1;
|
||||
const now = new Date();
|
||||
@@ -1792,6 +1799,23 @@ export function routineService(
|
||||
await tx.execute(
|
||||
sql`select id from ${routines} where ${routines.id} = ${input.routine.id} and ${routines.companyId} = ${input.routine.companyId} for update`,
|
||||
);
|
||||
const revisionActor = input.source !== "manual" && input.routine.latestRevisionId
|
||||
? await tx.select({ agentId: routineRevisions.createdByAgentId, userId: routineRevisions.createdByUserId })
|
||||
.from(routineRevisions).where(and(eq(routineRevisions.companyId, input.routine.companyId),
|
||||
eq(routineRevisions.routineId, input.routine.id), eq(routineRevisions.id, input.routine.latestRevisionId)))
|
||||
.then(rows => rows[0]) : undefined;
|
||||
const sourceRunActor = input.createdByRunId ? await tx.select({ agentId: heartbeatRuns.agentId })
|
||||
.from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, input.routine.companyId), eq(heartbeatRuns.id, input.createdByRunId)))
|
||||
.then(rows => rows[0]?.agentId ?? null) : null;
|
||||
const interactionActorAgentId = currentPluginInteractionOrigin()?.agentId ?? input.actor?.agentId ?? sourceRunActor
|
||||
?? (input.actor?.userId ? null : revisionActor ? revisionActor.agentId
|
||||
: input.routine.updatedByAgentId ?? (input.routine.updatedByUserId ? null : input.routine.createdByAgentId));
|
||||
const interactionActorCompanyId = interactionActorAgentId ? await tx.select({ companyId: agents.companyId }).from(agents)
|
||||
.where(eq(agents.id, interactionActorAgentId)).then(rows => rows[0]?.companyId) : undefined;
|
||||
await assertAgentInteraction(tx, { companyId: input.routine.companyId, actorAgentId: interactionActorAgentId,
|
||||
actorCompanyId: interactionActorCompanyId,
|
||||
operation: "assign", targetAgentId: assigneeAgentId, parentIssueId: input.routine.parentIssueId,
|
||||
texts: [title, description ?? ""], lock: true });
|
||||
|
||||
if (input.trigger && (input.source === "webhook" || input.source === "schedule")) {
|
||||
const currentTrigger = await txDb.select().from(routineTriggers).where(eq(routineTriggers.id, input.trigger.id)).then((rows) => rows[0]);
|
||||
@@ -1975,7 +1999,9 @@ export function routineService(
|
||||
reason: "issue_assigned",
|
||||
mutation: "create",
|
||||
contextSource: "routine.dispatch",
|
||||
requestedByActorType: input.source === "schedule" ? "system" : undefined,
|
||||
requestedByActorType: interactionActorAgentId ? "agent" : input.actor?.userId ? "user" : input.source === "schedule" ? "system" : undefined,
|
||||
requestedByActorId: interactionActorAgentId ?? input.actor?.userId ?? null,
|
||||
requestedByActorCompanyId: interactionActorCompanyId,
|
||||
rethrowOnError: true,
|
||||
});
|
||||
const updated = await finalizeRun(createdRun.id, {
|
||||
@@ -2236,6 +2262,9 @@ export function routineService(
|
||||
}
|
||||
const createdRoutine = await db.transaction(async (tx) => {
|
||||
const txDb = tx as unknown as Db;
|
||||
await assertAgentInteraction(tx, { companyId, actorAgentId: actor.agentId, actorCompanyId: actor.agentCompanyId, operation: "assign",
|
||||
targetAgentId: input.assigneeAgentId, parentIssueId: input.parentIssueId,
|
||||
texts: [input.title, input.description ?? ""], lock: true });
|
||||
await notifyDeliveryWork(txDb, DELIVERY_QUEUES.routineSchedules);
|
||||
const [created] = await txDb
|
||||
.insert(routines)
|
||||
@@ -2350,6 +2379,13 @@ export function routineService(
|
||||
.where(eq(routines.id, id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!locked) return null;
|
||||
await assertAgentInteraction(tx, { companyId: locked.companyId, actorAgentId: actor.agentId, actorCompanyId: actor.agentCompanyId, operation: "assign",
|
||||
targetAgentId: patch.assigneeAgentId === undefined ? locked.assigneeAgentId : patch.assigneeAgentId,
|
||||
parentIssueId: patch.parentIssueId === undefined ? locked.parentIssueId : patch.parentIssueId,
|
||||
texts: [
|
||||
...(patch.title !== undefined && patch.title !== locked.title ? [patch.title] : []),
|
||||
...(patch.description !== undefined && patch.description !== locked.description ? [patch.description ?? ""] : []),
|
||||
], lock: true });
|
||||
previousResponsibleUserId = locked.responsibleUserId;
|
||||
|
||||
if (patch.baseRevisionId && patch.baseRevisionId !== locked.latestRevisionId) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { RepositoryCatalogUnavailableError, findRepositoryCatalogUnavailableErro
|
||||
import { notifyVoiceChange } from "./voice/voice-recovery-notifications.js";
|
||||
import { notifyToolConnectionHealth, writeToolConnectionHealthChange } from "./tool-connection-health-notifications.js";
|
||||
import { connectionHealthSchedule } from "./tool-access/catalog/health-schedule.js";
|
||||
import { assertAgentConnectionInstallAccess, type ConnectionManagementContext } from "./connection-management.js";
|
||||
import { assertAgentConnectionInstallAccess, assertAgentToolAssignmentInteractions, type ConnectionManagementContext } from "./connection-management.js";
|
||||
import { connectionActorUserId } from "./tool-access/actor.js";
|
||||
import { restConnectorFor } from "./connectors/rest-registry.js";
|
||||
import { createConnectionCredentialValidation } from "./tool-access/connections/credential-validation.js";
|
||||
@@ -3296,7 +3296,17 @@ export function toolAccessService(
|
||||
.where(and(eq(toolConnectionInstalls.companyId, companyId), eq(toolConnectionInstalls.connectionId, connection.id)));
|
||||
const companyTargets = lockedConnection.status !== "active" || !lockedConnection.enabled || (existingProfile && existingProfile.status !== "active")
|
||||
? [{ targetType: "company", targetId: companyId }] : [];
|
||||
await lockToolAssignmentAgents(tx, companyId, [...existingBindings, ...bindingInputs, ...existingInstalls, ...companyTargets]);
|
||||
const sourceAgentId = actor?.managementContext?.actor.type === "agent" ? actor.managementContext.actor.agentId
|
||||
: actor?.actorType === "agent" ? actor.actorId : null;
|
||||
await lockToolAssignmentAgents(tx, companyId, [...existingBindings, ...bindingInputs, ...existingInstalls, ...companyTargets], sourceAgentId);
|
||||
const requestedAgentTargets = new Set(bindingInputs.filter(target => target.targetType === "agent").map(target => target.targetId));
|
||||
const changedAgentTargets: string[] = [];
|
||||
for (const savedTargets of [existingBindings, ...(connection.connectionPurpose === "tool" ? [existingInstalls] : [])]) {
|
||||
const existingAgentTargets = new Set(savedTargets.filter(target => target.targetType === "agent").map(target => target.targetId));
|
||||
changedAgentTargets.push(...[...requestedAgentTargets].filter(id => !existingAgentTargets.has(id)));
|
||||
if (!input.preserveExistingAccess) changedAgentTargets.push(...[...existingAgentTargets].filter(id => !requestedAgentTargets.has(id)));
|
||||
}
|
||||
await assertAgentToolAssignmentInteractions(tx as unknown as Db, sourceAgentId, companyId, changedAgentTargets, false);
|
||||
// A renamed connection can leave its old profile name in use. Resolve
|
||||
// names against profiles too, including when finalizing an installed draft.
|
||||
const otherProfiles = await tx
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
import { currentPluginInteractionOrigin } from "../plugin-interaction-context.js";
|
||||
import { and, eq, inArray, or, sql } from "drizzle-orm";
|
||||
import { agents, toolApplications, toolCatalogEntries, toolConnections, toolPolicies, toolProfiles } from "@paperclipai/db";
|
||||
import type { CreateToolProfileEntryForProfile } from "@paperclipai/shared";
|
||||
import type { ToolAccessMutationDb } from "./types.js";
|
||||
@@ -10,12 +11,14 @@ export async function lockManagedAppPolicies(client: ToolAccessMutationDb, compa
|
||||
)).orderBy(toolPolicies.id).for("no key update");
|
||||
}
|
||||
|
||||
export async function lockToolAssignmentAgents(client: ToolAccessMutationDb, companyId: string, targets: ReadonlyArray<{ targetType: string; targetId: string }>) {
|
||||
export async function lockToolAssignmentAgents(client: ToolAccessMutationDb, companyId: string, targets: ReadonlyArray<{ targetType: string; targetId: string }>, actorAgentId?: string | null) {
|
||||
const allAgents = targets.some(target => target.targetType === "company" && target.targetId === companyId);
|
||||
const ids = targets.filter(target => target.targetType === "agent").map(target => target.targetId);
|
||||
const sourceAgentId = currentPluginInteractionOrigin()?.agentId ?? actorAgentId;
|
||||
if (sourceAgentId) ids.push(sourceAgentId);
|
||||
if (!allAgents && !ids.length) return;
|
||||
await client.select({ id: agents.id }).from(agents)
|
||||
.where(and(eq(agents.companyId, companyId), sql`${agents.status} NOT IN ('pending_approval', 'terminated')`,
|
||||
.where(and(eq(agents.companyId, companyId), or(sql`${agents.status} NOT IN ('pending_approval', 'terminated')`, ids.length ? inArray(agents.id, ids) : undefined),
|
||||
allAgents ? undefined : inArray(agents.id, ids)))
|
||||
.orderBy(agents.id).for("no key update");
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { assertAgentToolAssignmentInteractions } from "../../connection-management.js";
|
||||
import { connectionActorUserId } from "../actor.js";
|
||||
import { toConnection, toConnectionInstall } from "./projection.js";
|
||||
import { actorBinding } from "../actor.js";
|
||||
@@ -208,7 +209,13 @@ export function createConnectionInstalls(db: Db, { now, getConnectionRow, assert
|
||||
const bindings = await tx.select({ targetType: toolProfileBindings.targetType, targetId: toolProfileBindings.targetId })
|
||||
.from(toolProfileBindings).innerJoin(toolProfiles, eq(toolProfiles.id, toolProfileBindings.profileId))
|
||||
.where(and(eq(toolProfileBindings.companyId, connection.companyId), eq(toolProfiles.companyId, connection.companyId), eq(toolProfiles.profileKey, `app:${connection.id}`)));
|
||||
await lockToolAssignmentAgents(tx, connection.companyId, [...existing, ...requested.values(), ...bindings]);
|
||||
const sourceAgentId = actor?.managementContext?.actor.type === "agent" ? actor.managementContext.actor.agentId
|
||||
: actor?.actorType === "agent" ? actor.actorId : null;
|
||||
await lockToolAssignmentAgents(tx, connection.companyId, [...existing, ...requested.values(), ...bindings], sourceAgentId);
|
||||
const changedAgentIds = [...existing.filter(install => !requested.has(`${install.targetType}:${install.targetId}`)),
|
||||
...[...requested.entries()].filter(([key]) => !existingKeys.has(key)).map(([, install]) => install)]
|
||||
.filter(install => install.targetType === "agent").map(install => install.targetId);
|
||||
await assertAgentToolAssignmentInteractions(tx as unknown as Db, sourceAgentId, connection.companyId, changedAgentIds, false);
|
||||
const removals = existing.filter(
|
||||
(install) =>
|
||||
!requested.has(`${install.targetType}:${install.targetId}`),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { assertAgentToolAssignmentInteractions } from "../../connection-management.js";
|
||||
import type { ConnectionManagementContext } from "../../connection-management.js";
|
||||
import { createConnectionProfileConfigurationReader } from "./connection-configuration.js";
|
||||
import { connectionActorUserId } from "../actor.js";
|
||||
@@ -843,6 +844,10 @@ export function createToolProfileService(db: Db, now: () => Date) {
|
||||
input.targetId,
|
||||
tx,
|
||||
);
|
||||
if (input.targetType === "agent") await assertAgentToolAssignmentInteractions(db,
|
||||
actor?.managementContext?.actor.type === "agent" ? actor.managementContext.actor.agentId : actor?.actorType === "agent" ? actor.actorId : null,
|
||||
profile.companyId, [input.targetId]);
|
||||
|
||||
const [row] = await db
|
||||
.insert(toolProfileBindings)
|
||||
.values({
|
||||
@@ -879,6 +884,9 @@ export function createToolProfileService(db: Db, now: () => Date) {
|
||||
input.targetId,
|
||||
tx,
|
||||
);
|
||||
if (input.targetType === "agent") await assertAgentToolAssignmentInteractions(db,
|
||||
managementContext?.actor.type === "agent" ? managementContext.actor.agentId : null, profile.companyId, [input.targetId]);
|
||||
|
||||
const rows = await db
|
||||
.delete(toolProfileBindings)
|
||||
.where(
|
||||
|
||||
+171
-158
@@ -1,3 +1,4 @@
|
||||
import { assertAgentToolAssignmentInteractions } from "./connection-management.js";
|
||||
import { isNewHumanIntentAfterToolRejection } from "./tool-action-replay-intent.js";
|
||||
import { approvalSnapshotsMatch, approvalTargetChangeMessage, oauthApprovalConfig,
|
||||
approvalGrantTenantIdentity, externalCredentialApprovalIdentity } from "./tool-approval-target.js";
|
||||
@@ -9111,54 +9112,60 @@ export function createToolGatewayService(
|
||||
input.body.displaySlug ??
|
||||
input.body.slug ??
|
||||
slugSegment(input.body.name, "gateway");
|
||||
const [gateway] = await db
|
||||
.insert(toolMcpGateways)
|
||||
.values({
|
||||
companyId: input.companyId,
|
||||
name: input.body.name,
|
||||
slug,
|
||||
displaySlug: slug,
|
||||
description: input.body.description ?? null,
|
||||
defaultProfileMode: input.body.defaultProfileMode ?? "gateway_only",
|
||||
contextScopeType: input.body.contextScopeType ?? "none",
|
||||
contextScopeId: input.body.contextScopeId ?? null,
|
||||
profileId: input.body.profileId,
|
||||
agentId: input.body.agentId ?? null,
|
||||
projectId: input.body.projectId ?? null,
|
||||
issueId: input.body.issueId ?? null,
|
||||
approvalIssueId: input.body.approvalIssueId ?? null,
|
||||
...(input.body.authConfig !== undefined
|
||||
? { authConfig: input.body.authConfig }
|
||||
: {}),
|
||||
...(input.body.headerPolicy !== undefined
|
||||
? { headerPolicy: input.body.headerPolicy }
|
||||
: {}),
|
||||
...(input.body.metadataPolicy !== undefined
|
||||
? { metadataPolicy: input.body.metadataPolicy }
|
||||
: {}),
|
||||
...(input.body.onDemandToolsConfig !== undefined
|
||||
? { onDemandToolsConfig: input.body.onDemandToolsConfig }
|
||||
: {}),
|
||||
metadata: input.body.metadata ?? {},
|
||||
createdByAgentId: input.actor?.agentId ?? null,
|
||||
createdByUserId: input.actor?.userId ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning();
|
||||
await db
|
||||
.insert(toolProfileBindings)
|
||||
.values({
|
||||
companyId: input.companyId,
|
||||
profileId: input.body.profileId,
|
||||
targetType: "gateway",
|
||||
targetId: gateway.id,
|
||||
priority: 10,
|
||||
metadata: { source: "named_mcp_gateway" },
|
||||
createdByAgentId: input.actor?.agentId ?? null,
|
||||
createdByUserId: input.actor?.userId ?? null,
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
const gateway = await db.transaction(async tx => {
|
||||
await assertAgentToolAssignmentInteractions(tx as unknown as Db,
|
||||
input.managementContext?.actor.type === "agent" ? input.managementContext.actor.agentId : input.actor?.agentId,
|
||||
input.companyId, input.body.agentId ? [input.body.agentId] : []);
|
||||
const [gateway] = await tx
|
||||
.insert(toolMcpGateways)
|
||||
.values({
|
||||
companyId: input.companyId,
|
||||
name: input.body.name,
|
||||
slug,
|
||||
displaySlug: slug,
|
||||
description: input.body.description ?? null,
|
||||
defaultProfileMode: input.body.defaultProfileMode ?? "gateway_only",
|
||||
contextScopeType: input.body.contextScopeType ?? "none",
|
||||
contextScopeId: input.body.contextScopeId ?? null,
|
||||
profileId: input.body.profileId,
|
||||
agentId: input.body.agentId ?? null,
|
||||
projectId: input.body.projectId ?? null,
|
||||
issueId: input.body.issueId ?? null,
|
||||
approvalIssueId: input.body.approvalIssueId ?? null,
|
||||
...(input.body.authConfig !== undefined
|
||||
? { authConfig: input.body.authConfig }
|
||||
: {}),
|
||||
...(input.body.headerPolicy !== undefined
|
||||
? { headerPolicy: input.body.headerPolicy }
|
||||
: {}),
|
||||
...(input.body.metadataPolicy !== undefined
|
||||
? { metadataPolicy: input.body.metadataPolicy }
|
||||
: {}),
|
||||
...(input.body.onDemandToolsConfig !== undefined
|
||||
? { onDemandToolsConfig: input.body.onDemandToolsConfig }
|
||||
: {}),
|
||||
metadata: input.body.metadata ?? {},
|
||||
createdByAgentId: input.actor?.agentId ?? null,
|
||||
createdByUserId: input.actor?.userId ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning();
|
||||
await tx
|
||||
.insert(toolProfileBindings)
|
||||
.values({
|
||||
companyId: input.companyId,
|
||||
profileId: input.body.profileId,
|
||||
targetType: "gateway",
|
||||
targetId: gateway.id,
|
||||
priority: 10,
|
||||
metadata: { source: "named_mcp_gateway" },
|
||||
createdByAgentId: input.actor?.agentId ?? null,
|
||||
createdByUserId: input.actor?.userId ?? null,
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
return gateway;
|
||||
});
|
||||
await writeAudit({
|
||||
session: {
|
||||
id: `gateway:${gateway.id}`,
|
||||
@@ -9211,117 +9218,123 @@ export function createToolGatewayService(
|
||||
managementContext?: ConnectionManagementContext;
|
||||
}): Promise<ToolMcpGatewayWithTokens> {
|
||||
await input.managementContext?.reauthorize();
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(toolMcpGateways)
|
||||
.where(
|
||||
and(
|
||||
eq(toolMcpGateways.companyId, input.companyId),
|
||||
eq(toolMcpGateways.id, input.gatewayId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!existing)
|
||||
throw new ToolGatewayHttpError(
|
||||
404,
|
||||
"MCP gateway not found",
|
||||
"gateway_not_found",
|
||||
);
|
||||
await assertGatewayContext({
|
||||
companyId: input.companyId,
|
||||
profileId: input.body.profileId ?? existing.profileId,
|
||||
agentId:
|
||||
input.body.agentId === undefined
|
||||
? existing.agentId
|
||||
: input.body.agentId,
|
||||
projectId:
|
||||
input.body.projectId === undefined
|
||||
? existing.projectId
|
||||
: input.body.projectId,
|
||||
issueId:
|
||||
input.body.issueId === undefined
|
||||
? existing.issueId
|
||||
: input.body.issueId,
|
||||
});
|
||||
const [updated] = await db
|
||||
.update(toolMcpGateways)
|
||||
.set({
|
||||
...(input.body.name !== undefined ? { name: input.body.name } : {}),
|
||||
...(input.body.slug !== undefined ||
|
||||
input.body.displaySlug !== undefined
|
||||
? { slug: input.body.displaySlug ?? input.body.slug }
|
||||
: {}),
|
||||
...(input.body.slug !== undefined ||
|
||||
input.body.displaySlug !== undefined
|
||||
? { displaySlug: input.body.displaySlug ?? input.body.slug }
|
||||
: {}),
|
||||
...(input.body.description !== undefined
|
||||
? { description: input.body.description ?? null }
|
||||
: {}),
|
||||
...(input.body.status !== undefined
|
||||
? { status: input.body.status }
|
||||
: {}),
|
||||
...(input.body.profileId !== undefined
|
||||
? { profileId: input.body.profileId }
|
||||
: {}),
|
||||
...(input.body.defaultProfileMode !== undefined
|
||||
? { defaultProfileMode: input.body.defaultProfileMode }
|
||||
: {}),
|
||||
...(input.body.contextScopeType !== undefined
|
||||
? { contextScopeType: input.body.contextScopeType }
|
||||
: {}),
|
||||
...(input.body.contextScopeId !== undefined
|
||||
? { contextScopeId: input.body.contextScopeId ?? null }
|
||||
: {}),
|
||||
...(input.body.agentId !== undefined
|
||||
? { agentId: input.body.agentId ?? null }
|
||||
: {}),
|
||||
...(input.body.projectId !== undefined
|
||||
? { projectId: input.body.projectId ?? null }
|
||||
: {}),
|
||||
...(input.body.issueId !== undefined
|
||||
? { issueId: input.body.issueId ?? null }
|
||||
: {}),
|
||||
...(input.body.approvalIssueId !== undefined
|
||||
? { approvalIssueId: input.body.approvalIssueId ?? null }
|
||||
: {}),
|
||||
...(input.body.authConfig !== undefined
|
||||
? { authConfig: input.body.authConfig }
|
||||
: {}),
|
||||
...(input.body.headerPolicy !== undefined
|
||||
? { headerPolicy: input.body.headerPolicy }
|
||||
: {}),
|
||||
...(input.body.metadataPolicy !== undefined
|
||||
? { metadataPolicy: input.body.metadataPolicy }
|
||||
: {}),
|
||||
...(input.body.onDemandToolsConfig !== undefined
|
||||
? { onDemandToolsConfig: input.body.onDemandToolsConfig }
|
||||
: {}),
|
||||
...(input.body.metadata !== undefined
|
||||
? { metadata: input.body.metadata ?? {} }
|
||||
: {}),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(toolMcpGateways.companyId, input.companyId),
|
||||
eq(toolMcpGateways.id, input.gatewayId),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
if (input.body.profileId && input.body.profileId !== existing.profileId) {
|
||||
await db
|
||||
.insert(toolProfileBindings)
|
||||
.values({
|
||||
companyId: input.companyId,
|
||||
profileId: input.body.profileId,
|
||||
targetType: "gateway",
|
||||
targetId: input.gatewayId,
|
||||
priority: 10,
|
||||
metadata: { source: "named_mcp_gateway" },
|
||||
const updated = await db.transaction(async tx => {
|
||||
const [existing] = await tx
|
||||
.select()
|
||||
.from(toolMcpGateways)
|
||||
.where(
|
||||
and(
|
||||
eq(toolMcpGateways.companyId, input.companyId),
|
||||
eq(toolMcpGateways.id, input.gatewayId),
|
||||
),
|
||||
)
|
||||
.limit(1).for("update");
|
||||
if (!existing)
|
||||
throw new ToolGatewayHttpError(
|
||||
404,
|
||||
"MCP gateway not found",
|
||||
"gateway_not_found",
|
||||
);
|
||||
const targets = [...new Set([existing.agentId, input.body.agentId].filter((id): id is string => Boolean(id)))];
|
||||
await assertAgentToolAssignmentInteractions(tx as unknown as Db,
|
||||
input.managementContext?.actor.type === "agent" ? input.managementContext.actor.agentId : null, input.companyId, targets);
|
||||
await assertGatewayContext({
|
||||
companyId: input.companyId,
|
||||
profileId: input.body.profileId ?? existing.profileId,
|
||||
agentId:
|
||||
input.body.agentId === undefined
|
||||
? existing.agentId
|
||||
: input.body.agentId,
|
||||
projectId:
|
||||
input.body.projectId === undefined
|
||||
? existing.projectId
|
||||
: input.body.projectId,
|
||||
issueId:
|
||||
input.body.issueId === undefined
|
||||
? existing.issueId
|
||||
: input.body.issueId,
|
||||
});
|
||||
const [updated] = await tx
|
||||
.update(toolMcpGateways)
|
||||
.set({
|
||||
...(input.body.name !== undefined ? { name: input.body.name } : {}),
|
||||
...(input.body.slug !== undefined ||
|
||||
input.body.displaySlug !== undefined
|
||||
? { slug: input.body.displaySlug ?? input.body.slug }
|
||||
: {}),
|
||||
...(input.body.slug !== undefined ||
|
||||
input.body.displaySlug !== undefined
|
||||
? { displaySlug: input.body.displaySlug ?? input.body.slug }
|
||||
: {}),
|
||||
...(input.body.description !== undefined
|
||||
? { description: input.body.description ?? null }
|
||||
: {}),
|
||||
...(input.body.status !== undefined
|
||||
? { status: input.body.status }
|
||||
: {}),
|
||||
...(input.body.profileId !== undefined
|
||||
? { profileId: input.body.profileId }
|
||||
: {}),
|
||||
...(input.body.defaultProfileMode !== undefined
|
||||
? { defaultProfileMode: input.body.defaultProfileMode }
|
||||
: {}),
|
||||
...(input.body.contextScopeType !== undefined
|
||||
? { contextScopeType: input.body.contextScopeType }
|
||||
: {}),
|
||||
...(input.body.contextScopeId !== undefined
|
||||
? { contextScopeId: input.body.contextScopeId ?? null }
|
||||
: {}),
|
||||
...(input.body.agentId !== undefined
|
||||
? { agentId: input.body.agentId ?? null }
|
||||
: {}),
|
||||
...(input.body.projectId !== undefined
|
||||
? { projectId: input.body.projectId ?? null }
|
||||
: {}),
|
||||
...(input.body.issueId !== undefined
|
||||
? { issueId: input.body.issueId ?? null }
|
||||
: {}),
|
||||
...(input.body.approvalIssueId !== undefined
|
||||
? { approvalIssueId: input.body.approvalIssueId ?? null }
|
||||
: {}),
|
||||
...(input.body.authConfig !== undefined
|
||||
? { authConfig: input.body.authConfig }
|
||||
: {}),
|
||||
...(input.body.headerPolicy !== undefined
|
||||
? { headerPolicy: input.body.headerPolicy }
|
||||
: {}),
|
||||
...(input.body.metadataPolicy !== undefined
|
||||
? { metadataPolicy: input.body.metadataPolicy }
|
||||
: {}),
|
||||
...(input.body.onDemandToolsConfig !== undefined
|
||||
? { onDemandToolsConfig: input.body.onDemandToolsConfig }
|
||||
: {}),
|
||||
...(input.body.metadata !== undefined
|
||||
? { metadata: input.body.metadata ?? {} }
|
||||
: {}),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
.where(
|
||||
and(
|
||||
eq(toolMcpGateways.companyId, input.companyId),
|
||||
eq(toolMcpGateways.id, input.gatewayId),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
if (input.body.profileId && input.body.profileId !== existing.profileId) {
|
||||
await tx
|
||||
.insert(toolProfileBindings)
|
||||
.values({
|
||||
companyId: input.companyId,
|
||||
profileId: input.body.profileId,
|
||||
targetType: "gateway",
|
||||
targetId: input.gatewayId,
|
||||
priority: 10,
|
||||
metadata: { source: "named_mcp_gateway" },
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
if (input.managementContext) await logActivity(db, { companyId: input.companyId, ...input.managementContext.audit,
|
||||
action: "tool_gateway.updated", entityType: "tool_mcp_gateway", entityId: updated.id,
|
||||
details: { profileId: updated.profileId, status: updated.status } });
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { notifyNativeStatusReassessment } from "./native-status-reassessment-notifications.js";
|
||||
import { assertAgentInteraction } from "./agent-interaction-policy.js";
|
||||
import { notifyApprovalQueueWork } from "./approval-work-notifications.js";
|
||||
import { and, desc, eq, inArray } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
@@ -273,8 +274,10 @@ export function workProductService(
|
||||
return row ? toIssueWorkProduct(row) : null;
|
||||
},
|
||||
|
||||
createForIssue: async (issueId: string, companyId: string, data: Omit<typeof issueWorkProducts.$inferInsert, "issueId" | "companyId">) => {
|
||||
createForIssue: async (issueId: string, companyId: string, data: Omit<typeof issueWorkProducts.$inferInsert, "issueId" | "companyId">, actorAgentId?: string | null) => {
|
||||
const row = await db.transaction(async (tx) => {
|
||||
await assertAgentInteraction(tx, { companyId, actorAgentId, operation: "edit", issueId,
|
||||
texts: [data.title, data.summary ?? ""], lock: true });
|
||||
await notifyNativeStatusReassessment(tx, { companyId, issueId });
|
||||
if (data.type === "pull_request") {
|
||||
await notifyTerminalWorkspaceWork(tx, `issue:${issueId}`);
|
||||
@@ -306,7 +309,7 @@ export function workProductService(
|
||||
return row ? toIssueWorkProduct(row) : null;
|
||||
},
|
||||
|
||||
update: async (id: string, patch: Partial<typeof issueWorkProducts.$inferInsert>) => {
|
||||
update: async (id: string, patch: Partial<typeof issueWorkProducts.$inferInsert>, actorAgentId?: string | null) => {
|
||||
const row = await db.transaction(async (tx) => {
|
||||
const existing = await tx
|
||||
.select()
|
||||
@@ -314,6 +317,15 @@ export function workProductService(
|
||||
.where(eq(issueWorkProducts.id, id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!existing) return null;
|
||||
await assertAgentInteraction(tx, { companyId: existing.companyId, actorAgentId, operation: "edit", issueId: existing.issueId,
|
||||
texts: [
|
||||
...(patch.title !== undefined && patch.title !== existing.title ? [patch.title] : []),
|
||||
...(patch.summary !== undefined && patch.summary !== existing.summary ? [patch.summary ?? ""] : []),
|
||||
], lock: true });
|
||||
if ((patch.issueId && patch.issueId !== existing.issueId) || (patch.companyId && patch.companyId !== existing.companyId)) {
|
||||
await assertAgentInteraction(tx, { companyId: patch.companyId ?? existing.companyId, actorAgentId,
|
||||
operation: "edit", issueId: patch.issueId ?? existing.issueId, lock: true });
|
||||
}
|
||||
await notifyNativeStatusReassessment(tx, { companyId: existing.companyId, issueId: existing.issueId });
|
||||
if ((patch.companyId && patch.companyId !== existing.companyId) || (patch.issueId && patch.issueId !== existing.issueId)) {
|
||||
await notifyNativeStatusReassessment(tx, { companyId: patch.companyId ?? existing.companyId, issueId: patch.issueId ?? existing.issueId });
|
||||
@@ -357,7 +369,7 @@ export function workProductService(
|
||||
* imported rows themselves. We reproduce "last primary wins" within each
|
||||
* (issue, type) group and insert the whole batch in chunked statements.
|
||||
*/
|
||||
createManyForImport: async (rows: ImportIssueWorkProductRow[]): Promise<void> => {
|
||||
createManyForImport: async (rows: ImportIssueWorkProductRow[], actorAgentId?: string | null, actorCompanyId?: string | null): Promise<void> => {
|
||||
if (rows.length === 0) return;
|
||||
const lastPrimaryIndexByGroup = new Map<string, number>();
|
||||
rows.forEach((row, index) => {
|
||||
@@ -391,6 +403,8 @@ export function workProductService(
|
||||
// prefix behind (which a retry would then duplicate). Mirrors the
|
||||
// per-writer transaction the batched issue/document writers use.
|
||||
await db.transaction(async (tx) => {
|
||||
for (const row of rows) await assertAgentInteraction(tx, { companyId: row.companyId, actorAgentId, actorCompanyId: actorCompanyId ?? undefined,
|
||||
operation: "mention", issueId: row.issueId, texts: [row.title, row.summary ?? ""], lock: true });
|
||||
for (const scope of new Map(values.map(value => [value.issueId, { companyId: value.companyId, issueId: value.issueId }])).values()) {
|
||||
await notifyNativeStatusReassessment(tx, scope);
|
||||
}
|
||||
@@ -404,10 +418,12 @@ export function workProductService(
|
||||
});
|
||||
},
|
||||
|
||||
remove: async (id: string) => {
|
||||
remove: async (id: string, actorAgentId?: string | null) => {
|
||||
const row = await db.transaction(async tx => {
|
||||
const [existing] = await tx.select({ type: issueWorkProducts.type, issueId: issueWorkProducts.issueId, companyId: issueWorkProducts.companyId })
|
||||
.from(issueWorkProducts).where(eq(issueWorkProducts.id, id));
|
||||
if (existing) await assertAgentInteraction(tx, { companyId: existing.companyId, actorAgentId,
|
||||
operation: "edit", issueId: existing.issueId, lock: true });
|
||||
if (existing) await notifyNativeStatusReassessment(tx, { companyId: existing.companyId, issueId: existing.issueId });
|
||||
if (existing?.type === "pull_request") {
|
||||
await notifyTerminalWorkspaceWork(tx, `issue:${existing.issueId}`);
|
||||
|
||||
Reference in new issue
Block a user