mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
Add draft routine defaults and run-time overrides
This commit is contained in:
parent
b4a58ba8a6
commit
5d021583be
18 changed files with 592 additions and 113 deletions
|
|
@ -329,6 +329,53 @@ describeEmbeddedPostgres("routine routes end-to-end", () => {
|
|||
expect(issue?.description).toBe("Review paperclip for high bugs");
|
||||
});
|
||||
|
||||
it("allows drafting a routine without defaults and running it with one-off overrides", 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({
|
||||
title: "Draft routine",
|
||||
description: "No saved defaults",
|
||||
});
|
||||
|
||||
expect(createRes.status).toBe(201);
|
||||
expect(createRes.body.projectId).toBeNull();
|
||||
expect(createRes.body.assigneeAgentId).toBeNull();
|
||||
expect(createRes.body.status).toBe("paused");
|
||||
|
||||
const runRes = await request(app)
|
||||
.post(`/api/routines/${createRes.body.id}/run`)
|
||||
.send({
|
||||
source: "manual",
|
||||
projectId,
|
||||
assigneeAgentId: agentId,
|
||||
});
|
||||
|
||||
expect(runRes.status).toBe(202);
|
||||
expect(runRes.body.status).toBe("issue_created");
|
||||
|
||||
const [issue] = await db
|
||||
.select({
|
||||
projectId: issues.projectId,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
})
|
||||
.from(issues)
|
||||
.where(eq(issues.id, runRes.body.linkedIssueId));
|
||||
|
||||
expect(issue).toEqual({
|
||||
projectId,
|
||||
assigneeAgentId: agentId,
|
||||
});
|
||||
});
|
||||
|
||||
it("persists execution workspace selections from manual routine runs", async () => {
|
||||
const { companyId, agentId, projectId, userId } = await seedFixture();
|
||||
const projectWorkspaceId = randomUUID();
|
||||
|
|
|
|||
|
|
@ -221,6 +221,31 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
|
|||
expect(routineIssues.map((issue) => issue.id)).toContain(run.linkedIssueId);
|
||||
});
|
||||
|
||||
it("creates draft routines without a project or default assignee", async () => {
|
||||
const { companyId, svc } = await seedFixture();
|
||||
|
||||
const routine = await svc.create(
|
||||
companyId,
|
||||
{
|
||||
projectId: null,
|
||||
goalId: null,
|
||||
parentIssueId: null,
|
||||
title: "draft routine",
|
||||
description: "No defaults yet",
|
||||
assigneeAgentId: null,
|
||||
priority: "medium",
|
||||
status: "active",
|
||||
concurrencyPolicy: "coalesce_if_active",
|
||||
catchUpPolicy: "skip_missed",
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
expect(routine.projectId).toBeNull();
|
||||
expect(routine.assigneeAgentId).toBeNull();
|
||||
expect(routine.status).toBe("paused");
|
||||
});
|
||||
|
||||
it("wakes the assignee when a routine creates a fresh execution issue", async () => {
|
||||
const { agentId, routine, svc, wakeups } = await seedFixture();
|
||||
|
||||
|
|
@ -436,6 +461,73 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("runs draft routines with one-off agent and project overrides", async () => {
|
||||
const { companyId, agentId, projectId, svc } = await seedFixture();
|
||||
const draftRoutine = await svc.create(
|
||||
companyId,
|
||||
{
|
||||
projectId: null,
|
||||
goalId: null,
|
||||
parentIssueId: null,
|
||||
title: "draft dispatch",
|
||||
description: "Pick defaults at run time",
|
||||
assigneeAgentId: null,
|
||||
priority: "medium",
|
||||
status: "paused",
|
||||
concurrencyPolicy: "coalesce_if_active",
|
||||
catchUpPolicy: "skip_missed",
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
const run = await svc.runRoutine(draftRoutine.id, {
|
||||
source: "manual",
|
||||
projectId,
|
||||
assigneeAgentId: agentId,
|
||||
});
|
||||
|
||||
expect(run.status).toBe("issue_created");
|
||||
expect(run.linkedIssueId).toBeTruthy();
|
||||
|
||||
const storedIssue = await db
|
||||
.select({
|
||||
projectId: issues.projectId,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
})
|
||||
.from(issues)
|
||||
.where(eq(issues.id, run.linkedIssueId!))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
expect(storedIssue).toEqual({
|
||||
projectId,
|
||||
assigneeAgentId: agentId,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects enabling automation for routines without a default agent", async () => {
|
||||
const { companyId, svc } = await seedFixture();
|
||||
const draftRoutine = await svc.create(
|
||||
companyId,
|
||||
{
|
||||
projectId: null,
|
||||
goalId: null,
|
||||
parentIssueId: null,
|
||||
title: "draft routine",
|
||||
description: null,
|
||||
assigneeAgentId: null,
|
||||
priority: "medium",
|
||||
status: "paused",
|
||||
concurrencyPolicy: "coalesce_if_active",
|
||||
catchUpPolicy: "skip_missed",
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
await expect(
|
||||
svc.update(draftRoutine.id, { status: "active" }, {}),
|
||||
).rejects.toThrow(/default agent required/i);
|
||||
});
|
||||
|
||||
it("blocks schedule triggers when required variables do not have defaults", async () => {
|
||||
const { companyId, agentId, projectId, svc } = await seedFixture();
|
||||
const variableRoutine = await svc.create(
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ export function routineRoutes(db: Db) {
|
|||
assertCompanyAccess(req, companyId);
|
||||
if (req.actor.type === "board") return;
|
||||
if (req.actor.type !== "agent" || !req.actor.agentId) throw unauthorized();
|
||||
if (assigneeAgentId && assigneeAgentId !== req.actor.agentId) {
|
||||
if (assigneeAgentId !== req.actor.agentId) {
|
||||
throw forbidden("Agents can only manage routines assigned to themselves");
|
||||
}
|
||||
}
|
||||
|
|
@ -114,7 +114,11 @@ export function routineRoutes(db: Db) {
|
|||
if (statusWillActivate) {
|
||||
await assertBoardCanAssignTasks(req, routine.companyId);
|
||||
}
|
||||
if (req.actor.type === "agent" && req.body.assigneeAgentId && req.body.assigneeAgentId !== req.actor.agentId) {
|
||||
if (
|
||||
req.actor.type === "agent" &&
|
||||
req.body.assigneeAgentId !== undefined &&
|
||||
req.body.assigneeAgentId !== req.actor.agentId
|
||||
) {
|
||||
throw forbidden("Agents can only assign routines to themselves");
|
||||
}
|
||||
const updated = await svc.update(routine.id, req.body, {
|
||||
|
|
|
|||
|
|
@ -3310,9 +3310,9 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
|||
|
||||
for (const routine of selectedRoutineRows) {
|
||||
const taskSlug = taskSlugByRoutineId.get(routine.id)!;
|
||||
const projectSlug = projectSlugById.get(routine.projectId) ?? null;
|
||||
const projectSlug = routine.projectId ? (projectSlugById.get(routine.projectId) ?? null) : null;
|
||||
const taskPath = `tasks/${taskSlug}/TASK.md`;
|
||||
const assigneeSlug = idToSlug.get(routine.assigneeAgentId) ?? null;
|
||||
const assigneeSlug = routine.assigneeAgentId ? (idToSlug.get(routine.assigneeAgentId) ?? null) : null;
|
||||
files[taskPath] = buildMarkdown(
|
||||
{
|
||||
name: routine.title,
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import type {
|
|||
UpdateRoutineTrigger,
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
getBuiltinRoutineVariableValues,
|
||||
interpolateRoutineTemplate,
|
||||
stringifyRoutineVariableValue,
|
||||
syncRoutineVariablesWithTemplate,
|
||||
|
|
@ -230,6 +231,23 @@ function assertScheduleCompatibleVariables(variables: RoutineVariable[]) {
|
|||
}
|
||||
}
|
||||
|
||||
function statusRequiresDefaultAgent(status: string) {
|
||||
return status === "active";
|
||||
}
|
||||
|
||||
function normalizeDraftRoutineStatus(status: string, assigneeAgentId: string | null | undefined) {
|
||||
if (statusRequiresDefaultAgent(status) && !assigneeAgentId) {
|
||||
return "paused";
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
function assertRoutineCanEnable(status: string, assigneeAgentId: string | null | undefined) {
|
||||
if (statusRequiresDefaultAgent(status) && !assigneeAgentId) {
|
||||
throw unprocessable("Default agent required");
|
||||
}
|
||||
}
|
||||
|
||||
function collectProvidedRoutineVariables(
|
||||
source: "schedule" | "manual" | "api" | "webhook",
|
||||
payload: Record<string, unknown> | null | undefined,
|
||||
|
|
@ -319,7 +337,8 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
|
|||
return routine;
|
||||
}
|
||||
|
||||
async function assertAssignableAgent(companyId: string, agentId: string) {
|
||||
async function assertAssignableAgent(companyId: string, agentId: string | null | undefined) {
|
||||
if (!agentId) return;
|
||||
const agent = await db
|
||||
.select({ id: agents.id, companyId: agents.companyId, status: agents.status })
|
||||
.from(agents)
|
||||
|
|
@ -331,7 +350,8 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
|
|||
if (agent.status === "terminated") throw conflict("Cannot assign routines to terminated agents");
|
||||
}
|
||||
|
||||
async function assertProject(companyId: string, projectId: string) {
|
||||
async function assertProject(companyId: string, projectId: string | null | undefined) {
|
||||
if (!projectId) return;
|
||||
const project = await db
|
||||
.select({ id: projects.id, companyId: projects.companyId })
|
||||
.from(projects)
|
||||
|
|
@ -669,14 +689,22 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
|
|||
source: "schedule" | "manual" | "api" | "webhook";
|
||||
payload?: Record<string, unknown> | null;
|
||||
variables?: Record<string, unknown> | null;
|
||||
projectId?: string | null;
|
||||
assigneeAgentId?: string | null;
|
||||
idempotencyKey?: string | null;
|
||||
executionWorkspaceId?: string | null;
|
||||
executionWorkspacePreference?: string | null;
|
||||
executionWorkspaceSettings?: Record<string, unknown> | null;
|
||||
}) {
|
||||
const projectId = input.projectId ?? input.routine.projectId ?? null;
|
||||
const assigneeAgentId = input.assigneeAgentId ?? input.routine.assigneeAgentId ?? null;
|
||||
if (!assigneeAgentId) {
|
||||
throw unprocessable("Default agent required");
|
||||
}
|
||||
const resolvedVariables = resolveRoutineVariableValues(input.routine.variables ?? [], input);
|
||||
const title = interpolateRoutineTemplate(input.routine.title, resolvedVariables) ?? input.routine.title;
|
||||
const description = interpolateRoutineTemplate(input.routine.description, resolvedVariables);
|
||||
const allVariables = { ...getBuiltinRoutineVariableValues(), ...resolvedVariables };
|
||||
const title = interpolateRoutineTemplate(input.routine.title, allVariables) ?? input.routine.title;
|
||||
const description = interpolateRoutineTemplate(input.routine.description, allVariables);
|
||||
const triggerPayload = mergeRoutineRunPayload(input.payload, resolvedVariables);
|
||||
const run = await db.transaction(async (tx) => {
|
||||
const txDb = tx as unknown as Db;
|
||||
|
|
@ -746,14 +774,14 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
|
|||
|
||||
try {
|
||||
createdIssue = await issueSvc.create(input.routine.companyId, {
|
||||
projectId: input.routine.projectId,
|
||||
projectId,
|
||||
goalId: input.routine.goalId,
|
||||
parentId: input.routine.parentIssueId,
|
||||
title,
|
||||
description,
|
||||
status: "todo",
|
||||
priority: input.routine.priority,
|
||||
assigneeAgentId: input.routine.assigneeAgentId,
|
||||
assigneeAgentId,
|
||||
originKind: "routine_execution",
|
||||
originId: input.routine.id,
|
||||
originRunId: createdRun.id,
|
||||
|
|
@ -906,8 +934,12 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
|
|||
const row = await getRoutineById(id);
|
||||
if (!row) return null;
|
||||
const [project, assignee, parentIssue, triggers, recentRuns, activeIssue] = await Promise.all([
|
||||
db.select().from(projects).where(eq(projects.id, row.projectId)).then((rows) => rows[0] ?? null),
|
||||
db.select().from(agents).where(eq(agents.id, row.assigneeAgentId)).then((rows) => rows[0] ?? null),
|
||||
row.projectId
|
||||
? db.select().from(projects).where(eq(projects.id, row.projectId)).then((rows) => rows[0] ?? null)
|
||||
: null,
|
||||
row.assigneeAgentId
|
||||
? db.select().from(agents).where(eq(agents.id, row.assigneeAgentId)).then((rows) => rows[0] ?? null)
|
||||
: null,
|
||||
row.parentIssueId ? issueSvc.getById(row.parentIssueId) : null,
|
||||
db.select().from(routineTriggers).where(eq(routineTriggers.routineId, row.id)).orderBy(asc(routineTriggers.createdAt)),
|
||||
db
|
||||
|
|
@ -992,8 +1024,8 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
|
|||
},
|
||||
|
||||
create: async (companyId: string, input: CreateRoutine, actor: Actor): Promise<Routine> => {
|
||||
await assertProject(companyId, input.projectId);
|
||||
await assertAssignableAgent(companyId, input.assigneeAgentId);
|
||||
await assertProject(companyId, input.projectId ?? null);
|
||||
await assertAssignableAgent(companyId, input.assigneeAgentId ?? null);
|
||||
if (input.goalId) await assertGoal(companyId, input.goalId);
|
||||
if (input.parentIssueId) await assertParentIssue(companyId, input.parentIssueId);
|
||||
const variables = syncRoutineVariablesWithTemplate(
|
||||
|
|
@ -1001,18 +1033,19 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
|
|||
sanitizeRoutineVariableInputs(input.variables),
|
||||
);
|
||||
assertRoutineVariableDefinitions(variables);
|
||||
const status = normalizeDraftRoutineStatus(input.status, input.assigneeAgentId);
|
||||
const [created] = await db
|
||||
.insert(routines)
|
||||
.values({
|
||||
companyId,
|
||||
projectId: input.projectId,
|
||||
projectId: input.projectId ?? null,
|
||||
goalId: input.goalId ?? null,
|
||||
parentIssueId: input.parentIssueId ?? null,
|
||||
title: input.title,
|
||||
description: input.description ?? null,
|
||||
assigneeAgentId: input.assigneeAgentId,
|
||||
assigneeAgentId: input.assigneeAgentId ?? null,
|
||||
priority: input.priority,
|
||||
status: input.status,
|
||||
status,
|
||||
concurrencyPolicy: input.concurrencyPolicy,
|
||||
catchUpPolicy: input.catchUpPolicy,
|
||||
variables,
|
||||
|
|
@ -1028,16 +1061,23 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
|
|||
update: async (id: string, patch: UpdateRoutine, actor: Actor): Promise<Routine | null> => {
|
||||
const existing = await getRoutineById(id);
|
||||
if (!existing) return null;
|
||||
const nextProjectId = patch.projectId ?? existing.projectId;
|
||||
const nextAssigneeAgentId = patch.assigneeAgentId ?? existing.assigneeAgentId;
|
||||
const nextProjectId = patch.projectId === undefined ? existing.projectId : patch.projectId;
|
||||
const nextAssigneeAgentId = patch.assigneeAgentId === undefined ? existing.assigneeAgentId : patch.assigneeAgentId;
|
||||
const nextTitle = patch.title ?? existing.title;
|
||||
const nextDescription = patch.description === undefined ? existing.description : patch.description;
|
||||
const requestedStatus = patch.status ?? existing.status;
|
||||
if (patch.status === "active") {
|
||||
assertRoutineCanEnable(patch.status, nextAssigneeAgentId);
|
||||
}
|
||||
const nextStatus = patch.assigneeAgentId === undefined
|
||||
? requestedStatus
|
||||
: normalizeDraftRoutineStatus(requestedStatus, nextAssigneeAgentId);
|
||||
const nextVariables = syncRoutineVariablesWithTemplate(
|
||||
[nextTitle, 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.projectId !== undefined) await assertProject(existing.companyId, nextProjectId);
|
||||
if (patch.assigneeAgentId !== undefined) 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);
|
||||
|
|
@ -1066,7 +1106,7 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
|
|||
description: nextDescription,
|
||||
assigneeAgentId: nextAssigneeAgentId,
|
||||
priority: patch.priority ?? existing.priority,
|
||||
status: patch.status ?? existing.status,
|
||||
status: nextStatus,
|
||||
concurrencyPolicy: patch.concurrencyPolicy ?? existing.concurrencyPolicy,
|
||||
catchUpPolicy: patch.catchUpPolicy ?? existing.catchUpPolicy,
|
||||
variables: nextVariables,
|
||||
|
|
@ -1233,6 +1273,8 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
|
|||
const routine = await getRoutineById(id);
|
||||
if (!routine) throw notFound("Routine not found");
|
||||
if (routine.status === "archived") throw conflict("Routine is archived");
|
||||
await assertProject(routine.companyId, input.projectId ?? null);
|
||||
await assertAssignableAgent(routine.companyId, input.assigneeAgentId ?? null);
|
||||
const trigger = input.triggerId ? await getTriggerById(input.triggerId) : null;
|
||||
if (trigger && trigger.routineId !== routine.id) throw forbidden("Trigger does not belong to routine");
|
||||
if (trigger && !trigger.enabled) throw conflict("Routine trigger is not active");
|
||||
|
|
@ -1242,6 +1284,8 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
|
|||
source: input.source,
|
||||
payload: input.payload as Record<string, unknown> | null | undefined,
|
||||
variables: input.variables as Record<string, unknown> | null | undefined,
|
||||
projectId: input.projectId ?? null,
|
||||
assigneeAgentId: input.assigneeAgentId ?? null,
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
executionWorkspaceId: input.executionWorkspaceId ?? null,
|
||||
executionWorkspacePreference: input.executionWorkspacePreference ?? null,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue