mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
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:
commit
fb3b57ab1f
59 changed files with 16794 additions and 375 deletions
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { Router, type Request } from "express";
|
|||
import { generateKeyPairSync, randomUUID } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agents as agentsTable, companies, heartbeatRuns } from "@paperclipai/db";
|
||||
import { agents as agentsTable, companies, heartbeatRuns, issues as issuesTable } from "@paperclipai/db";
|
||||
import { and, desc, eq, inArray, not, sql } from "drizzle-orm";
|
||||
import {
|
||||
agentSkillSyncSchema,
|
||||
|
|
@ -220,6 +220,73 @@ export function agentRoutes(db: Db) {
|
|||
return allowedByGrant || canCreateAgents(actorAgent);
|
||||
}
|
||||
|
||||
async function buildSkippedWakeupResponse(
|
||||
agent: NonNullable<Awaited<ReturnType<typeof svc.getById>>>,
|
||||
payload: Record<string, unknown> | null | undefined,
|
||||
) {
|
||||
const issueId = typeof payload?.issueId === "string" && payload.issueId.trim() ? payload.issueId : null;
|
||||
if (!issueId) {
|
||||
return {
|
||||
status: "skipped" as const,
|
||||
reason: "wakeup_skipped",
|
||||
message: "Wakeup was skipped.",
|
||||
issueId: null,
|
||||
executionRunId: null,
|
||||
executionAgentId: null,
|
||||
executionAgentName: null,
|
||||
};
|
||||
}
|
||||
|
||||
const issue = await db
|
||||
.select({
|
||||
id: issuesTable.id,
|
||||
executionRunId: issuesTable.executionRunId,
|
||||
})
|
||||
.from(issuesTable)
|
||||
.where(and(eq(issuesTable.id, issueId), eq(issuesTable.companyId, agent.companyId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!issue?.executionRunId) {
|
||||
return {
|
||||
status: "skipped" as const,
|
||||
reason: "wakeup_skipped",
|
||||
message: "Wakeup was skipped.",
|
||||
issueId,
|
||||
executionRunId: null,
|
||||
executionAgentId: null,
|
||||
executionAgentName: null,
|
||||
};
|
||||
}
|
||||
|
||||
const executionRun = await heartbeat.getRun(issue.executionRunId);
|
||||
if (!executionRun || (executionRun.status !== "queued" && executionRun.status !== "running")) {
|
||||
return {
|
||||
status: "skipped" as const,
|
||||
reason: "wakeup_skipped",
|
||||
message: "Wakeup was skipped.",
|
||||
issueId,
|
||||
executionRunId: issue.executionRunId,
|
||||
executionAgentId: null,
|
||||
executionAgentName: null,
|
||||
};
|
||||
}
|
||||
|
||||
const executionAgent = await svc.getById(executionRun.agentId);
|
||||
const executionAgentName = executionAgent?.name ?? null;
|
||||
|
||||
return {
|
||||
status: "skipped" as const,
|
||||
reason: "issue_execution_deferred",
|
||||
message: executionAgentName
|
||||
? `Wakeup was deferred because this issue is already being executed by ${executionAgentName}.`
|
||||
: "Wakeup was deferred because this issue already has an active execution run.",
|
||||
issueId,
|
||||
executionRunId: executionRun.id,
|
||||
executionAgentId: executionRun.agentId,
|
||||
executionAgentName,
|
||||
};
|
||||
}
|
||||
|
||||
async function assertCanUpdateAgent(req: Request, targetAgent: { id: string; companyId: string }) {
|
||||
assertCompanyAccess(req, targetAgent.companyId);
|
||||
if (req.actor.type === "board") return;
|
||||
|
|
@ -532,8 +599,15 @@ export function agentRoutes(db: Db) {
|
|||
};
|
||||
}
|
||||
|
||||
const ADAPTERS_REQUIRING_MATERIALIZED_RUNTIME_SKILLS = new Set([
|
||||
"cursor",
|
||||
"gemini_local",
|
||||
"opencode_local",
|
||||
"pi_local",
|
||||
]);
|
||||
|
||||
function shouldMaterializeRuntimeSkillsForAdapter(adapterType: string) {
|
||||
return adapterType !== "claude_local";
|
||||
return ADAPTERS_REQUIRING_MATERIALIZED_RUNTIME_SKILLS.has(adapterType);
|
||||
}
|
||||
|
||||
async function buildRuntimeSkillConfig(
|
||||
|
|
@ -1994,7 +2068,7 @@ export function agentRoutes(db: Db) {
|
|||
});
|
||||
|
||||
if (!run) {
|
||||
res.status(202).json({ status: "skipped" });
|
||||
res.status(202).json(await buildSkippedWakeupResponse(agent, req.body.payload ?? null));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,11 @@ export function instanceSettingsRoutes(db: Db) {
|
|||
const svc = instanceSettingsService(db);
|
||||
|
||||
router.get("/instance/settings/general", async (req, res) => {
|
||||
assertCanManageInstanceSettings(req);
|
||||
// General settings (e.g. keyboardShortcuts) are readable by any
|
||||
// authenticated board user. Only PATCH requires instance-admin.
|
||||
if (req.actor.type !== "board") {
|
||||
throw forbidden("Board access required");
|
||||
}
|
||||
res.json(await svc.getGeneral());
|
||||
});
|
||||
|
||||
|
|
@ -56,7 +60,11 @@ export function instanceSettingsRoutes(db: Db) {
|
|||
);
|
||||
|
||||
router.get("/instance/settings/experimental", async (req, res) => {
|
||||
assertCanManageInstanceSettings(req);
|
||||
// Experimental settings are readable by any authenticated board user.
|
||||
// Only PATCH requires instance-admin.
|
||||
if (req.actor.type !== "board") {
|
||||
throw forbidden("Board access required");
|
||||
}
|
||||
res.json(await svc.getExperimental());
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import type {
|
|||
CompanyPortabilitySidebarOrder,
|
||||
CompanyPortabilitySkillManifestEntry,
|
||||
CompanySkill,
|
||||
RoutineVariable,
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
ISSUE_PRIORITIES,
|
||||
|
|
@ -523,7 +524,7 @@ const ADAPTER_DEFAULT_RULES_BY_TYPE: Record<string, Array<{ path: string[]; valu
|
|||
claude_local: [
|
||||
{ path: ["timeoutSec"], value: 0 },
|
||||
{ path: ["graceSec"], value: 15 },
|
||||
{ path: ["maxTurnsPerRun"], value: 300 },
|
||||
{ path: ["maxTurnsPerRun"], value: 1000 },
|
||||
],
|
||||
openclaw_gateway: [
|
||||
{ path: ["timeoutSec"], value: 120 },
|
||||
|
|
@ -568,6 +569,29 @@ function normalizeRoutineTriggerExtension(value: unknown): CompanyPortabilityIss
|
|||
};
|
||||
}
|
||||
|
||||
function normalizeRoutineVariableExtension(value: unknown): RoutineVariable | null {
|
||||
if (!isPlainRecord(value)) return null;
|
||||
const name = asString(value.name);
|
||||
if (!name) return null;
|
||||
const type = asString(value.type) ?? "text";
|
||||
if (!["text", "textarea", "number", "boolean", "select"].includes(type)) return null;
|
||||
const options = Array.isArray(value.options)
|
||||
? value.options.map((entry) => asString(entry)).filter((entry): entry is string => Boolean(entry))
|
||||
: [];
|
||||
const defaultValue =
|
||||
typeof value.defaultValue === "string" || typeof value.defaultValue === "number" || typeof value.defaultValue === "boolean"
|
||||
? value.defaultValue
|
||||
: null;
|
||||
return {
|
||||
name,
|
||||
label: asString(value.label),
|
||||
type: type as RoutineVariable["type"],
|
||||
defaultValue,
|
||||
required: asBoolean(value.required) ?? true,
|
||||
options,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRoutineExtension(value: unknown): CompanyPortabilityIssueRoutineManifestEntry | null {
|
||||
if (!isPlainRecord(value)) return null;
|
||||
const triggers = Array.isArray(value.triggers)
|
||||
|
|
@ -575,9 +599,15 @@ function normalizeRoutineExtension(value: unknown): CompanyPortabilityIssueRouti
|
|||
.map((entry) => normalizeRoutineTriggerExtension(entry))
|
||||
.filter((entry): entry is CompanyPortabilityIssueRoutineTriggerManifestEntry => entry !== null)
|
||||
: [];
|
||||
const variables = Array.isArray(value.variables)
|
||||
? value.variables
|
||||
.map((entry) => normalizeRoutineVariableExtension(entry))
|
||||
.filter((entry): entry is RoutineVariable => entry !== null)
|
||||
: null;
|
||||
const routine = {
|
||||
concurrencyPolicy: asString(value.concurrencyPolicy),
|
||||
catchUpPolicy: asString(value.catchUpPolicy),
|
||||
variables,
|
||||
triggers,
|
||||
};
|
||||
return stripEmptyValues(routine) ? routine : null;
|
||||
|
|
@ -587,6 +617,7 @@ function buildRoutineManifestFromLiveRoutine(routine: RoutineLike): CompanyPorta
|
|||
return {
|
||||
concurrencyPolicy: routine.concurrencyPolicy,
|
||||
catchUpPolicy: routine.catchUpPolicy,
|
||||
variables: routine.variables,
|
||||
triggers: routine.triggers.map((trigger) => ({
|
||||
kind: trigger.kind,
|
||||
label: trigger.label ?? null,
|
||||
|
|
@ -1086,11 +1117,13 @@ function resolvePortableRoutineDefinition(
|
|||
? {
|
||||
concurrencyPolicy: issue.routine.concurrencyPolicy,
|
||||
catchUpPolicy: issue.routine.catchUpPolicy,
|
||||
variables: issue.routine.variables ?? null,
|
||||
triggers: [...issue.routine.triggers],
|
||||
}
|
||||
: {
|
||||
concurrencyPolicy: null,
|
||||
catchUpPolicy: null,
|
||||
variables: null,
|
||||
triggers: [] as CompanyPortabilityIssueRoutineTriggerManifestEntry[],
|
||||
};
|
||||
|
||||
|
|
@ -3204,6 +3237,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
|||
priority: routine.priority !== "medium" ? routine.priority : undefined,
|
||||
concurrencyPolicy: routine.concurrencyPolicy !== "coalesce_if_active" ? routine.concurrencyPolicy : undefined,
|
||||
catchUpPolicy: routine.catchUpPolicy !== "skip_missed" ? routine.catchUpPolicy : undefined,
|
||||
variables: (routine.variables ?? []).length > 0 ? routine.variables : undefined,
|
||||
triggers: routine.triggers.map((trigger) => stripEmptyValues({
|
||||
kind: trigger.kind,
|
||||
label: trigger.label ?? null,
|
||||
|
|
@ -4173,6 +4207,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
|||
const routineDefinition = resolvedRoutine.routine ?? {
|
||||
concurrencyPolicy: null,
|
||||
catchUpPolicy: null,
|
||||
variables: null,
|
||||
triggers: [],
|
||||
};
|
||||
const createdRoutine = await routines.create(targetCompany.id, {
|
||||
|
|
@ -4196,6 +4231,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
|||
routineDefinition.catchUpPolicy && ROUTINE_CATCH_UP_POLICIES.includes(routineDefinition.catchUpPolicy as any)
|
||||
? routineDefinition.catchUpPolicy as typeof ROUTINE_CATCH_UP_POLICIES[number]
|
||||
: "skip_missed",
|
||||
variables: routineDefinition.variables ?? [],
|
||||
}, {
|
||||
agentId: null,
|
||||
userId: actorUserId ?? null,
|
||||
|
|
|
|||
|
|
@ -99,6 +99,11 @@ type IssueUserCommentStats = {
|
|||
myLastCommentAt: Date | null;
|
||||
lastExternalCommentAt: Date | null;
|
||||
};
|
||||
type IssueLastActivityStat = {
|
||||
issueId: string;
|
||||
latestCommentAt: Date | null;
|
||||
latestLogAt: Date | null;
|
||||
};
|
||||
type IssueUserContextInput = {
|
||||
createdByUserId: string | null;
|
||||
assigneeUserId: string | null;
|
||||
|
|
@ -262,8 +267,8 @@ function issueLastActivityAtExpr(companyId: string, userId: string) {
|
|||
const lastExternalCommentAt = lastExternalCommentAtExpr(companyId, userId);
|
||||
const myLastTouchAt = myLastTouchAtExpr(companyId, userId);
|
||||
return sql<Date>`
|
||||
COALESCE(
|
||||
${lastExternalCommentAt},
|
||||
GREATEST(
|
||||
COALESCE(${lastExternalCommentAt}, to_timestamp(0)),
|
||||
CASE
|
||||
WHEN ${issues.updatedAt} > COALESCE(${myLastTouchAt}, to_timestamp(0))
|
||||
THEN ${issues.updatedAt}
|
||||
|
|
@ -273,6 +278,52 @@ function issueLastActivityAtExpr(companyId: string, userId: string) {
|
|||
`;
|
||||
}
|
||||
|
||||
const ISSUE_LOCAL_INBOX_ACTIVITY_ACTIONS = [
|
||||
"issue.read_marked",
|
||||
"issue.read_unmarked",
|
||||
"issue.inbox_archived",
|
||||
"issue.inbox_unarchived",
|
||||
] as const;
|
||||
|
||||
function issueLatestCommentAtExpr(companyId: string) {
|
||||
return sql<Date | null>`
|
||||
(
|
||||
SELECT MAX(${issueComments.createdAt})
|
||||
FROM ${issueComments}
|
||||
WHERE ${issueComments.issueId} = ${issues.id}
|
||||
AND ${issueComments.companyId} = ${companyId}
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
function issueLatestLogAtExpr(companyId: string) {
|
||||
return sql<Date | null>`
|
||||
(
|
||||
SELECT MAX(${activityLog.createdAt})
|
||||
FROM ${activityLog}
|
||||
WHERE ${activityLog.companyId} = ${companyId}
|
||||
AND ${activityLog.entityType} = 'issue'
|
||||
AND ${activityLog.entityId} = ${issues.id}::text
|
||||
AND ${activityLog.action} NOT IN (${sql.join(
|
||||
ISSUE_LOCAL_INBOX_ACTIVITY_ACTIONS.map((action) => sql`${action}`),
|
||||
sql`, `,
|
||||
)})
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
function issueCanonicalLastActivityAtExpr(companyId: string) {
|
||||
const latestCommentAt = issueLatestCommentAtExpr(companyId);
|
||||
const latestLogAt = issueLatestLogAtExpr(companyId);
|
||||
return sql<Date>`
|
||||
GREATEST(
|
||||
${issues.updatedAt},
|
||||
COALESCE(${latestCommentAt}, to_timestamp(0)),
|
||||
COALESCE(${latestLogAt}, to_timestamp(0))
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
function unreadForUserCondition(companyId: string, userId: string) {
|
||||
const touchedCondition = touchedByUserCondition(companyId, userId);
|
||||
const myLastTouchAt = myLastTouchAtExpr(companyId, userId);
|
||||
|
|
@ -383,6 +434,19 @@ export function deriveIssueUserContext(
|
|||
};
|
||||
}
|
||||
|
||||
function latestIssueActivityAt(...values: Array<Date | string | null | undefined>): Date | null {
|
||||
const normalized = values
|
||||
.map((value) => {
|
||||
if (!value) return null;
|
||||
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value;
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||
})
|
||||
.filter((value): value is Date => value instanceof Date)
|
||||
.sort((a, b) => b.getTime() - a.getTime());
|
||||
return normalized[0] ?? null;
|
||||
}
|
||||
|
||||
async function labelMapForIssues(dbOrTx: any, issueIds: string[]): Promise<Map<string, IssueLabelRow[]>> {
|
||||
const map = new Map<string, IssueLabelRow[]>();
|
||||
if (issueIds.length === 0) return map;
|
||||
|
|
@ -749,66 +813,158 @@ export function issueService(db: Db) {
|
|||
ELSE 6
|
||||
END
|
||||
`;
|
||||
const canonicalLastActivityAt = issueCanonicalLastActivityAtExpr(companyId);
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(issues)
|
||||
.where(and(...conditions))
|
||||
.orderBy(hasSearch ? asc(searchOrder) : asc(priorityOrder), asc(priorityOrder), desc(issues.updatedAt));
|
||||
.orderBy(
|
||||
hasSearch ? asc(searchOrder) : asc(priorityOrder),
|
||||
asc(priorityOrder),
|
||||
desc(canonicalLastActivityAt),
|
||||
desc(issues.updatedAt),
|
||||
);
|
||||
const withLabels = await withIssueLabels(db, rows);
|
||||
const runMap = await activeRunMapForIssues(db, withLabels);
|
||||
const withRuns = withActiveRuns(withLabels, runMap);
|
||||
if (!contextUserId || withRuns.length === 0) {
|
||||
if (withRuns.length === 0) {
|
||||
return withRuns;
|
||||
}
|
||||
|
||||
const issueIds = withRuns.map((row) => row.id);
|
||||
const statsRows = await db
|
||||
.select({
|
||||
issueId: issueComments.issueId,
|
||||
myLastCommentAt: sql<Date | null>`
|
||||
MAX(CASE WHEN ${issueComments.authorUserId} = ${contextUserId} THEN ${issueComments.createdAt} END)
|
||||
`,
|
||||
lastExternalCommentAt: sql<Date | null>`
|
||||
MAX(
|
||||
CASE
|
||||
WHEN ${issueComments.authorUserId} IS NULL OR ${issueComments.authorUserId} <> ${contextUserId}
|
||||
THEN ${issueComments.createdAt}
|
||||
END
|
||||
const [statsRows, readRows, lastActivityRows] = await Promise.all([
|
||||
contextUserId
|
||||
? db
|
||||
.select({
|
||||
issueId: issueComments.issueId,
|
||||
myLastCommentAt: sql<Date | null>`
|
||||
MAX(CASE WHEN ${issueComments.authorUserId} = ${contextUserId} THEN ${issueComments.createdAt} END)
|
||||
`,
|
||||
lastExternalCommentAt: sql<Date | null>`
|
||||
MAX(
|
||||
CASE
|
||||
WHEN ${issueComments.authorUserId} IS NULL OR ${issueComments.authorUserId} <> ${contextUserId}
|
||||
THEN ${issueComments.createdAt}
|
||||
END
|
||||
)
|
||||
`,
|
||||
})
|
||||
.from(issueComments)
|
||||
.where(
|
||||
and(
|
||||
eq(issueComments.companyId, companyId),
|
||||
inArray(issueComments.issueId, issueIds),
|
||||
),
|
||||
)
|
||||
`,
|
||||
})
|
||||
.from(issueComments)
|
||||
.where(
|
||||
and(
|
||||
eq(issueComments.companyId, companyId),
|
||||
inArray(issueComments.issueId, issueIds),
|
||||
),
|
||||
)
|
||||
.groupBy(issueComments.issueId);
|
||||
const readRows = await db
|
||||
.select({
|
||||
issueId: issueReadStates.issueId,
|
||||
myLastReadAt: issueReadStates.lastReadAt,
|
||||
})
|
||||
.from(issueReadStates)
|
||||
.where(
|
||||
and(
|
||||
eq(issueReadStates.companyId, companyId),
|
||||
eq(issueReadStates.userId, contextUserId),
|
||||
inArray(issueReadStates.issueId, issueIds),
|
||||
),
|
||||
);
|
||||
.groupBy(issueComments.issueId)
|
||||
: Promise.resolve([]),
|
||||
contextUserId
|
||||
? db
|
||||
.select({
|
||||
issueId: issueReadStates.issueId,
|
||||
myLastReadAt: issueReadStates.lastReadAt,
|
||||
})
|
||||
.from(issueReadStates)
|
||||
.where(
|
||||
and(
|
||||
eq(issueReadStates.companyId, companyId),
|
||||
eq(issueReadStates.userId, contextUserId),
|
||||
inArray(issueReadStates.issueId, issueIds),
|
||||
),
|
||||
)
|
||||
: Promise.resolve([]),
|
||||
Promise.all([
|
||||
db
|
||||
.select({
|
||||
issueId: issueComments.issueId,
|
||||
latestCommentAt: sql<Date | null>`MAX(${issueComments.createdAt})`,
|
||||
})
|
||||
.from(issueComments)
|
||||
.where(
|
||||
and(
|
||||
eq(issueComments.companyId, companyId),
|
||||
inArray(issueComments.issueId, issueIds),
|
||||
),
|
||||
)
|
||||
.groupBy(issueComments.issueId),
|
||||
db
|
||||
.select({
|
||||
issueId: activityLog.entityId,
|
||||
latestLogAt: sql<Date | null>`MAX(${activityLog.createdAt})`,
|
||||
})
|
||||
.from(activityLog)
|
||||
.where(
|
||||
and(
|
||||
eq(activityLog.companyId, companyId),
|
||||
eq(activityLog.entityType, "issue"),
|
||||
inArray(activityLog.entityId, issueIds),
|
||||
sql`${activityLog.action} NOT IN (${sql.join(
|
||||
ISSUE_LOCAL_INBOX_ACTIVITY_ACTIONS.map((action) => sql`${action}`),
|
||||
sql`, `,
|
||||
)})`,
|
||||
),
|
||||
)
|
||||
.groupBy(activityLog.entityId),
|
||||
]).then(([commentRows, logRows]) => {
|
||||
const byIssueId = new Map<string, IssueLastActivityStat>();
|
||||
for (const row of commentRows) {
|
||||
byIssueId.set(row.issueId, {
|
||||
issueId: row.issueId,
|
||||
latestCommentAt: row.latestCommentAt,
|
||||
latestLogAt: null,
|
||||
});
|
||||
}
|
||||
for (const row of logRows) {
|
||||
const existing = byIssueId.get(row.issueId);
|
||||
if (existing) existing.latestLogAt = row.latestLogAt;
|
||||
else {
|
||||
byIssueId.set(row.issueId, {
|
||||
issueId: row.issueId,
|
||||
latestCommentAt: null,
|
||||
latestLogAt: row.latestLogAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
return [...byIssueId.values()];
|
||||
}),
|
||||
]);
|
||||
const statsByIssueId = new Map(statsRows.map((row) => [row.issueId, row]));
|
||||
const lastActivityByIssueId = new Map(lastActivityRows.map((row) => [row.issueId, row]));
|
||||
|
||||
if (!contextUserId) {
|
||||
return withRuns.map((row) => {
|
||||
const activity = lastActivityByIssueId.get(row.id);
|
||||
const lastActivityAt = latestIssueActivityAt(
|
||||
row.updatedAt,
|
||||
activity?.latestCommentAt ?? null,
|
||||
activity?.latestLogAt ?? null,
|
||||
) ?? row.updatedAt;
|
||||
return {
|
||||
...row,
|
||||
lastActivityAt,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const readByIssueId = new Map(readRows.map((row) => [row.issueId, row.myLastReadAt]));
|
||||
|
||||
return withRuns.map((row) => ({
|
||||
...row,
|
||||
...deriveIssueUserContext(row, contextUserId, {
|
||||
myLastCommentAt: statsByIssueId.get(row.id)?.myLastCommentAt ?? null,
|
||||
myLastReadAt: readByIssueId.get(row.id) ?? null,
|
||||
lastExternalCommentAt: statsByIssueId.get(row.id)?.lastExternalCommentAt ?? null,
|
||||
}),
|
||||
}));
|
||||
return withRuns.map((row) => {
|
||||
const activity = lastActivityByIssueId.get(row.id);
|
||||
const lastActivityAt = latestIssueActivityAt(
|
||||
row.updatedAt,
|
||||
activity?.latestCommentAt ?? null,
|
||||
activity?.latestLogAt ?? null,
|
||||
) ?? row.updatedAt;
|
||||
return {
|
||||
...row,
|
||||
lastActivityAt,
|
||||
...deriveIssueUserContext(row, contextUserId, {
|
||||
myLastCommentAt: statsByIssueId.get(row.id)?.myLastCommentAt ?? null,
|
||||
myLastReadAt: readByIssueId.get(row.id) ?? null,
|
||||
lastExternalCommentAt: statsByIssueId.get(row.id)?.lastExternalCommentAt ?? null,
|
||||
}),
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
countUnreadTouchedByUser: async (companyId: string, userId: string, status?: string) => {
|
||||
|
|
|
|||
|
|
@ -21,10 +21,16 @@ import type {
|
|||
RoutineRunSummary,
|
||||
RoutineTrigger,
|
||||
RoutineTriggerSecretMaterial,
|
||||
RoutineVariable,
|
||||
RunRoutine,
|
||||
UpdateRoutine,
|
||||
UpdateRoutineTrigger,
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
interpolateRoutineTemplate,
|
||||
stringifyRoutineVariableValue,
|
||||
syncRoutineVariablesWithTemplate,
|
||||
} from "@paperclipai/shared";
|
||||
import { conflict, forbidden, notFound, unauthorized, unprocessable } from "../errors.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import { issueService } from "./issues.js";
|
||||
|
|
@ -138,6 +144,151 @@ function normalizeWebhookTimestampMs(rawTimestamp: string) {
|
|||
return parsed > 1e12 ? parsed : parsed * 1000;
|
||||
}
|
||||
|
||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function parseBooleanVariableValue(name: string, raw: unknown) {
|
||||
if (typeof raw === "boolean") return raw;
|
||||
if (typeof raw === "number" && (raw === 0 || raw === 1)) return raw === 1;
|
||||
if (typeof raw === "string") {
|
||||
const normalized = raw.trim().toLowerCase();
|
||||
if (["true", "1", "yes", "y", "on"].includes(normalized)) return true;
|
||||
if (["false", "0", "no", "n", "off"].includes(normalized)) return false;
|
||||
}
|
||||
throw unprocessable(`Variable "${name}" must be a boolean`);
|
||||
}
|
||||
|
||||
function parseNumberVariableValue(name: string, raw: unknown) {
|
||||
if (typeof raw === "number" && Number.isFinite(raw)) return raw;
|
||||
if (typeof raw === "string" && raw.trim().length > 0) {
|
||||
const parsed = Number(raw);
|
||||
if (Number.isFinite(parsed)) return parsed;
|
||||
}
|
||||
throw unprocessable(`Variable "${name}" must be a number`);
|
||||
}
|
||||
|
||||
function normalizeRoutineVariableValue(variable: RoutineVariable, raw: unknown): string | number | boolean | null {
|
||||
if (raw == null) return null;
|
||||
if (variable.type === "boolean") return parseBooleanVariableValue(variable.name, raw);
|
||||
if (variable.type === "number") return parseNumberVariableValue(variable.name, raw);
|
||||
|
||||
const normalized = stringifyRoutineVariableValue(raw);
|
||||
if (variable.type === "select") {
|
||||
if (!variable.options.includes(normalized)) {
|
||||
throw unprocessable(`Variable "${variable.name}" must match one of: ${variable.options.join(", ")}`);
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function isMissingRoutineVariableValue(value: string | number | boolean | null) {
|
||||
return value == null || (typeof value === "string" && value.trim().length === 0);
|
||||
}
|
||||
|
||||
function assertRoutineVariableDefinitions(variables: RoutineVariable[]) {
|
||||
for (const variable of variables) {
|
||||
if (variable.defaultValue != null) {
|
||||
normalizeRoutineVariableValue(variable, variable.defaultValue);
|
||||
}
|
||||
if (variable.type === "select" && variable.options.length === 0) {
|
||||
throw unprocessable(`Variable "${variable.name}" must define at least one option`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeRoutineVariableInputs(
|
||||
variables: Array<Partial<RoutineVariable> & Pick<RoutineVariable, "name">> | null | undefined,
|
||||
): RoutineVariable[] {
|
||||
return (variables ?? []).map((variable) => ({
|
||||
name: variable.name,
|
||||
label: variable.label ?? null,
|
||||
type: variable.type ?? "text",
|
||||
defaultValue: variable.defaultValue ?? null,
|
||||
required: variable.required ?? true,
|
||||
options: variable.options ?? [],
|
||||
}));
|
||||
}
|
||||
|
||||
function assertScheduleCompatibleVariables(variables: RoutineVariable[]) {
|
||||
const missingDefaults = variables
|
||||
.filter((variable) => variable.required)
|
||||
.filter((variable) => {
|
||||
try {
|
||||
return isMissingRoutineVariableValue(normalizeRoutineVariableValue(variable, variable.defaultValue));
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
})
|
||||
.map((variable) => variable.name);
|
||||
if (missingDefaults.length > 0) {
|
||||
throw unprocessable(
|
||||
`Scheduled routines require defaults for required variables: ${missingDefaults.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function collectProvidedRoutineVariables(
|
||||
source: "schedule" | "manual" | "api" | "webhook",
|
||||
payload: Record<string, unknown> | null | undefined,
|
||||
variables: Record<string, unknown> | null | undefined,
|
||||
) {
|
||||
const nestedVariables = isPlainRecord(payload) && isPlainRecord(payload.variables) ? payload.variables : {};
|
||||
const provided = {
|
||||
...(source === "webhook" && payload ? payload : {}),
|
||||
...nestedVariables,
|
||||
...(variables ?? {}),
|
||||
};
|
||||
delete provided.variables;
|
||||
return provided;
|
||||
}
|
||||
|
||||
function resolveRoutineVariableValues(
|
||||
variables: RoutineVariable[],
|
||||
input: {
|
||||
source: "schedule" | "manual" | "api" | "webhook";
|
||||
payload?: Record<string, unknown> | null;
|
||||
variables?: Record<string, unknown> | null;
|
||||
},
|
||||
) {
|
||||
if (variables.length === 0) return {} as Record<string, string | number | boolean>;
|
||||
const provided = collectProvidedRoutineVariables(input.source, input.payload, input.variables);
|
||||
const resolved: Record<string, string | number | boolean> = {};
|
||||
const missing: string[] = [];
|
||||
|
||||
for (const variable of variables) {
|
||||
const candidate = provided[variable.name] !== undefined ? provided[variable.name] : variable.defaultValue;
|
||||
const normalized = normalizeRoutineVariableValue(variable, candidate);
|
||||
if (normalized == null || (typeof normalized === "string" && normalized.trim().length === 0)) {
|
||||
if (variable.required) missing.push(variable.name);
|
||||
continue;
|
||||
}
|
||||
resolved[variable.name] = normalized;
|
||||
}
|
||||
|
||||
if (missing.length > 0) {
|
||||
throw unprocessable(`Missing routine variables: ${missing.join(", ")}`);
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function mergeRoutineRunPayload(
|
||||
payload: Record<string, unknown> | null | undefined,
|
||||
variables: Record<string, string | number | boolean>,
|
||||
) {
|
||||
if (Object.keys(variables).length === 0) return payload ?? null;
|
||||
if (!payload) return { variables };
|
||||
const existingVariables = isPlainRecord(payload.variables) ? payload.variables : {};
|
||||
return {
|
||||
...payload,
|
||||
variables: {
|
||||
...existingVariables,
|
||||
...variables,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeupDeps } = {}) {
|
||||
const issueSvc = issueService(db);
|
||||
const secretsSvc = secretService(db);
|
||||
|
|
@ -515,8 +666,15 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
|
|||
trigger: typeof routineTriggers.$inferSelect | null;
|
||||
source: "schedule" | "manual" | "api" | "webhook";
|
||||
payload?: Record<string, unknown> | null;
|
||||
variables?: Record<string, unknown> | null;
|
||||
idempotencyKey?: string | null;
|
||||
executionWorkspaceId?: string | null;
|
||||
executionWorkspacePreference?: string | null;
|
||||
executionWorkspaceSettings?: Record<string, unknown> | null;
|
||||
}) {
|
||||
const resolvedVariables = resolveRoutineVariableValues(input.routine.variables ?? [], input);
|
||||
const description = interpolateRoutineTemplate(input.routine.description, resolvedVariables);
|
||||
const triggerPayload = mergeRoutineRunPayload(input.payload, resolvedVariables);
|
||||
const run = await db.transaction(async (tx) => {
|
||||
const txDb = tx as unknown as Db;
|
||||
await tx.execute(
|
||||
|
|
@ -553,7 +711,7 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
|
|||
status: "received",
|
||||
triggeredAt,
|
||||
idempotencyKey: input.idempotencyKey ?? null,
|
||||
triggerPayload: input.payload ?? null,
|
||||
triggerPayload,
|
||||
})
|
||||
.returning();
|
||||
|
||||
|
|
@ -589,13 +747,16 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
|
|||
goalId: input.routine.goalId,
|
||||
parentId: input.routine.parentIssueId,
|
||||
title: input.routine.title,
|
||||
description: input.routine.description,
|
||||
description,
|
||||
status: "todo",
|
||||
priority: input.routine.priority,
|
||||
assigneeAgentId: input.routine.assigneeAgentId,
|
||||
originKind: "routine_execution",
|
||||
originId: input.routine.id,
|
||||
originRunId: createdRun.id,
|
||||
executionWorkspaceId: input.executionWorkspaceId ?? null,
|
||||
executionWorkspacePreference: input.executionWorkspacePreference ?? null,
|
||||
executionWorkspaceSettings: input.executionWorkspaceSettings ?? null,
|
||||
});
|
||||
} catch (error) {
|
||||
const isOpenExecutionConflict =
|
||||
|
|
@ -824,6 +985,11 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
|
|||
await assertAssignableAgent(companyId, input.assigneeAgentId);
|
||||
if (input.goalId) await assertGoal(companyId, input.goalId);
|
||||
if (input.parentIssueId) await assertParentIssue(companyId, input.parentIssueId);
|
||||
const variables = syncRoutineVariablesWithTemplate(
|
||||
input.description,
|
||||
sanitizeRoutineVariableInputs(input.variables),
|
||||
);
|
||||
assertRoutineVariableDefinitions(variables);
|
||||
const [created] = await db
|
||||
.insert(routines)
|
||||
.values({
|
||||
|
|
@ -838,6 +1004,7 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
|
|||
status: input.status,
|
||||
concurrencyPolicy: input.concurrencyPolicy,
|
||||
catchUpPolicy: input.catchUpPolicy,
|
||||
variables,
|
||||
createdByAgentId: actor.agentId ?? null,
|
||||
createdByUserId: actor.userId ?? null,
|
||||
updatedByAgentId: actor.agentId ?? null,
|
||||
|
|
@ -852,10 +1019,31 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
|
|||
if (!existing) return null;
|
||||
const nextProjectId = patch.projectId ?? existing.projectId;
|
||||
const nextAssigneeAgentId = patch.assigneeAgentId ?? existing.assigneeAgentId;
|
||||
const nextDescription = patch.description === undefined ? existing.description : patch.description;
|
||||
const nextVariables = syncRoutineVariablesWithTemplate(
|
||||
nextDescription,
|
||||
patch.variables === undefined ? existing.variables : sanitizeRoutineVariableInputs(patch.variables),
|
||||
);
|
||||
if (patch.projectId) await assertProject(existing.companyId, nextProjectId);
|
||||
if (patch.assigneeAgentId) await assertAssignableAgent(existing.companyId, nextAssigneeAgentId);
|
||||
if (patch.goalId) await assertGoal(existing.companyId, patch.goalId);
|
||||
if (patch.parentIssueId) await assertParentIssue(existing.companyId, patch.parentIssueId);
|
||||
assertRoutineVariableDefinitions(nextVariables);
|
||||
const enabledScheduleTriggers = await db
|
||||
.select({ id: routineTriggers.id })
|
||||
.from(routineTriggers)
|
||||
.where(
|
||||
and(
|
||||
eq(routineTriggers.routineId, existing.id),
|
||||
eq(routineTriggers.kind, "schedule"),
|
||||
eq(routineTriggers.enabled, true),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
.then((rows) => rows.length > 0);
|
||||
if (enabledScheduleTriggers) {
|
||||
assertScheduleCompatibleVariables(nextVariables);
|
||||
}
|
||||
const [updated] = await db
|
||||
.update(routines)
|
||||
.set({
|
||||
|
|
@ -863,12 +1051,13 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
|
|||
goalId: patch.goalId === undefined ? existing.goalId : patch.goalId,
|
||||
parentIssueId: patch.parentIssueId === undefined ? existing.parentIssueId : patch.parentIssueId,
|
||||
title: patch.title ?? existing.title,
|
||||
description: patch.description === undefined ? existing.description : patch.description,
|
||||
description: nextDescription,
|
||||
assigneeAgentId: nextAssigneeAgentId,
|
||||
priority: patch.priority ?? existing.priority,
|
||||
status: patch.status ?? existing.status,
|
||||
concurrencyPolicy: patch.concurrencyPolicy ?? existing.concurrencyPolicy,
|
||||
catchUpPolicy: patch.catchUpPolicy ?? existing.catchUpPolicy,
|
||||
variables: nextVariables,
|
||||
updatedByAgentId: actor.agentId ?? null,
|
||||
updatedByUserId: actor.userId ?? null,
|
||||
updatedAt: new Date(),
|
||||
|
|
@ -892,6 +1081,7 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
|
|||
let nextRunAt: Date | null = null;
|
||||
|
||||
if (input.kind === "schedule") {
|
||||
assertScheduleCompatibleVariables(routine.variables ?? []);
|
||||
const timeZone = input.timezone || "UTC";
|
||||
assertTimeZone(timeZone);
|
||||
const error = validateCron(input.cronExpression);
|
||||
|
|
@ -947,6 +1137,8 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
|
|||
let timezone = existing.timezone;
|
||||
|
||||
if (existing.kind === "schedule") {
|
||||
const routine = await getRoutineById(existing.routineId);
|
||||
if (!routine) throw notFound("Routine not found");
|
||||
if (patch.cronExpression !== undefined) {
|
||||
if (patch.cronExpression == null) throw unprocessable("Scheduled triggers require cronExpression");
|
||||
const error = validateCron(patch.cronExpression);
|
||||
|
|
@ -961,6 +1153,9 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
|
|||
if (cronExpression && timezone) {
|
||||
nextRunAt = nextCronTickInTimeZone(cronExpression, timezone, new Date());
|
||||
}
|
||||
if ((patch.enabled ?? existing.enabled) === true) {
|
||||
assertScheduleCompatibleVariables(routine.variables ?? []);
|
||||
}
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
|
|
@ -1034,7 +1229,12 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
|
|||
trigger,
|
||||
source: input.source,
|
||||
payload: input.payload as Record<string, unknown> | null | undefined,
|
||||
variables: input.variables as Record<string, unknown> | null | undefined,
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
executionWorkspaceId: input.executionWorkspaceId ?? null,
|
||||
executionWorkspacePreference: input.executionWorkspacePreference ?? null,
|
||||
executionWorkspaceSettings:
|
||||
(input.executionWorkspaceSettings as Record<string, unknown> | null | undefined) ?? null,
|
||||
});
|
||||
},
|
||||
|
||||
|
|
@ -1097,6 +1297,9 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
|
|||
trigger,
|
||||
source: "webhook",
|
||||
payload: input.payload,
|
||||
variables: isPlainRecord(input.payload) && isPlainRecord(input.payload.variables)
|
||||
? input.payload.variables
|
||||
: null,
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
});
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue