merge master into pap-1078-qol-fixes

Resolve the keyboard shortcut conflicts after [#2539](https://github.com/paperclipai/paperclip/pull/2539) and [#2540](https://github.com/paperclipai/paperclip/pull/2540), keep the release package rewrite working with cliVersion, and stabilize the provisioning timeout in the full suite.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-04-02 13:14:20 -05:00
commit fb3b57ab1f
59 changed files with 16794 additions and 375 deletions

View file

@ -231,11 +231,31 @@ describe("agent skill routes", () => {
);
});
it("keeps runtime materialization for persistent skill adapters", async () => {
it("skips runtime materialization when listing Codex skills", async () => {
mockAgentService.getById.mockResolvedValue(makeAgent("codex_local"));
mockAdapter.listSkills.mockResolvedValue({
adapterType: "codex_local",
supported: true,
mode: "ephemeral",
desiredSkills: ["paperclipai/paperclip/paperclip"],
entries: [],
warnings: [],
});
const res = await request(createApp())
.get("/api/agents/11111111-1111-4111-8111-111111111111/skills?companyId=company-1");
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockCompanySkillService.listRuntimeSkillEntries).toHaveBeenCalledWith("company-1", {
materializeMissing: false,
});
});
it("keeps runtime materialization for persistent skill adapters", async () => {
mockAgentService.getById.mockResolvedValue(makeAgent("cursor"));
mockAdapter.listSkills.mockResolvedValue({
adapterType: "cursor",
supported: true,
mode: "persistent",
desiredSkills: ["paperclipai/paperclip/paperclip"],
entries: [],

View file

@ -35,6 +35,7 @@ describe("instance settings routes", () => {
vi.clearAllMocks();
mockInstanceSettingsService.getGeneral.mockResolvedValue({
censorUsernameInLogs: false,
keyboardShortcuts: false,
feedbackDataSharingPreference: "prompt",
});
mockInstanceSettingsService.getExperimental.mockResolvedValue({
@ -45,6 +46,7 @@ describe("instance settings routes", () => {
id: "instance-settings-1",
general: {
censorUsernameInLogs: true,
keyboardShortcuts: true,
feedbackDataSharingPreference: "allowed",
},
});
@ -114,6 +116,7 @@ describe("instance settings routes", () => {
expect(getRes.status).toBe(200);
expect(getRes.body).toEqual({
censorUsernameInLogs: false,
keyboardShortcuts: false,
feedbackDataSharingPreference: "prompt",
});
@ -121,18 +124,20 @@ describe("instance settings routes", () => {
.patch("/api/instance/settings/general")
.send({
censorUsernameInLogs: true,
keyboardShortcuts: true,
feedbackDataSharingPreference: "allowed",
});
expect(patchRes.status).toBe(200);
expect(mockInstanceSettingsService.updateGeneral).toHaveBeenCalledWith({
censorUsernameInLogs: true,
keyboardShortcuts: true,
feedbackDataSharingPreference: "allowed",
});
expect(mockLogActivity).toHaveBeenCalledTimes(2);
});
it("rejects non-admin board users", async () => {
it("allows non-admin board users to read general settings", async () => {
const app = createApp({
type: "board",
userId: "user-1",
@ -143,8 +148,25 @@ describe("instance settings routes", () => {
const res = await request(app).get("/api/instance/settings/general");
expect(res.status).toBe(200);
expect(mockInstanceSettingsService.getGeneral).toHaveBeenCalled();
});
it("rejects non-admin board users from updating general settings", async () => {
const app = createApp({
type: "board",
userId: "user-1",
source: "session",
isInstanceAdmin: false,
companyIds: ["company-1"],
});
const res = await request(app)
.patch("/api/instance/settings/general")
.send({ censorUsernameInLogs: true, keyboardShortcuts: true });
expect(res.status).toBe(403);
expect(mockInstanceSettingsService.getGeneral).not.toHaveBeenCalled();
expect(mockInstanceSettingsService.updateGeneral).not.toHaveBeenCalled();
});
it("rejects agent callers", async () => {

View file

@ -1,4 +1,5 @@
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
activityLog,
@ -428,6 +429,160 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
resurfacedIssueId,
]));
});
it("resurfaces archived issue when status/updatedAt changes after archiving", async () => {
const companyId = randomUUID();
const userId = "user-1";
const otherUserId = "user-2";
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
const issueId = randomUUID();
await db.insert(issues).values({
id: issueId,
companyId,
title: "Issue with old comment then status change",
status: "todo",
priority: "medium",
createdByUserId: userId,
createdAt: new Date("2026-03-26T10:00:00.000Z"),
updatedAt: new Date("2026-03-26T10:00:00.000Z"),
});
// Old external comment before archiving
await db.insert(issueComments).values({
companyId,
issueId,
authorUserId: otherUserId,
body: "Old comment before archive",
createdAt: new Date("2026-03-26T11:00:00.000Z"),
updatedAt: new Date("2026-03-26T11:00:00.000Z"),
});
// Archive after seeing the comment
await svc.archiveInbox(
companyId,
issueId,
userId,
new Date("2026-03-26T12:00:00.000Z"),
);
// Verify it's archived
const afterArchive = await svc.list(companyId, {
touchedByUserId: userId,
inboxArchivedByUserId: userId,
});
expect(afterArchive.map((i) => i.id)).not.toContain(issueId);
// Status/work update changes updatedAt (no new comment)
await db
.update(issues)
.set({
status: "in_progress",
updatedAt: new Date("2026-03-26T13:00:00.000Z"),
})
.where(eq(issues.id, issueId));
// Should resurface because updatedAt > archivedAt
const afterUpdate = await svc.list(companyId, {
touchedByUserId: userId,
inboxArchivedByUserId: userId,
});
expect(afterUpdate.map((i) => i.id)).toContain(issueId);
});
it("sorts and exposes last activity from comments and non-local issue activity logs", async () => {
const companyId = randomUUID();
const olderIssueId = randomUUID();
const commentIssueId = randomUUID();
const activityIssueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(issues).values([
{
id: olderIssueId,
companyId,
title: "Older issue",
status: "todo",
priority: "medium",
updatedAt: new Date("2026-03-26T10:00:00.000Z"),
},
{
id: commentIssueId,
companyId,
title: "Comment activity issue",
status: "todo",
priority: "medium",
updatedAt: new Date("2026-03-26T10:00:00.000Z"),
},
{
id: activityIssueId,
companyId,
title: "Logged activity issue",
status: "todo",
priority: "medium",
updatedAt: new Date("2026-03-26T10:00:00.000Z"),
},
]);
await db.insert(issueComments).values({
companyId,
issueId: commentIssueId,
body: "New comment without touching issue.updatedAt",
createdAt: new Date("2026-03-26T11:00:00.000Z"),
updatedAt: new Date("2026-03-26T11:00:00.000Z"),
});
await db.insert(activityLog).values([
{
companyId,
actorType: "system",
actorId: "system",
action: "issue.document_updated",
entityType: "issue",
entityId: activityIssueId,
createdAt: new Date("2026-03-26T12:00:00.000Z"),
},
{
companyId,
actorType: "user",
actorId: "user-1",
action: "issue.read_marked",
entityType: "issue",
entityId: olderIssueId,
createdAt: new Date("2026-03-26T13:00:00.000Z"),
},
]);
const result = await svc.list(companyId, {});
expect(result.map((issue) => issue.id)).toEqual([
activityIssueId,
commentIssueId,
olderIssueId,
]);
expect(result.find((issue) => issue.id === activityIssueId)?.lastActivityAt?.toISOString()).toBe(
"2026-03-26T12:00:00.000Z",
);
expect(result.find((issue) => issue.id === commentIssueId)?.lastActivityAt?.toISOString()).toBe(
"2026-03-26T11:00:00.000Z",
);
expect(result.find((issue) => issue.id === olderIssueId)?.lastActivityAt?.toISOString()).toBe(
"2026-03-26T10:00:00.000Z",
);
});
});
describeEmbeddedPostgres("issueService.create workspace inheritance", () => {

View file

@ -10,11 +10,13 @@ import {
companies,
companyMemberships,
createDb,
executionWorkspaces,
heartbeatRunEvents,
heartbeatRuns,
instanceSettings,
issues,
principalPermissionGrants,
projectWorkspaces,
projects,
routineRuns,
routines,
@ -102,6 +104,8 @@ describeEmbeddedPostgres("routine routes end-to-end", () => {
await db.delete(heartbeatRuns);
await db.delete(agentWakeupRequests);
await db.delete(issues);
await db.delete(executionWorkspaces);
await db.delete(projectWorkspaces);
await db.delete(principalPermissionGrants);
await db.delete(companyMemberships);
await db.delete(routines);
@ -272,4 +276,136 @@ describeEmbeddedPostgres("routine routes end-to-end", () => {
]),
);
});
it("runs routines with variable inputs and interpolates the execution issue description", async () => {
const { companyId, agentId, projectId, userId } = await seedFixture();
const app = await createApp({
type: "board",
userId,
source: "session",
isInstanceAdmin: false,
companyIds: [companyId],
});
const createRes = await request(app)
.post(`/api/companies/${companyId}/routines`)
.send({
projectId,
title: "Repository triage",
description: "Review {{repo}} for {{priority}} bugs",
assigneeAgentId: agentId,
variables: [
{ name: "repo", type: "text", required: true },
{ name: "priority", type: "select", required: true, defaultValue: "high", options: ["high", "low"] },
],
});
expect(createRes.status).toBe(201);
const runRes = await request(app)
.post(`/api/routines/${createRes.body.id}/run`)
.send({
source: "manual",
variables: { repo: "paperclip" },
});
expect(runRes.status).toBe(202);
expect(runRes.body.triggerPayload).toEqual({
variables: {
repo: "paperclip",
priority: "high",
},
});
const [issue] = await db
.select({ description: issues.description })
.from(issues)
.where(eq(issues.id, runRes.body.linkedIssueId));
expect(issue?.description).toBe("Review paperclip for high bugs");
});
it("persists execution workspace selections from manual routine runs", async () => {
const { companyId, agentId, projectId, userId } = await seedFixture();
const projectWorkspaceId = randomUUID();
const executionWorkspaceId = randomUUID();
const app = await createApp({
type: "board",
userId,
source: "session",
isInstanceAdmin: false,
companyIds: [companyId],
});
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId,
projectId,
name: "Primary workspace",
isPrimary: true,
sharedWorkspaceKey: "routine-primary",
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
projectWorkspaceId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Routine worktree",
status: "active",
providerType: "git_worktree",
});
await db
.update(projects)
.set({
executionWorkspacePolicy: {
enabled: true,
defaultMode: "shared_workspace",
defaultProjectWorkspaceId: projectWorkspaceId,
},
})
.where(eq(projects.id, projectId));
await db.insert(instanceSettings).values({
experimental: { enableIsolatedWorkspaces: true },
});
const createRes = await request(app)
.post(`/api/companies/${companyId}/routines`)
.send({
projectId,
title: "Workspace-aware routine",
assigneeAgentId: agentId,
});
expect(createRes.status).toBe(201);
const runRes = await request(app)
.post(`/api/routines/${createRes.body.id}/run`)
.send({
source: "manual",
executionWorkspaceId,
executionWorkspacePreference: "reuse_existing",
executionWorkspaceSettings: { mode: "isolated_workspace" },
});
expect(runRes.status).toBe(202);
const [issue] = await db
.select({
projectWorkspaceId: issues.projectWorkspaceId,
executionWorkspaceId: issues.executionWorkspaceId,
executionWorkspacePreference: issues.executionWorkspacePreference,
executionWorkspaceSettings: issues.executionWorkspaceSettings,
})
.from(issues)
.where(eq(issues.id, runRes.body.linkedIssueId));
expect(issue).toEqual({
projectWorkspaceId,
executionWorkspaceId,
executionWorkspacePreference: "reuse_existing",
executionWorkspaceSettings: { mode: "isolated_workspace" },
});
});
});

View file

@ -8,8 +8,11 @@ import {
companySecrets,
companySecretVersions,
createDb,
executionWorkspaces,
heartbeatRuns,
instanceSettings,
issues,
projectWorkspaces,
projects,
routineRuns,
routines,
@ -20,6 +23,7 @@ import {
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { issueService } from "../services/issues.ts";
import { instanceSettingsService } from "../services/instance-settings.ts";
import { routineService } from "../services/routines.ts";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
@ -49,9 +53,12 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
await db.delete(companySecrets);
await db.delete(heartbeatRuns);
await db.delete(issues);
await db.delete(executionWorkspaces);
await db.delete(projectWorkspaces);
await db.delete(projects);
await db.delete(agents);
await db.delete(companies);
await db.delete(instanceSettings);
});
afterAll(async () => {
@ -317,6 +324,196 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
expect(routineIssues[0]?.id).toBe(previousIssue.id);
});
it("interpolates routine variables into the execution issue and stores resolved values", async () => {
const { companyId, agentId, projectId, svc } = await seedFixture();
const variableRoutine = await svc.create(
companyId,
{
projectId,
goalId: null,
parentIssueId: null,
title: "repo triage",
description: "Review {{repo}} for {{priority}} bugs",
assigneeAgentId: agentId,
priority: "medium",
status: "active",
concurrencyPolicy: "coalesce_if_active",
catchUpPolicy: "skip_missed",
variables: [
{ name: "repo", label: null, type: "text", defaultValue: null, required: true, options: [] },
{ name: "priority", label: null, type: "select", defaultValue: "high", required: true, options: ["high", "low"] },
],
},
{},
);
const run = await svc.runRoutine(variableRoutine.id, {
source: "manual",
variables: { repo: "paperclip" },
});
const storedIssue = await db
.select({ description: issues.description })
.from(issues)
.where(eq(issues.id, run.linkedIssueId!))
.then((rows) => rows[0] ?? null);
const storedRun = await db
.select({ triggerPayload: routineRuns.triggerPayload })
.from(routineRuns)
.where(eq(routineRuns.id, run.id))
.then((rows) => rows[0] ?? null);
expect(storedIssue?.description).toBe("Review paperclip for high bugs");
expect(storedRun?.triggerPayload).toEqual({
variables: {
repo: "paperclip",
priority: "high",
},
});
});
it("attaches the selected execution workspace to manually triggered routine issues", async () => {
const { companyId, projectId, routine, svc } = await seedFixture();
const projectWorkspaceId = randomUUID();
const executionWorkspaceId = randomUUID();
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
await db
.update(projects)
.set({
executionWorkspacePolicy: {
enabled: true,
defaultMode: "shared_workspace",
defaultProjectWorkspaceId: projectWorkspaceId,
},
})
.where(eq(projects.id, projectId));
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId,
projectId,
name: "Primary workspace",
isPrimary: true,
sharedWorkspaceKey: "routine-primary",
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
projectWorkspaceId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Routine worktree",
status: "active",
providerType: "git_worktree",
});
const run = await svc.runRoutine(routine.id, {
source: "manual",
executionWorkspaceId,
executionWorkspacePreference: "reuse_existing",
executionWorkspaceSettings: { mode: "isolated_workspace" },
});
const storedIssue = await db
.select({
projectWorkspaceId: issues.projectWorkspaceId,
executionWorkspaceId: issues.executionWorkspaceId,
executionWorkspacePreference: issues.executionWorkspacePreference,
executionWorkspaceSettings: issues.executionWorkspaceSettings,
})
.from(issues)
.where(eq(issues.id, run.linkedIssueId!))
.then((rows) => rows[0] ?? null);
expect(storedIssue).toEqual({
projectWorkspaceId,
executionWorkspaceId,
executionWorkspacePreference: "reuse_existing",
executionWorkspaceSettings: { mode: "isolated_workspace" },
});
});
it("blocks schedule triggers when required variables do not have defaults", async () => {
const { companyId, agentId, projectId, svc } = await seedFixture();
const variableRoutine = await svc.create(
companyId,
{
projectId,
goalId: null,
parentIssueId: null,
title: "repo triage",
description: "Review {{repo}}",
assigneeAgentId: agentId,
priority: "medium",
status: "active",
concurrencyPolicy: "coalesce_if_active",
catchUpPolicy: "skip_missed",
variables: [
{ name: "repo", label: null, type: "text", defaultValue: null, required: true, options: [] },
],
},
{},
);
await expect(
svc.createTrigger(variableRoutine.id, {
kind: "schedule",
label: "daily",
cronExpression: "0 10 * * *",
timezone: "UTC",
}, {}),
).rejects.toThrow(/require defaults for required variables/i);
});
it("treats malformed stored defaults as missing when validating schedule triggers", async () => {
const { companyId, agentId, projectId, svc } = await seedFixture();
const variableRoutine = await svc.create(
companyId,
{
projectId,
goalId: null,
parentIssueId: null,
title: "ship check",
description: "Review {{approved}}",
assigneeAgentId: agentId,
priority: "medium",
status: "active",
concurrencyPolicy: "coalesce_if_active",
catchUpPolicy: "skip_missed",
variables: [
{ name: "approved", label: null, type: "boolean", defaultValue: true, required: true, options: [] },
],
},
{},
);
await db
.update(routines)
.set({
variables: [
{
name: "approved",
label: null,
type: "boolean",
defaultValue: "definitely",
required: true,
options: [],
},
],
})
.where(eq(routines.id, variableRoutine.id));
await expect(
svc.createTrigger(variableRoutine.id, {
kind: "schedule",
label: "daily",
cronExpression: "0 10 * * *",
timezone: "UTC",
}, {}),
).rejects.toThrow(/require defaults for required variables/i);
});
it("serializes concurrent dispatches until the first execution issue is linked to a queued run", async () => {
const { routine, svc } = await seedFixture({
wakeup: async (wakeupAgentId, wakeupOpts) => {

View file

@ -552,7 +552,7 @@ describe("realizeExecutionWorkspace", () => {
} finally {
process.chdir(previousCwd);
}
});
}, 15_000);
it("records worktree setup and provision operations when a recorder is provided", async () => {
const repoRoot = await createTempRepo();