mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 19:00:38 +09:00
Merge pull request #3220 from paperclipai/pap-1266-routines
feat(routines): support draft routines and run-time overrides
This commit is contained in:
commit
6d63a4df45
19 changed files with 609 additions and 118 deletions
2
packages/db/src/migrations/0054_draft_routines.sql
Normal file
2
packages/db/src/migrations/0054_draft_routines.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
ALTER TABLE "routines" ALTER COLUMN "project_id" DROP NOT NULL;
|
||||||
|
ALTER TABLE "routines" ALTER COLUMN "assignee_agent_id" DROP NOT NULL;
|
||||||
|
|
@ -379,6 +379,13 @@
|
||||||
"when": 1775604018515,
|
"when": 1775604018515,
|
||||||
"tag": "0053_sharp_wild_child",
|
"tag": "0053_sharp_wild_child",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 54,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1775750400000,
|
||||||
|
"tag": "0054_draft_routines",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,12 +22,12 @@ export const routines = pgTable(
|
||||||
{
|
{
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||||
projectId: uuid("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
|
projectId: uuid("project_id").references(() => projects.id, { onDelete: "cascade" }),
|
||||||
goalId: uuid("goal_id").references(() => goals.id, { onDelete: "set null" }),
|
goalId: uuid("goal_id").references(() => goals.id, { onDelete: "set null" }),
|
||||||
parentIssueId: uuid("parent_issue_id").references(() => issues.id, { onDelete: "set null" }),
|
parentIssueId: uuid("parent_issue_id").references(() => issues.id, { onDelete: "set null" }),
|
||||||
title: text("title").notNull(),
|
title: text("title").notNull(),
|
||||||
description: text("description"),
|
description: text("description"),
|
||||||
assigneeAgentId: uuid("assignee_agent_id").notNull().references(() => agents.id),
|
assigneeAgentId: uuid("assignee_agent_id").references(() => agents.id),
|
||||||
priority: text("priority").notNull().default("medium"),
|
priority: text("priority").notNull().default("medium"),
|
||||||
status: text("status").notNull().default("active"),
|
status: text("status").notNull().default("active"),
|
||||||
concurrencyPolicy: text("concurrency_policy").notNull().default("coalesce_if_active"),
|
concurrencyPolicy: text("concurrency_policy").notNull().default("coalesce_if_active"),
|
||||||
|
|
|
||||||
|
|
@ -637,8 +637,11 @@ export {
|
||||||
} from "./project-mentions.js";
|
} from "./project-mentions.js";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
BUILTIN_ROUTINE_VARIABLE_NAMES,
|
||||||
extractRoutineVariableNames,
|
extractRoutineVariableNames,
|
||||||
|
getBuiltinRoutineVariableValues,
|
||||||
interpolateRoutineTemplate,
|
interpolateRoutineTemplate,
|
||||||
|
isBuiltinRoutineVariable,
|
||||||
isValidRoutineVariableName,
|
isValidRoutineVariableName,
|
||||||
stringifyRoutineVariableValue,
|
stringifyRoutineVariableValue,
|
||||||
syncRoutineVariablesWithTemplate,
|
syncRoutineVariablesWithTemplate,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
BUILTIN_ROUTINE_VARIABLE_NAMES,
|
||||||
extractRoutineVariableNames,
|
extractRoutineVariableNames,
|
||||||
|
getBuiltinRoutineVariableValues,
|
||||||
interpolateRoutineTemplate,
|
interpolateRoutineTemplate,
|
||||||
|
isBuiltinRoutineVariable,
|
||||||
syncRoutineVariablesWithTemplate,
|
syncRoutineVariablesWithTemplate,
|
||||||
} from "./routine-variables.js";
|
} from "./routine-variables.js";
|
||||||
|
|
||||||
|
|
@ -40,4 +43,34 @@ describe("routine variable helpers", () => {
|
||||||
}),
|
}),
|
||||||
).toBe("Review paperclip for high");
|
).toBe("Review paperclip for high");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("identifies built-in variable names", () => {
|
||||||
|
expect(isBuiltinRoutineVariable("date")).toBe(true);
|
||||||
|
expect(isBuiltinRoutineVariable("repo")).toBe(false);
|
||||||
|
expect(BUILTIN_ROUTINE_VARIABLE_NAMES.has("date")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getBuiltinRoutineVariableValues returns date in YYYY-MM-DD format", () => {
|
||||||
|
const values = getBuiltinRoutineVariableValues();
|
||||||
|
expect(values.date).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||||
|
expect(values.date).toBe(new Date().toISOString().slice(0, 10));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes built-in variables from syncRoutineVariablesWithTemplate", () => {
|
||||||
|
const result = syncRoutineVariablesWithTemplate(
|
||||||
|
"Daily report for {{date}} — {{repo}}",
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ name: "repo", label: null, type: "text", defaultValue: null, required: true, options: [] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("interpolates built-in date variable alongside user variables", () => {
|
||||||
|
const builtins = getBuiltinRoutineVariableValues();
|
||||||
|
const allVars = { ...builtins, repo: "paperclip" };
|
||||||
|
expect(
|
||||||
|
interpolateRoutineTemplate("Report for {{date}} on {{repo}}", allVars),
|
||||||
|
).toBe(`Report for ${builtins.date} on paperclip`);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,26 @@ import type { RoutineVariable } from "./types/routine.js";
|
||||||
const ROUTINE_VARIABLE_MATCHER = /\{\{\s*([A-Za-z][A-Za-z0-9_]*)\s*\}\}/g;
|
const ROUTINE_VARIABLE_MATCHER = /\{\{\s*([A-Za-z][A-Za-z0-9_]*)\s*\}\}/g;
|
||||||
type RoutineTemplateInput = string | null | undefined | Array<string | null | undefined>;
|
type RoutineTemplateInput = string | null | undefined | Array<string | null | undefined>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Built-in variable names that are automatically available in routine templates
|
||||||
|
* without needing to be defined in the routine's variables list.
|
||||||
|
*/
|
||||||
|
export const BUILTIN_ROUTINE_VARIABLE_NAMES = new Set(["date"]);
|
||||||
|
|
||||||
|
export function isBuiltinRoutineVariable(name: string): boolean {
|
||||||
|
return BUILTIN_ROUTINE_VARIABLE_NAMES.has(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns current values for all built-in routine variables.
|
||||||
|
* `date` expands to the current date in YYYY-MM-DD format (UTC).
|
||||||
|
*/
|
||||||
|
export function getBuiltinRoutineVariableValues(): Record<string, string> {
|
||||||
|
return {
|
||||||
|
date: new Date().toISOString().slice(0, 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function isValidRoutineVariableName(name: string): boolean {
|
export function isValidRoutineVariableName(name: string): boolean {
|
||||||
return /^[A-Za-z][A-Za-z0-9_]*$/.test(name);
|
return /^[A-Za-z][A-Za-z0-9_]*$/.test(name);
|
||||||
}
|
}
|
||||||
|
|
@ -40,7 +60,7 @@ export function syncRoutineVariablesWithTemplate(
|
||||||
template: RoutineTemplateInput,
|
template: RoutineTemplateInput,
|
||||||
existing: RoutineVariable[] | null | undefined,
|
existing: RoutineVariable[] | null | undefined,
|
||||||
): RoutineVariable[] {
|
): RoutineVariable[] {
|
||||||
const names = extractRoutineVariableNames(template);
|
const names = extractRoutineVariableNames(template).filter((name) => !isBuiltinRoutineVariable(name));
|
||||||
const existingByName = new Map((existing ?? []).map((variable) => [variable.name, variable]));
|
const existingByName = new Map((existing ?? []).map((variable) => [variable.name, variable]));
|
||||||
return names.map((name) => existingByName.get(name) ?? defaultRoutineVariable(name));
|
return names.map((name) => existingByName.get(name) ?? defaultRoutineVariable(name));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,12 +39,12 @@ export interface RoutineVariable {
|
||||||
export interface Routine {
|
export interface Routine {
|
||||||
id: string;
|
id: string;
|
||||||
companyId: string;
|
companyId: string;
|
||||||
projectId: string;
|
projectId: string | null;
|
||||||
goalId: string | null;
|
goalId: string | null;
|
||||||
parentIssueId: string | null;
|
parentIssueId: string | null;
|
||||||
title: string;
|
title: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
assigneeAgentId: string;
|
assigneeAgentId: string | null;
|
||||||
priority: string;
|
priority: string;
|
||||||
status: string;
|
status: string;
|
||||||
concurrencyPolicy: string;
|
concurrencyPolicy: string;
|
||||||
|
|
|
||||||
|
|
@ -48,12 +48,12 @@ export const routineVariableSchema = z.object({
|
||||||
});
|
});
|
||||||
|
|
||||||
export const createRoutineSchema = z.object({
|
export const createRoutineSchema = z.object({
|
||||||
projectId: z.string().uuid(),
|
projectId: z.string().uuid().optional().nullable(),
|
||||||
goalId: z.string().uuid().optional().nullable(),
|
goalId: z.string().uuid().optional().nullable(),
|
||||||
parentIssueId: z.string().uuid().optional().nullable(),
|
parentIssueId: z.string().uuid().optional().nullable(),
|
||||||
title: z.string().trim().min(1).max(200),
|
title: z.string().trim().min(1).max(200),
|
||||||
description: z.string().optional().nullable(),
|
description: z.string().optional().nullable(),
|
||||||
assigneeAgentId: z.string().uuid(),
|
assigneeAgentId: z.string().uuid().optional().nullable(),
|
||||||
priority: z.enum(ISSUE_PRIORITIES).optional().default("medium"),
|
priority: z.enum(ISSUE_PRIORITIES).optional().default("medium"),
|
||||||
status: z.enum(ROUTINE_STATUSES).optional().default("active"),
|
status: z.enum(ROUTINE_STATUSES).optional().default("active"),
|
||||||
concurrencyPolicy: z.enum(ROUTINE_CONCURRENCY_POLICIES).optional().default("coalesce_if_active"),
|
concurrencyPolicy: z.enum(ROUTINE_CONCURRENCY_POLICIES).optional().default("coalesce_if_active"),
|
||||||
|
|
@ -104,6 +104,8 @@ export const runRoutineSchema = z.object({
|
||||||
triggerId: z.string().uuid().optional().nullable(),
|
triggerId: z.string().uuid().optional().nullable(),
|
||||||
payload: z.record(z.unknown()).optional().nullable(),
|
payload: z.record(z.unknown()).optional().nullable(),
|
||||||
variables: z.record(routineVariableValueSchema).optional().nullable(),
|
variables: z.record(routineVariableValueSchema).optional().nullable(),
|
||||||
|
projectId: z.string().uuid().optional().nullable(),
|
||||||
|
assigneeAgentId: z.string().uuid().optional().nullable(),
|
||||||
idempotencyKey: z.string().trim().max(255).optional().nullable(),
|
idempotencyKey: z.string().trim().max(255).optional().nullable(),
|
||||||
source: z.enum(["manual", "api"]).optional().default("manual"),
|
source: z.enum(["manual", "api"]).optional().default("manual"),
|
||||||
executionWorkspaceId: z.string().uuid().optional().nullable(),
|
executionWorkspaceId: z.string().uuid().optional().nullable(),
|
||||||
|
|
|
||||||
|
|
@ -329,6 +329,53 @@ describeEmbeddedPostgres("routine routes end-to-end", () => {
|
||||||
expect(issue?.description).toBe("Review paperclip for high bugs");
|
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 () => {
|
it("persists execution workspace selections from manual routine runs", async () => {
|
||||||
const { companyId, agentId, projectId, userId } = await seedFixture();
|
const { companyId, agentId, projectId, userId } = await seedFixture();
|
||||||
const projectWorkspaceId = randomUUID();
|
const projectWorkspaceId = randomUUID();
|
||||||
|
|
|
||||||
|
|
@ -221,6 +221,31 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
|
||||||
expect(routineIssues.map((issue) => issue.id)).toContain(run.linkedIssueId);
|
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 () => {
|
it("wakes the assignee when a routine creates a fresh execution issue", async () => {
|
||||||
const { agentId, routine, svc, wakeups } = await seedFixture();
|
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 () => {
|
it("blocks schedule triggers when required variables do not have defaults", async () => {
|
||||||
const { companyId, agentId, projectId, svc } = await seedFixture();
|
const { companyId, agentId, projectId, svc } = await seedFixture();
|
||||||
const variableRoutine = await svc.create(
|
const variableRoutine = await svc.create(
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ export function routineRoutes(db: Db) {
|
||||||
assertCompanyAccess(req, companyId);
|
assertCompanyAccess(req, companyId);
|
||||||
if (req.actor.type === "board") return;
|
if (req.actor.type === "board") return;
|
||||||
if (req.actor.type !== "agent" || !req.actor.agentId) throw unauthorized();
|
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");
|
throw forbidden("Agents can only manage routines assigned to themselves");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -114,7 +114,11 @@ export function routineRoutes(db: Db) {
|
||||||
if (statusWillActivate) {
|
if (statusWillActivate) {
|
||||||
await assertBoardCanAssignTasks(req, routine.companyId);
|
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");
|
throw forbidden("Agents can only assign routines to themselves");
|
||||||
}
|
}
|
||||||
const updated = await svc.update(routine.id, req.body, {
|
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) {
|
for (const routine of selectedRoutineRows) {
|
||||||
const taskSlug = taskSlugByRoutineId.get(routine.id)!;
|
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 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(
|
files[taskPath] = buildMarkdown(
|
||||||
{
|
{
|
||||||
name: routine.title,
|
name: routine.title,
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import type {
|
||||||
UpdateRoutineTrigger,
|
UpdateRoutineTrigger,
|
||||||
} from "@paperclipai/shared";
|
} from "@paperclipai/shared";
|
||||||
import {
|
import {
|
||||||
|
getBuiltinRoutineVariableValues,
|
||||||
interpolateRoutineTemplate,
|
interpolateRoutineTemplate,
|
||||||
stringifyRoutineVariableValue,
|
stringifyRoutineVariableValue,
|
||||||
syncRoutineVariablesWithTemplate,
|
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(
|
function collectProvidedRoutineVariables(
|
||||||
source: "schedule" | "manual" | "api" | "webhook",
|
source: "schedule" | "manual" | "api" | "webhook",
|
||||||
payload: Record<string, unknown> | null | undefined,
|
payload: Record<string, unknown> | null | undefined,
|
||||||
|
|
@ -319,7 +337,8 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
|
||||||
return routine;
|
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
|
const agent = await db
|
||||||
.select({ id: agents.id, companyId: agents.companyId, status: agents.status })
|
.select({ id: agents.id, companyId: agents.companyId, status: agents.status })
|
||||||
.from(agents)
|
.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");
|
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
|
const project = await db
|
||||||
.select({ id: projects.id, companyId: projects.companyId })
|
.select({ id: projects.id, companyId: projects.companyId })
|
||||||
.from(projects)
|
.from(projects)
|
||||||
|
|
@ -669,14 +689,22 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
|
||||||
source: "schedule" | "manual" | "api" | "webhook";
|
source: "schedule" | "manual" | "api" | "webhook";
|
||||||
payload?: Record<string, unknown> | null;
|
payload?: Record<string, unknown> | null;
|
||||||
variables?: Record<string, unknown> | null;
|
variables?: Record<string, unknown> | null;
|
||||||
|
projectId?: string | null;
|
||||||
|
assigneeAgentId?: string | null;
|
||||||
idempotencyKey?: string | null;
|
idempotencyKey?: string | null;
|
||||||
executionWorkspaceId?: string | null;
|
executionWorkspaceId?: string | null;
|
||||||
executionWorkspacePreference?: string | null;
|
executionWorkspacePreference?: string | null;
|
||||||
executionWorkspaceSettings?: Record<string, unknown> | 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 resolvedVariables = resolveRoutineVariableValues(input.routine.variables ?? [], input);
|
||||||
const title = interpolateRoutineTemplate(input.routine.title, resolvedVariables) ?? input.routine.title;
|
const allVariables = { ...getBuiltinRoutineVariableValues(), ...resolvedVariables };
|
||||||
const description = interpolateRoutineTemplate(input.routine.description, 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 triggerPayload = mergeRoutineRunPayload(input.payload, resolvedVariables);
|
||||||
const run = await db.transaction(async (tx) => {
|
const run = await db.transaction(async (tx) => {
|
||||||
const txDb = tx as unknown as Db;
|
const txDb = tx as unknown as Db;
|
||||||
|
|
@ -746,14 +774,14 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
|
||||||
|
|
||||||
try {
|
try {
|
||||||
createdIssue = await issueSvc.create(input.routine.companyId, {
|
createdIssue = await issueSvc.create(input.routine.companyId, {
|
||||||
projectId: input.routine.projectId,
|
projectId,
|
||||||
goalId: input.routine.goalId,
|
goalId: input.routine.goalId,
|
||||||
parentId: input.routine.parentIssueId,
|
parentId: input.routine.parentIssueId,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
status: "todo",
|
status: "todo",
|
||||||
priority: input.routine.priority,
|
priority: input.routine.priority,
|
||||||
assigneeAgentId: input.routine.assigneeAgentId,
|
assigneeAgentId,
|
||||||
originKind: "routine_execution",
|
originKind: "routine_execution",
|
||||||
originId: input.routine.id,
|
originId: input.routine.id,
|
||||||
originRunId: createdRun.id,
|
originRunId: createdRun.id,
|
||||||
|
|
@ -906,8 +934,12 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
|
||||||
const row = await getRoutineById(id);
|
const row = await getRoutineById(id);
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
const [project, assignee, parentIssue, triggers, recentRuns, activeIssue] = await Promise.all([
|
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),
|
row.projectId
|
||||||
db.select().from(agents).where(eq(agents.id, row.assigneeAgentId)).then((rows) => rows[0] ?? null),
|
? 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,
|
row.parentIssueId ? issueSvc.getById(row.parentIssueId) : null,
|
||||||
db.select().from(routineTriggers).where(eq(routineTriggers.routineId, row.id)).orderBy(asc(routineTriggers.createdAt)),
|
db.select().from(routineTriggers).where(eq(routineTriggers.routineId, row.id)).orderBy(asc(routineTriggers.createdAt)),
|
||||||
db
|
db
|
||||||
|
|
@ -992,8 +1024,8 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
|
||||||
},
|
},
|
||||||
|
|
||||||
create: async (companyId: string, input: CreateRoutine, actor: Actor): Promise<Routine> => {
|
create: async (companyId: string, input: CreateRoutine, actor: Actor): Promise<Routine> => {
|
||||||
await assertProject(companyId, input.projectId);
|
await assertProject(companyId, input.projectId ?? null);
|
||||||
await assertAssignableAgent(companyId, input.assigneeAgentId);
|
await assertAssignableAgent(companyId, input.assigneeAgentId ?? null);
|
||||||
if (input.goalId) await assertGoal(companyId, input.goalId);
|
if (input.goalId) await assertGoal(companyId, input.goalId);
|
||||||
if (input.parentIssueId) await assertParentIssue(companyId, input.parentIssueId);
|
if (input.parentIssueId) await assertParentIssue(companyId, input.parentIssueId);
|
||||||
const variables = syncRoutineVariablesWithTemplate(
|
const variables = syncRoutineVariablesWithTemplate(
|
||||||
|
|
@ -1001,18 +1033,19 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
|
||||||
sanitizeRoutineVariableInputs(input.variables),
|
sanitizeRoutineVariableInputs(input.variables),
|
||||||
);
|
);
|
||||||
assertRoutineVariableDefinitions(variables);
|
assertRoutineVariableDefinitions(variables);
|
||||||
|
const status = normalizeDraftRoutineStatus(input.status, input.assigneeAgentId);
|
||||||
const [created] = await db
|
const [created] = await db
|
||||||
.insert(routines)
|
.insert(routines)
|
||||||
.values({
|
.values({
|
||||||
companyId,
|
companyId,
|
||||||
projectId: input.projectId,
|
projectId: input.projectId ?? null,
|
||||||
goalId: input.goalId ?? null,
|
goalId: input.goalId ?? null,
|
||||||
parentIssueId: input.parentIssueId ?? null,
|
parentIssueId: input.parentIssueId ?? null,
|
||||||
title: input.title,
|
title: input.title,
|
||||||
description: input.description ?? null,
|
description: input.description ?? null,
|
||||||
assigneeAgentId: input.assigneeAgentId,
|
assigneeAgentId: input.assigneeAgentId ?? null,
|
||||||
priority: input.priority,
|
priority: input.priority,
|
||||||
status: input.status,
|
status,
|
||||||
concurrencyPolicy: input.concurrencyPolicy,
|
concurrencyPolicy: input.concurrencyPolicy,
|
||||||
catchUpPolicy: input.catchUpPolicy,
|
catchUpPolicy: input.catchUpPolicy,
|
||||||
variables,
|
variables,
|
||||||
|
|
@ -1028,16 +1061,23 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
|
||||||
update: async (id: string, patch: UpdateRoutine, actor: Actor): Promise<Routine | null> => {
|
update: async (id: string, patch: UpdateRoutine, actor: Actor): Promise<Routine | null> => {
|
||||||
const existing = await getRoutineById(id);
|
const existing = await getRoutineById(id);
|
||||||
if (!existing) return null;
|
if (!existing) return null;
|
||||||
const nextProjectId = patch.projectId ?? existing.projectId;
|
const nextProjectId = patch.projectId === undefined ? existing.projectId : patch.projectId;
|
||||||
const nextAssigneeAgentId = patch.assigneeAgentId ?? existing.assigneeAgentId;
|
const nextAssigneeAgentId = patch.assigneeAgentId === undefined ? existing.assigneeAgentId : patch.assigneeAgentId;
|
||||||
const nextTitle = patch.title ?? existing.title;
|
const nextTitle = patch.title ?? existing.title;
|
||||||
const nextDescription = patch.description === undefined ? existing.description : patch.description;
|
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(
|
const nextVariables = syncRoutineVariablesWithTemplate(
|
||||||
[nextTitle, nextDescription],
|
[nextTitle, nextDescription],
|
||||||
patch.variables === undefined ? existing.variables : sanitizeRoutineVariableInputs(patch.variables),
|
patch.variables === undefined ? existing.variables : sanitizeRoutineVariableInputs(patch.variables),
|
||||||
);
|
);
|
||||||
if (patch.projectId) await assertProject(existing.companyId, nextProjectId);
|
if (patch.projectId !== undefined) await assertProject(existing.companyId, nextProjectId);
|
||||||
if (patch.assigneeAgentId) await assertAssignableAgent(existing.companyId, nextAssigneeAgentId);
|
if (patch.assigneeAgentId !== undefined) await assertAssignableAgent(existing.companyId, nextAssigneeAgentId);
|
||||||
if (patch.goalId) await assertGoal(existing.companyId, patch.goalId);
|
if (patch.goalId) await assertGoal(existing.companyId, patch.goalId);
|
||||||
if (patch.parentIssueId) await assertParentIssue(existing.companyId, patch.parentIssueId);
|
if (patch.parentIssueId) await assertParentIssue(existing.companyId, patch.parentIssueId);
|
||||||
assertRoutineVariableDefinitions(nextVariables);
|
assertRoutineVariableDefinitions(nextVariables);
|
||||||
|
|
@ -1066,7 +1106,7 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
|
||||||
description: nextDescription,
|
description: nextDescription,
|
||||||
assigneeAgentId: nextAssigneeAgentId,
|
assigneeAgentId: nextAssigneeAgentId,
|
||||||
priority: patch.priority ?? existing.priority,
|
priority: patch.priority ?? existing.priority,
|
||||||
status: patch.status ?? existing.status,
|
status: nextStatus,
|
||||||
concurrencyPolicy: patch.concurrencyPolicy ?? existing.concurrencyPolicy,
|
concurrencyPolicy: patch.concurrencyPolicy ?? existing.concurrencyPolicy,
|
||||||
catchUpPolicy: patch.catchUpPolicy ?? existing.catchUpPolicy,
|
catchUpPolicy: patch.catchUpPolicy ?? existing.catchUpPolicy,
|
||||||
variables: nextVariables,
|
variables: nextVariables,
|
||||||
|
|
@ -1233,6 +1273,8 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
|
||||||
const routine = await getRoutineById(id);
|
const routine = await getRoutineById(id);
|
||||||
if (!routine) throw notFound("Routine not found");
|
if (!routine) throw notFound("Routine not found");
|
||||||
if (routine.status === "archived") throw conflict("Routine is archived");
|
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;
|
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.routineId !== routine.id) throw forbidden("Trigger does not belong to routine");
|
||||||
if (trigger && !trigger.enabled) throw conflict("Routine trigger is not active");
|
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,
|
source: input.source,
|
||||||
payload: input.payload as Record<string, unknown> | null | undefined,
|
payload: input.payload as Record<string, unknown> | null | undefined,
|
||||||
variables: input.variables 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,
|
idempotencyKey: input.idempotencyKey,
|
||||||
executionWorkspaceId: input.executionWorkspaceId ?? null,
|
executionWorkspaceId: input.executionWorkspaceId ?? null,
|
||||||
executionWorkspacePreference: input.executionWorkspacePreference ?? null,
|
executionWorkspacePreference: input.executionWorkspacePreference ?? null,
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ interface InlineEntitySelectorProps {
|
||||||
renderOption?: (option: InlineEntityOption, isSelected: boolean) => ReactNode;
|
renderOption?: (option: InlineEntityOption, isSelected: boolean) => ReactNode;
|
||||||
/** Skip the Portal so the popover stays in the DOM tree (fixes scroll inside Dialogs). */
|
/** Skip the Portal so the popover stays in the DOM tree (fixes scroll inside Dialogs). */
|
||||||
disablePortal?: boolean;
|
disablePortal?: boolean;
|
||||||
|
/** Open the popover when the trigger receives keyboard/programmatic focus. */
|
||||||
|
openOnFocus?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const InlineEntitySelector = forwardRef<HTMLButtonElement, InlineEntitySelectorProps>(
|
export const InlineEntitySelector = forwardRef<HTMLButtonElement, InlineEntitySelectorProps>(
|
||||||
|
|
@ -40,6 +42,7 @@ export const InlineEntitySelector = forwardRef<HTMLButtonElement, InlineEntitySe
|
||||||
renderTriggerValue,
|
renderTriggerValue,
|
||||||
renderOption,
|
renderOption,
|
||||||
disablePortal,
|
disablePortal,
|
||||||
|
openOnFocus = true,
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
|
|
@ -103,7 +106,7 @@ export const InlineEntitySelector = forwardRef<HTMLButtonElement, InlineEntitySe
|
||||||
)}
|
)}
|
||||||
onPointerDown={() => { isPointerDownRef.current = true; }}
|
onPointerDown={() => { isPointerDownRef.current = true; }}
|
||||||
onFocus={() => {
|
onFocus={() => {
|
||||||
if (!isPointerDownRef.current) setOpen(true);
|
if (openOnFocus && !isPointerDownRef.current) setOpen(true);
|
||||||
isPointerDownRef.current = false;
|
isPointerDownRef.current = false;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -123,7 +126,9 @@ export const InlineEntitySelector = forwardRef<HTMLButtonElement, InlineEntitySe
|
||||||
// On touch devices, don't auto-focus the search input to avoid
|
// On touch devices, don't auto-focus the search input to avoid
|
||||||
// opening the virtual keyboard which reshapes the viewport and
|
// opening the virtual keyboard which reshapes the viewport and
|
||||||
// pushes the popover off-screen.
|
// pushes the popover off-screen.
|
||||||
const isTouch = window.matchMedia("(pointer: coarse)").matches;
|
const isTouch = typeof window.matchMedia === "function"
|
||||||
|
? window.matchMedia("(pointer: coarse)").matches
|
||||||
|
: false;
|
||||||
if (!isTouch) {
|
if (!isTouch) {
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -222,6 +222,18 @@ async function flush() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function waitForValue<T>(getValue: () => T | null | undefined, attempts = 10): Promise<T> {
|
||||||
|
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
||||||
|
const value = getValue();
|
||||||
|
if (value != null) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
await flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Timed out waiting for value");
|
||||||
|
}
|
||||||
|
|
||||||
function renderDialog(container: HTMLDivElement) {
|
function renderDialog(container: HTMLDivElement) {
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
|
|
@ -421,13 +433,13 @@ describe("NewIssueDialog", () => {
|
||||||
|
|
||||||
expect(container.textContent).not.toContain("will no longer use the parent issue workspace");
|
expect(container.textContent).not.toContain("will no longer use the parent issue workspace");
|
||||||
|
|
||||||
const selects = Array.from(container.querySelectorAll("select"));
|
const modeSelect = await waitForValue(
|
||||||
const modeSelect = selects[0] as HTMLSelectElement | undefined;
|
() => container.querySelector("select") as HTMLSelectElement | null,
|
||||||
expect(modeSelect).not.toBeUndefined();
|
);
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
modeSelect!.value = "shared_workspace";
|
modeSelect.value = "shared_workspace";
|
||||||
modeSelect!.dispatchEvent(new Event("change", { bubbles: true }));
|
modeSelect.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
});
|
});
|
||||||
await flush();
|
await flush();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import { act } from "react";
|
import { act } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import type { Project } from "@paperclipai/shared";
|
import type { Agent, Project } from "@paperclipai/shared";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { RoutineRunVariablesDialog } from "./RoutineRunVariablesDialog";
|
import { RoutineRunVariablesDialog } from "./RoutineRunVariablesDialog";
|
||||||
|
|
||||||
|
|
@ -85,6 +85,33 @@ function createProject(): Project {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createAgent(): Agent {
|
||||||
|
return {
|
||||||
|
id: "agent-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
name: "Routine Agent",
|
||||||
|
role: "engineer",
|
||||||
|
title: null,
|
||||||
|
status: "active",
|
||||||
|
reportsTo: null,
|
||||||
|
capabilities: null,
|
||||||
|
adapterType: "process",
|
||||||
|
adapterConfig: {},
|
||||||
|
runtimeConfig: {},
|
||||||
|
budgetMonthlyCents: 0,
|
||||||
|
spentMonthlyCents: 0,
|
||||||
|
lastHeartbeatAt: null,
|
||||||
|
icon: "code",
|
||||||
|
metadata: null,
|
||||||
|
createdAt: new Date("2026-04-02T00:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-04-02T00:00:00.000Z"),
|
||||||
|
urlKey: "routine-agent",
|
||||||
|
pauseReason: null,
|
||||||
|
pausedAt: null,
|
||||||
|
permissions: { canCreateAgents: false },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe("RoutineRunVariablesDialog", () => {
|
describe("RoutineRunVariablesDialog", () => {
|
||||||
let container: HTMLDivElement;
|
let container: HTMLDivElement;
|
||||||
|
|
||||||
|
|
@ -116,7 +143,10 @@ describe("RoutineRunVariablesDialog", () => {
|
||||||
open
|
open
|
||||||
onOpenChange={() => {}}
|
onOpenChange={() => {}}
|
||||||
companyId="company-1"
|
companyId="company-1"
|
||||||
project={createProject()}
|
projects={[createProject()]}
|
||||||
|
agents={[createAgent()]}
|
||||||
|
defaultProjectId="project-1"
|
||||||
|
defaultAssigneeAgentId="agent-1"
|
||||||
variables={[]}
|
variables={[]}
|
||||||
isPending={false}
|
isPending={false}
|
||||||
onSubmit={() => {}}
|
onSubmit={() => {}}
|
||||||
|
|
@ -129,6 +159,8 @@ describe("RoutineRunVariablesDialog", () => {
|
||||||
|
|
||||||
expect(issueWorkspaceDraftCalls).toBeLessThanOrEqual(2);
|
expect(issueWorkspaceDraftCalls).toBeLessThanOrEqual(2);
|
||||||
expect(document.body.textContent).toContain("Run routine");
|
expect(document.body.textContent).toContain("Run routine");
|
||||||
|
expect(document.body.textContent).not.toContain("Search agents...");
|
||||||
|
expect(document.body.textContent).not.toContain("Search projects...");
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root.unmount();
|
root.unmount();
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import type { IssueExecutionWorkspaceSettings, Project, RoutineVariable } from "@paperclipai/shared";
|
import type { Agent, IssueExecutionWorkspaceSettings, Project, RoutineVariable } from "@paperclipai/shared";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { IssueWorkspaceCard } from "./IssueWorkspaceCard";
|
import { IssueWorkspaceCard } from "./IssueWorkspaceCard";
|
||||||
|
import { AgentIcon } from "./AgentIconPicker";
|
||||||
|
import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector";
|
||||||
|
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|
@ -28,6 +31,16 @@ function buildInitialValues(variables: RoutineVariable[]) {
|
||||||
return Object.fromEntries(variables.map((variable) => [variable.name, variable.defaultValue ?? ""]));
|
return Object.fromEntries(variables.map((variable) => [variable.name, variable.defaultValue ?? ""]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildInitialRunSelection(input: {
|
||||||
|
defaultAssigneeAgentId?: string | null;
|
||||||
|
defaultProjectId?: string | null;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
assigneeAgentId: input.defaultAssigneeAgentId ?? "",
|
||||||
|
projectId: input.defaultProjectId ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function defaultProjectWorkspaceIdForProject(project: Project | null | undefined) {
|
function defaultProjectWorkspaceIdForProject(project: Project | null | undefined) {
|
||||||
if (!project) return null;
|
if (!project) return null;
|
||||||
return project.executionWorkspacePolicy?.defaultProjectWorkspaceId
|
return project.executionWorkspacePolicy?.defaultProjectWorkspaceId
|
||||||
|
|
@ -107,6 +120,8 @@ export function routineRunNeedsConfiguration(input: {
|
||||||
|
|
||||||
export interface RoutineRunDialogSubmitData {
|
export interface RoutineRunDialogSubmitData {
|
||||||
variables?: Record<string, string | number | boolean>;
|
variables?: Record<string, string | number | boolean>;
|
||||||
|
assigneeAgentId?: string | null;
|
||||||
|
projectId?: string | null;
|
||||||
executionWorkspaceId?: string | null;
|
executionWorkspaceId?: string | null;
|
||||||
executionWorkspacePreference?: string | null;
|
executionWorkspacePreference?: string | null;
|
||||||
executionWorkspaceSettings?: IssueExecutionWorkspaceSettings | null;
|
executionWorkspaceSettings?: IssueExecutionWorkspaceSettings | null;
|
||||||
|
|
@ -116,7 +131,10 @@ export function RoutineRunVariablesDialog({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
companyId,
|
companyId,
|
||||||
project,
|
projects,
|
||||||
|
agents,
|
||||||
|
defaultProjectId,
|
||||||
|
defaultAssigneeAgentId,
|
||||||
variables,
|
variables,
|
||||||
isPending,
|
isPending,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
|
@ -124,13 +142,48 @@ export function RoutineRunVariablesDialog({
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
companyId: string | null | undefined;
|
companyId: string | null | undefined;
|
||||||
project: Project | null | undefined;
|
projects: Project[];
|
||||||
|
agents: Agent[];
|
||||||
|
defaultProjectId?: string | null;
|
||||||
|
defaultAssigneeAgentId?: string | null;
|
||||||
variables: RoutineVariable[];
|
variables: RoutineVariable[];
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
onSubmit: (data: RoutineRunDialogSubmitData) => void;
|
onSubmit: (data: RoutineRunDialogSubmitData) => void;
|
||||||
}) {
|
}) {
|
||||||
const [values, setValues] = useState<Record<string, unknown>>({});
|
const [values, setValues] = useState<Record<string, unknown>>({});
|
||||||
const [workspaceConfig, setWorkspaceConfig] = useState(() => buildInitialWorkspaceConfig(project));
|
const [selection, setSelection] = useState(() => buildInitialRunSelection({
|
||||||
|
defaultAssigneeAgentId,
|
||||||
|
defaultProjectId,
|
||||||
|
}));
|
||||||
|
const selectedProject = useMemo(
|
||||||
|
() => projects.find((project) => project.id === selection.projectId) ?? null,
|
||||||
|
[projects, selection.projectId],
|
||||||
|
);
|
||||||
|
const recentAssigneeIds = useMemo(() => getRecentAssigneeIds(), [open]);
|
||||||
|
const assigneeOptions = useMemo<InlineEntityOption[]>(
|
||||||
|
() =>
|
||||||
|
sortAgentsByRecency(
|
||||||
|
agents.filter((agent) => agent.status !== "terminated"),
|
||||||
|
recentAssigneeIds,
|
||||||
|
).map((agent) => ({
|
||||||
|
id: agent.id,
|
||||||
|
label: agent.name,
|
||||||
|
searchText: `${agent.name} ${agent.role} ${agent.title ?? ""}`,
|
||||||
|
})),
|
||||||
|
[agents, recentAssigneeIds],
|
||||||
|
);
|
||||||
|
const projectOptions = useMemo<InlineEntityOption[]>(
|
||||||
|
() => projects.map((project) => ({
|
||||||
|
id: project.id,
|
||||||
|
label: project.name,
|
||||||
|
searchText: project.description ?? "",
|
||||||
|
})),
|
||||||
|
[projects],
|
||||||
|
);
|
||||||
|
const currentAssignee = selection.assigneeAgentId
|
||||||
|
? agents.find((agent) => agent.id === selection.assigneeAgentId) ?? null
|
||||||
|
: null;
|
||||||
|
const [workspaceConfig, setWorkspaceConfig] = useState(() => buildInitialWorkspaceConfig(selectedProject));
|
||||||
const [workspaceConfigValid, setWorkspaceConfigValid] = useState(true);
|
const [workspaceConfigValid, setWorkspaceConfigValid] = useState(true);
|
||||||
|
|
||||||
const { data: experimentalSettings } = useQuery({
|
const { data: experimentalSettings } = useQuery({
|
||||||
|
|
@ -140,16 +193,18 @@ export function RoutineRunVariablesDialog({
|
||||||
});
|
});
|
||||||
|
|
||||||
const workspaceSelectionEnabled = supportsRoutineRunWorkspaceSelection(
|
const workspaceSelectionEnabled = supportsRoutineRunWorkspaceSelection(
|
||||||
project,
|
selectedProject,
|
||||||
experimentalSettings?.enableIsolatedWorkspaces === true,
|
experimentalSettings?.enableIsolatedWorkspaces === true,
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
setValues(buildInitialValues(variables));
|
setValues(buildInitialValues(variables));
|
||||||
setWorkspaceConfig(buildInitialWorkspaceConfig(project));
|
const nextSelection = buildInitialRunSelection({ defaultAssigneeAgentId, defaultProjectId });
|
||||||
|
setSelection(nextSelection);
|
||||||
|
setWorkspaceConfig(buildInitialWorkspaceConfig(projects.find((project) => project.id === nextSelection.projectId) ?? null));
|
||||||
setWorkspaceConfigValid(true);
|
setWorkspaceConfigValid(true);
|
||||||
}, [open, project, variables]);
|
}, [defaultAssigneeAgentId, defaultProjectId, open, projects, variables]);
|
||||||
|
|
||||||
const missingRequired = useMemo(
|
const missingRequired = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -162,7 +217,7 @@ export function RoutineRunVariablesDialog({
|
||||||
|
|
||||||
const workspaceIssue = useMemo(() => ({
|
const workspaceIssue = useMemo(() => ({
|
||||||
companyId: companyId ?? null,
|
companyId: companyId ?? null,
|
||||||
projectId: project?.id ?? null,
|
projectId: selectedProject?.id ?? null,
|
||||||
projectWorkspaceId: workspaceConfig.projectWorkspaceId,
|
projectWorkspaceId: workspaceConfig.projectWorkspaceId,
|
||||||
executionWorkspaceId: workspaceConfig.executionWorkspaceId,
|
executionWorkspaceId: workspaceConfig.executionWorkspaceId,
|
||||||
executionWorkspacePreference: workspaceConfig.executionWorkspacePreference,
|
executionWorkspacePreference: workspaceConfig.executionWorkspacePreference,
|
||||||
|
|
@ -170,14 +225,17 @@ export function RoutineRunVariablesDialog({
|
||||||
currentExecutionWorkspace: null,
|
currentExecutionWorkspace: null,
|
||||||
}), [
|
}), [
|
||||||
companyId,
|
companyId,
|
||||||
project?.id,
|
selectedProject?.id,
|
||||||
workspaceConfig.executionWorkspaceId,
|
workspaceConfig.executionWorkspaceId,
|
||||||
workspaceConfig.executionWorkspacePreference,
|
workspaceConfig.executionWorkspacePreference,
|
||||||
workspaceConfig.executionWorkspaceSettings,
|
workspaceConfig.executionWorkspaceSettings,
|
||||||
workspaceConfig.projectWorkspaceId,
|
workspaceConfig.projectWorkspaceId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const canSubmit = missingRequired.length === 0 && (!workspaceSelectionEnabled || workspaceConfigValid);
|
const canSubmit =
|
||||||
|
selection.assigneeAgentId.trim().length > 0 &&
|
||||||
|
missingRequired.length === 0 &&
|
||||||
|
(!workspaceSelectionEnabled || workspaceConfigValid);
|
||||||
|
|
||||||
const handleWorkspaceUpdate = useCallback((data: Record<string, unknown>) => {
|
const handleWorkspaceUpdate = useCallback((data: Record<string, unknown>) => {
|
||||||
setWorkspaceConfig((current) => applyWorkspaceDraft(current, data));
|
setWorkspaceConfig((current) => applyWorkspaceDraft(current, data));
|
||||||
|
|
@ -197,11 +255,100 @@ export function RoutineRunVariablesDialog({
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Run routine</DialogTitle>
|
<DialogTitle>Run routine</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Fill in the routine variables before starting the execution issue.
|
Choose the agent and optional project for this one run. Routine defaults are prefilled and won't be changed.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Agent *</Label>
|
||||||
|
<InlineEntitySelector
|
||||||
|
value={selection.assigneeAgentId}
|
||||||
|
options={assigneeOptions}
|
||||||
|
placeholder="Agent"
|
||||||
|
noneLabel="Select an agent"
|
||||||
|
searchPlaceholder="Search agents..."
|
||||||
|
emptyMessage="No agents found."
|
||||||
|
disablePortal
|
||||||
|
openOnFocus={false}
|
||||||
|
onChange={(assigneeAgentId) => {
|
||||||
|
if (assigneeAgentId) trackRecentAssignee(assigneeAgentId);
|
||||||
|
setSelection((current) => ({ ...current, assigneeAgentId }));
|
||||||
|
}}
|
||||||
|
renderTriggerValue={(option) =>
|
||||||
|
option ? (
|
||||||
|
currentAssignee ? (
|
||||||
|
<>
|
||||||
|
<AgentIcon icon={currentAssignee.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="truncate">{option.label}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="truncate">{option.label}</span>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">Select an agent</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
renderOption={(option) => {
|
||||||
|
if (!option.id) return <span className="truncate">{option.label}</span>;
|
||||||
|
const assignee = agents.find((agent) => agent.id === option.id);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{assignee ? <AgentIcon icon={assignee.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : null}
|
||||||
|
<span className="truncate">{option.label}</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Project</Label>
|
||||||
|
<InlineEntitySelector
|
||||||
|
value={selection.projectId}
|
||||||
|
options={projectOptions}
|
||||||
|
placeholder="Project"
|
||||||
|
noneLabel="No project"
|
||||||
|
searchPlaceholder="Search projects..."
|
||||||
|
emptyMessage="No projects found."
|
||||||
|
disablePortal
|
||||||
|
openOnFocus={false}
|
||||||
|
onChange={(projectId) => {
|
||||||
|
const project = projects.find((entry) => entry.id === projectId) ?? null;
|
||||||
|
setSelection((current) => ({ ...current, projectId }));
|
||||||
|
setWorkspaceConfig(buildInitialWorkspaceConfig(project));
|
||||||
|
setWorkspaceConfigValid(true);
|
||||||
|
}}
|
||||||
|
renderTriggerValue={(option) =>
|
||||||
|
option && selectedProject ? (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className="h-3.5 w-3.5 shrink-0 rounded-sm"
|
||||||
|
style={{ backgroundColor: selectedProject.color ?? "#64748b" }}
|
||||||
|
/>
|
||||||
|
<span className="truncate">{option.label}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">No project</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
renderOption={(option) => {
|
||||||
|
if (!option.id) return <span className="truncate">{option.label}</span>;
|
||||||
|
const project = projects.find((entry) => entry.id === option.id);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className="h-3.5 w-3.5 shrink-0 rounded-sm"
|
||||||
|
style={{ backgroundColor: project?.color ?? "#64748b" }}
|
||||||
|
/>
|
||||||
|
<span className="truncate">{option.label}</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{variables.map((variable) => (
|
{variables.map((variable) => (
|
||||||
<div key={variable.name} className="space-y-1.5">
|
<div key={variable.name} className="space-y-1.5">
|
||||||
<Label className="text-xs">
|
<Label className="text-xs">
|
||||||
|
|
@ -259,11 +406,11 @@ export function RoutineRunVariablesDialog({
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{workspaceSelectionEnabled && project && companyId ? (
|
{workspaceSelectionEnabled && selectedProject && companyId ? (
|
||||||
<IssueWorkspaceCard
|
<IssueWorkspaceCard
|
||||||
key={`${open ? "open" : "closed"}:${project.id}`}
|
key={`${open ? "open" : "closed"}:${selectedProject.id}`}
|
||||||
issue={workspaceIssue}
|
issue={workspaceIssue}
|
||||||
project={project}
|
project={selectedProject}
|
||||||
initialEditing
|
initialEditing
|
||||||
livePreview
|
livePreview
|
||||||
onUpdate={handleWorkspaceUpdate}
|
onUpdate={handleWorkspaceUpdate}
|
||||||
|
|
@ -273,7 +420,9 @@ export function RoutineRunVariablesDialog({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter showCloseButton={false}>
|
<DialogFooter showCloseButton={false}>
|
||||||
{missingRequired.length > 0 ? (
|
{!selection.assigneeAgentId ? (
|
||||||
|
<p className="mr-auto text-xs text-amber-600">Default agent required for this run.</p>
|
||||||
|
) : missingRequired.length > 0 ? (
|
||||||
<p className="mr-auto text-xs text-amber-600">
|
<p className="mr-auto text-xs text-amber-600">
|
||||||
Missing: {missingRequired.join(", ")}
|
Missing: {missingRequired.join(", ")}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -303,6 +452,8 @@ export function RoutineRunVariablesDialog({
|
||||||
}
|
}
|
||||||
onSubmit({
|
onSubmit({
|
||||||
variables: nextVariables,
|
variables: nextVariables,
|
||||||
|
assigneeAgentId: selection.assigneeAgentId,
|
||||||
|
projectId: selection.projectId || null,
|
||||||
...(workspaceSelectionEnabled
|
...(workspaceSelectionEnabled
|
||||||
? {
|
? {
|
||||||
executionWorkspaceId: workspaceConfig.executionWorkspaceId,
|
executionWorkspaceId: workspaceConfig.executionWorkspaceId,
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@ import {
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { routinesApi, type RoutineTriggerResponse, type RotateRoutineTriggerResponse } from "../api/routines";
|
import { routinesApi, type RoutineTriggerResponse, type RotateRoutineTriggerResponse } from "../api/routines";
|
||||||
import { heartbeatsApi } from "../api/heartbeats";
|
import { heartbeatsApi } from "../api/heartbeats";
|
||||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
|
||||||
import { LiveRunWidget } from "../components/LiveRunWidget";
|
import { LiveRunWidget } from "../components/LiveRunWidget";
|
||||||
import { agentsApi } from "../api/agents";
|
import { agentsApi } from "../api/agents";
|
||||||
import { projectsApi } from "../api/projects";
|
import { projectsApi } from "../api/projects";
|
||||||
|
|
@ -35,7 +34,6 @@ import { InlineEntitySelector, type InlineEntityOption } from "../components/Inl
|
||||||
import { MarkdownEditor, type MarkdownEditorRef } from "../components/MarkdownEditor";
|
import { MarkdownEditor, type MarkdownEditorRef } from "../components/MarkdownEditor";
|
||||||
import {
|
import {
|
||||||
RoutineRunVariablesDialog,
|
RoutineRunVariablesDialog,
|
||||||
routineRunNeedsConfiguration,
|
|
||||||
type RoutineRunDialogSubmitData,
|
type RoutineRunDialogSubmitData,
|
||||||
} from "../components/RoutineRunVariablesDialog";
|
} from "../components/RoutineRunVariablesDialog";
|
||||||
import { RoutineVariablesEditor, RoutineVariablesHint } from "../components/RoutineVariablesEditor";
|
import { RoutineVariablesEditor, RoutineVariablesHint } from "../components/RoutineVariablesEditor";
|
||||||
|
|
@ -123,6 +121,24 @@ function getLocalTimezone(): string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildRoutineMutationPayload(input: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
projectId: string;
|
||||||
|
assigneeAgentId: string;
|
||||||
|
priority: string;
|
||||||
|
concurrencyPolicy: string;
|
||||||
|
catchUpPolicy: string;
|
||||||
|
variables: RoutineVariable[];
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
...input,
|
||||||
|
description: input.description.trim() || null,
|
||||||
|
projectId: input.projectId || null,
|
||||||
|
assigneeAgentId: input.assigneeAgentId || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function TriggerEditor({
|
function TriggerEditor({
|
||||||
trigger,
|
trigger,
|
||||||
onSave,
|
onSave,
|
||||||
|
|
@ -333,11 +349,6 @@ export function RoutineDetail() {
|
||||||
queryFn: () => projectsApi.list(selectedCompanyId!),
|
queryFn: () => projectsApi.list(selectedCompanyId!),
|
||||||
enabled: !!selectedCompanyId,
|
enabled: !!selectedCompanyId,
|
||||||
});
|
});
|
||||||
const { data: experimentalSettings } = useQuery({
|
|
||||||
queryKey: queryKeys.instance.experimentalSettings,
|
|
||||||
queryFn: () => instanceSettingsApi.getExperimental(),
|
|
||||||
retry: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const routineDefaults = useMemo(
|
const routineDefaults = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -345,8 +356,8 @@ export function RoutineDetail() {
|
||||||
? {
|
? {
|
||||||
title: routine.title,
|
title: routine.title,
|
||||||
description: routine.description ?? "",
|
description: routine.description ?? "",
|
||||||
projectId: routine.projectId,
|
projectId: routine.projectId ?? "",
|
||||||
assigneeAgentId: routine.assigneeAgentId,
|
assigneeAgentId: routine.assigneeAgentId ?? "",
|
||||||
priority: routine.priority,
|
priority: routine.priority,
|
||||||
concurrencyPolicy: routine.concurrencyPolicy,
|
concurrencyPolicy: routine.concurrencyPolicy,
|
||||||
catchUpPolicy: routine.catchUpPolicy,
|
catchUpPolicy: routine.catchUpPolicy,
|
||||||
|
|
@ -418,10 +429,7 @@ export function RoutineDetail() {
|
||||||
|
|
||||||
const saveRoutine = useMutation({
|
const saveRoutine = useMutation({
|
||||||
mutationFn: () => {
|
mutationFn: () => {
|
||||||
return routinesApi.update(routineId!, {
|
return routinesApi.update(routineId!, buildRoutineMutationPayload(editDraft));
|
||||||
...editDraft,
|
|
||||||
description: editDraft.description.trim() || null,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
|
|
@ -443,6 +451,8 @@ export function RoutineDetail() {
|
||||||
mutationFn: (data?: RoutineRunDialogSubmitData) =>
|
mutationFn: (data?: RoutineRunDialogSubmitData) =>
|
||||||
routinesApi.run(routineId!, {
|
routinesApi.run(routineId!, {
|
||||||
...(data?.variables && Object.keys(data.variables).length > 0 ? { variables: data.variables } : {}),
|
...(data?.variables && Object.keys(data.variables).length > 0 ? { variables: data.variables } : {}),
|
||||||
|
...(data?.assigneeAgentId !== undefined ? { assigneeAgentId: data.assigneeAgentId } : {}),
|
||||||
|
...(data?.projectId !== undefined ? { projectId: data.projectId } : {}),
|
||||||
...(data?.executionWorkspaceId !== undefined ? { executionWorkspaceId: data.executionWorkspaceId } : {}),
|
...(data?.executionWorkspaceId !== undefined ? { executionWorkspaceId: data.executionWorkspaceId } : {}),
|
||||||
...(data?.executionWorkspacePreference !== undefined
|
...(data?.executionWorkspacePreference !== undefined
|
||||||
? { executionWorkspacePreference: data.executionWorkspacePreference }
|
? { executionWorkspacePreference: data.executionWorkspacePreference }
|
||||||
|
|
@ -657,14 +667,15 @@ export function RoutineDetail() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const automationEnabled = routine.status === "active";
|
const automationEnabled = routine.status === "active";
|
||||||
const selectedProject = projects?.find((project) => project.id === routine.projectId) ?? null;
|
const selectedProject = routine.projectId ? (projects?.find((project) => project.id === routine.projectId) ?? null) : null;
|
||||||
const needsRunConfiguration = routineRunNeedsConfiguration({
|
|
||||||
variables: routine.variables ?? [],
|
|
||||||
project: selectedProject,
|
|
||||||
isolatedWorkspacesEnabled: experimentalSettings?.enableIsolatedWorkspaces === true,
|
|
||||||
});
|
|
||||||
const automationToggleDisabled = updateRoutineStatus.isPending || routine.status === "archived";
|
const automationToggleDisabled = updateRoutineStatus.isPending || routine.status === "archived";
|
||||||
const automationLabel = routine.status === "archived" ? "Archived" : automationEnabled ? "Active" : "Paused";
|
const automationLabel = routine.status === "archived"
|
||||||
|
? "Archived"
|
||||||
|
: !routine.assigneeAgentId
|
||||||
|
? "Draft"
|
||||||
|
: automationEnabled
|
||||||
|
? "Active"
|
||||||
|
: "Paused";
|
||||||
const automationLabelClassName = routine.status === "archived"
|
const automationLabelClassName = routine.status === "archived"
|
||||||
? "text-muted-foreground"
|
? "text-muted-foreground"
|
||||||
: automationEnabled
|
: automationEnabled
|
||||||
|
|
@ -708,18 +719,24 @@ export function RoutineDetail() {
|
||||||
<div className="flex shrink-0 items-center gap-3 pt-1">
|
<div className="flex shrink-0 items-center gap-3 pt-1">
|
||||||
<RunButton
|
<RunButton
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (needsRunConfiguration) {
|
setRunVariablesOpen(true);
|
||||||
setRunVariablesOpen(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
runRoutine.mutate({});
|
|
||||||
}}
|
}}
|
||||||
disabled={runRoutine.isPending}
|
disabled={runRoutine.isPending}
|
||||||
/>
|
/>
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
size="lg"
|
size="lg"
|
||||||
checked={automationEnabled}
|
checked={automationEnabled}
|
||||||
onCheckedChange={() => updateRoutineStatus.mutate(automationEnabled ? "paused" : "active")}
|
onCheckedChange={() => {
|
||||||
|
if (!automationEnabled && !routine.assigneeAgentId) {
|
||||||
|
pushToast({
|
||||||
|
title: "Default agent required",
|
||||||
|
body: "Set a default agent before enabling routine automation.",
|
||||||
|
tone: "warn",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateRoutineStatus.mutate(automationEnabled ? "paused" : "active");
|
||||||
|
}}
|
||||||
disabled={automationToggleDisabled}
|
disabled={automationToggleDisabled}
|
||||||
aria-label={automationEnabled ? "Pause automatic triggers" : "Enable automatic triggers"}
|
aria-label={automationEnabled ? "Pause automatic triggers" : "Enable automatic triggers"}
|
||||||
/>
|
/>
|
||||||
|
|
@ -755,6 +772,12 @@ export function RoutineDetail() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!routine.assigneeAgentId ? (
|
||||||
|
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-4 text-sm text-amber-900 dark:text-amber-200">
|
||||||
|
Default agent required. This routine can stay as a draft and still run manually, but automation stays paused until you assign a default agent.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* Assignment row */}
|
{/* Assignment row */}
|
||||||
<div className="overflow-x-auto overscroll-x-contain">
|
<div className="overflow-x-auto overscroll-x-contain">
|
||||||
<div className="inline-flex min-w-full flex-wrap items-center gap-2 text-sm text-muted-foreground sm:min-w-max sm:flex-nowrap">
|
<div className="inline-flex min-w-full flex-wrap items-center gap-2 text-sm text-muted-foreground sm:min-w-max sm:flex-nowrap">
|
||||||
|
|
@ -853,7 +876,7 @@ export function RoutineDetail() {
|
||||||
bordered={false}
|
bordered={false}
|
||||||
contentClassName="min-h-[120px] text-[15px] leading-7"
|
contentClassName="min-h-[120px] text-[15px] leading-7"
|
||||||
onSubmit={() => {
|
onSubmit={() => {
|
||||||
if (!saveRoutine.isPending && editDraft.title.trim() && editDraft.projectId && editDraft.assigneeAgentId) {
|
if (!saveRoutine.isPending && editDraft.title.trim()) {
|
||||||
saveRoutine.mutate();
|
saveRoutine.mutate();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|
@ -921,7 +944,7 @@ export function RoutineDetail() {
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
onClick={() => saveRoutine.mutate()}
|
onClick={() => saveRoutine.mutate()}
|
||||||
disabled={saveRoutine.isPending || !editDraft.title.trim() || !editDraft.projectId || !editDraft.assigneeAgentId}
|
disabled={saveRoutine.isPending || !editDraft.title.trim()}
|
||||||
>
|
>
|
||||||
<Save className="mr-2 h-4 w-4" />
|
<Save className="mr-2 h-4 w-4" />
|
||||||
Save routine
|
Save routine
|
||||||
|
|
@ -1091,7 +1114,10 @@ export function RoutineDetail() {
|
||||||
open={runVariablesOpen}
|
open={runVariablesOpen}
|
||||||
onOpenChange={setRunVariablesOpen}
|
onOpenChange={setRunVariablesOpen}
|
||||||
companyId={routine.companyId}
|
companyId={routine.companyId}
|
||||||
project={selectedProject}
|
agents={agents ?? []}
|
||||||
|
projects={projects ?? []}
|
||||||
|
defaultProjectId={routine.projectId}
|
||||||
|
defaultAssigneeAgentId={routine.assigneeAgentId}
|
||||||
variables={routine.variables ?? []}
|
variables={routine.variables ?? []}
|
||||||
isPending={runRoutine.isPending}
|
isPending={runRoutine.isPending}
|
||||||
onSubmit={(data) => runRoutine.mutate(data)}
|
onSubmit={(data) => runRoutine.mutate(data)}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useNavigate, useSearchParams } from "@/lib/router";
|
import { useNavigate, useSearchParams } from "@/lib/router";
|
||||||
import { Check, ChevronDown, ChevronRight, Layers, MoreHorizontal, Plus, Repeat } from "lucide-react";
|
import { Check, ChevronDown, ChevronRight, Layers, MoreHorizontal, Plus, Repeat } from "lucide-react";
|
||||||
import { routinesApi } from "../api/routines";
|
import { routinesApi } from "../api/routines";
|
||||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
|
||||||
import { agentsApi } from "../api/agents";
|
import { agentsApi } from "../api/agents";
|
||||||
import { projectsApi } from "../api/projects";
|
import { projectsApi } from "../api/projects";
|
||||||
import { issuesApi } from "../api/issues";
|
import { issuesApi } from "../api/issues";
|
||||||
|
|
@ -25,7 +24,6 @@ import { InlineEntitySelector, type InlineEntityOption } from "../components/Inl
|
||||||
import { MarkdownEditor, type MarkdownEditorRef } from "../components/MarkdownEditor";
|
import { MarkdownEditor, type MarkdownEditorRef } from "../components/MarkdownEditor";
|
||||||
import {
|
import {
|
||||||
RoutineRunVariablesDialog,
|
RoutineRunVariablesDialog,
|
||||||
routineRunNeedsConfiguration,
|
|
||||||
type RoutineRunDialogSubmitData,
|
type RoutineRunDialogSubmitData,
|
||||||
} from "../components/RoutineRunVariablesDialog";
|
} from "../components/RoutineRunVariablesDialog";
|
||||||
import { RoutineVariablesEditor, RoutineVariablesHint } from "../components/RoutineVariablesEditor";
|
import { RoutineVariablesEditor, RoutineVariablesHint } from "../components/RoutineVariablesEditor";
|
||||||
|
|
@ -117,6 +115,24 @@ function formatRoutineRunStatus(value: string | null | undefined) {
|
||||||
return value.replaceAll("_", " ");
|
return value.replaceAll("_", " ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildRoutineMutationPayload(input: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
projectId: string;
|
||||||
|
assigneeAgentId: string;
|
||||||
|
priority: string;
|
||||||
|
concurrencyPolicy: string;
|
||||||
|
catchUpPolicy: string;
|
||||||
|
variables: RoutineVariable[];
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
...input,
|
||||||
|
description: input.description.trim() || null,
|
||||||
|
projectId: input.projectId || null,
|
||||||
|
assigneeAgentId: input.assigneeAgentId || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function buildRoutineGroups(
|
export function buildRoutineGroups(
|
||||||
routines: RoutineListItem[],
|
routines: RoutineListItem[],
|
||||||
groupByValue: RoutineGroupBy,
|
groupByValue: RoutineGroupBy,
|
||||||
|
|
@ -186,6 +202,7 @@ function RoutineListRow({
|
||||||
const isStatusPending = statusMutationRoutineId === routine.id;
|
const isStatusPending = statusMutationRoutineId === routine.id;
|
||||||
const project = routine.projectId ? projectById.get(routine.projectId) ?? null : null;
|
const project = routine.projectId ? projectById.get(routine.projectId) ?? null : null;
|
||||||
const agent = routine.assigneeAgentId ? agentById.get(routine.assigneeAgentId) ?? null : null;
|
const agent = routine.assigneeAgentId ? agentById.get(routine.assigneeAgentId) ?? null : null;
|
||||||
|
const isDraft = !isArchived && !routine.assigneeAgentId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -195,9 +212,9 @@ function RoutineListRow({
|
||||||
<div className="min-w-0 flex-1 space-y-1.5">
|
<div className="min-w-0 flex-1 space-y-1.5">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="truncate text-sm font-medium">{routine.title}</span>
|
<span className="truncate text-sm font-medium">{routine.title}</span>
|
||||||
{(isArchived || routine.status === "paused") ? (
|
{(isArchived || routine.status === "paused" || isDraft) ? (
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{isArchived ? "archived" : "paused"}
|
{isArchived ? "archived" : isDraft ? "draft" : "paused"}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -207,11 +224,11 @@ function RoutineListRow({
|
||||||
className="h-2.5 w-2.5 shrink-0 rounded-sm"
|
className="h-2.5 w-2.5 shrink-0 rounded-sm"
|
||||||
style={{ backgroundColor: project?.color ?? "#64748b" }}
|
style={{ backgroundColor: project?.color ?? "#64748b" }}
|
||||||
/>
|
/>
|
||||||
<span>{project?.name ?? "Unknown project"}</span>
|
<span>{routine.projectId ? (project?.name ?? "Unknown project") : "No project"}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
{agent?.icon ? <AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0" /> : null}
|
{agent?.icon ? <AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0" /> : null}
|
||||||
<span>{agent?.name ?? "Unknown agent"}</span>
|
<span>{routine.assigneeAgentId ? (agent?.name ?? "Unknown agent") : "No default agent"}</span>
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
{formatLastRunTimestamp(routine.lastRun?.triggeredAt)}
|
{formatLastRunTimestamp(routine.lastRun?.triggeredAt)}
|
||||||
|
|
@ -230,7 +247,7 @@ function RoutineListRow({
|
||||||
aria-label={enabled ? `Disable ${routine.title}` : `Enable ${routine.title}`}
|
aria-label={enabled ? `Disable ${routine.title}` : `Enable ${routine.title}`}
|
||||||
/>
|
/>
|
||||||
<span className="w-12 text-xs text-muted-foreground">
|
<span className="w-12 text-xs text-muted-foreground">
|
||||||
{isArchived ? "Archived" : enabled ? "On" : "Off"}
|
{isArchived ? "Archived" : isDraft ? "Draft" : enabled ? "On" : "Off"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -334,11 +351,6 @@ export function Routines() {
|
||||||
queryFn: () => projectsApi.list(selectedCompanyId!),
|
queryFn: () => projectsApi.list(selectedCompanyId!),
|
||||||
enabled: !!selectedCompanyId,
|
enabled: !!selectedCompanyId,
|
||||||
});
|
});
|
||||||
const { data: experimentalSettings } = useQuery({
|
|
||||||
queryKey: queryKeys.instance.experimentalSettings,
|
|
||||||
queryFn: () => instanceSettingsApi.getExperimental(),
|
|
||||||
retry: false,
|
|
||||||
});
|
|
||||||
const { data: routineExecutionIssues, isLoading: recentRunsLoading, error: recentRunsError } = useQuery({
|
const { data: routineExecutionIssues, isLoading: recentRunsLoading, error: recentRunsError } = useQuery({
|
||||||
queryKey: [...queryKeys.issues.list(selectedCompanyId!), "routine-executions"],
|
queryKey: [...queryKeys.issues.list(selectedCompanyId!), "routine-executions"],
|
||||||
queryFn: () => issuesApi.list(selectedCompanyId!, { originKind: "routine_execution" }),
|
queryFn: () => issuesApi.list(selectedCompanyId!, { originKind: "routine_execution" }),
|
||||||
|
|
@ -357,10 +369,7 @@ export function Routines() {
|
||||||
|
|
||||||
const createRoutine = useMutation({
|
const createRoutine = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
routinesApi.create(selectedCompanyId!, {
|
routinesApi.create(selectedCompanyId!, buildRoutineMutationPayload(draft)),
|
||||||
...draft,
|
|
||||||
description: draft.description.trim() || null,
|
|
||||||
}),
|
|
||||||
onSuccess: async (routine) => {
|
onSuccess: async (routine) => {
|
||||||
setDraft({
|
setDraft({
|
||||||
title: "",
|
title: "",
|
||||||
|
|
@ -377,7 +386,9 @@ export function Routines() {
|
||||||
await queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) });
|
await queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) });
|
||||||
pushToast({
|
pushToast({
|
||||||
title: "Routine created",
|
title: "Routine created",
|
||||||
body: "Add the first trigger to turn it into a live workflow.",
|
body: routine.assigneeAgentId
|
||||||
|
? "Add the first trigger to turn it into a live workflow."
|
||||||
|
: "Draft saved. Add a default agent before enabling automation.",
|
||||||
tone: "success",
|
tone: "success",
|
||||||
});
|
});
|
||||||
navigate(`/routines/${routine.id}?tab=triggers`);
|
navigate(`/routines/${routine.id}?tab=triggers`);
|
||||||
|
|
@ -417,6 +428,8 @@ export function Routines() {
|
||||||
const runRoutine = useMutation({
|
const runRoutine = useMutation({
|
||||||
mutationFn: ({ id, data }: { id: string; data?: RoutineRunDialogSubmitData }) => routinesApi.run(id, {
|
mutationFn: ({ id, data }: { id: string; data?: RoutineRunDialogSubmitData }) => routinesApi.run(id, {
|
||||||
...(data?.variables && Object.keys(data.variables).length > 0 ? { variables: data.variables } : {}),
|
...(data?.variables && Object.keys(data.variables).length > 0 ? { variables: data.variables } : {}),
|
||||||
|
...(data?.assigneeAgentId !== undefined ? { assigneeAgentId: data.assigneeAgentId } : {}),
|
||||||
|
...(data?.projectId !== undefined ? { projectId: data.projectId } : {}),
|
||||||
...(data?.executionWorkspaceId !== undefined ? { executionWorkspaceId: data.executionWorkspaceId } : {}),
|
...(data?.executionWorkspaceId !== undefined ? { executionWorkspaceId: data.executionWorkspaceId } : {}),
|
||||||
...(data?.executionWorkspacePreference !== undefined
|
...(data?.executionWorkspacePreference !== undefined
|
||||||
? { executionWorkspacePreference: data.executionWorkspacePreference }
|
? { executionWorkspacePreference: data.executionWorkspacePreference }
|
||||||
|
|
@ -497,7 +510,6 @@ export function Routines() {
|
||||||
),
|
),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
const runDialogProject = runDialogRoutine?.projectId ? projectById.get(runDialogRoutine.projectId) ?? null : null;
|
|
||||||
const currentAssignee = draft.assigneeAgentId ? agentById.get(draft.assigneeAgentId) ?? null : null;
|
const currentAssignee = draft.assigneeAgentId ? agentById.get(draft.assigneeAgentId) ?? null : null;
|
||||||
const currentProject = draft.projectId ? projectById.get(draft.projectId) ?? null : null;
|
const currentProject = draft.projectId ? projectById.get(draft.projectId) ?? null : null;
|
||||||
|
|
||||||
|
|
@ -517,20 +529,18 @@ export function Routines() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleRunNow(routine: RoutineListItem) {
|
function handleRunNow(routine: RoutineListItem) {
|
||||||
const project = routine.projectId ? projectById.get(routine.projectId) ?? null : null;
|
setRunDialogRoutine(routine);
|
||||||
const needsConfiguration = routineRunNeedsConfiguration({
|
|
||||||
variables: routine.variables ?? [],
|
|
||||||
project,
|
|
||||||
isolatedWorkspacesEnabled: experimentalSettings?.enableIsolatedWorkspaces === true,
|
|
||||||
});
|
|
||||||
if (needsConfiguration) {
|
|
||||||
setRunDialogRoutine(routine);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
runRoutine.mutate({ id: routine.id, data: {} });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleToggleEnabled(routine: RoutineListItem, enabled: boolean) {
|
function handleToggleEnabled(routine: RoutineListItem, enabled: boolean) {
|
||||||
|
if (!enabled && !routine.assigneeAgentId) {
|
||||||
|
pushToast({
|
||||||
|
title: "Default agent required",
|
||||||
|
body: "Set a default agent before enabling routine automation.",
|
||||||
|
tone: "warn",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
updateRoutineStatus.mutate({
|
updateRoutineStatus.mutate({
|
||||||
id: routine.id,
|
id: routine.id,
|
||||||
status: nextRoutineStatus(routine.status, !enabled),
|
status: nextRoutineStatus(routine.status, !enabled),
|
||||||
|
|
@ -648,7 +658,7 @@ export function Routines() {
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-medium uppercase tracking-[0.2em] text-muted-foreground">New routine</p>
|
<p className="text-xs font-medium uppercase tracking-[0.2em] text-muted-foreground">New routine</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Define the recurring work first. Trigger setup comes next on the detail page.
|
Define the recurring work first. Default project and agent are optional for draft routines.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -798,7 +808,7 @@ export function Routines() {
|
||||||
bordered={false}
|
bordered={false}
|
||||||
contentClassName="min-h-[160px] text-sm text-muted-foreground"
|
contentClassName="min-h-[160px] text-sm text-muted-foreground"
|
||||||
onSubmit={() => {
|
onSubmit={() => {
|
||||||
if (!createRoutine.isPending && draft.title.trim() && draft.projectId && draft.assigneeAgentId) {
|
if (!createRoutine.isPending && draft.title.trim()) {
|
||||||
createRoutine.mutate();
|
createRoutine.mutate();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|
@ -867,16 +877,14 @@ export function Routines() {
|
||||||
|
|
||||||
<div className="shrink-0 flex flex-col gap-3 border-t border-border/60 px-5 py-4 sm:flex-row sm:items-center sm:justify-between">
|
<div className="shrink-0 flex flex-col gap-3 border-t border-border/60 px-5 py-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
After creation, Paperclip takes you straight to trigger setup for schedules, webhooks, or internal runs.
|
After creation, Paperclip takes you straight to trigger setup. Draft routines stay paused until you add a default agent.
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2 sm:items-end">
|
<div className="flex flex-col gap-2 sm:items-end">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => createRoutine.mutate()}
|
onClick={() => createRoutine.mutate()}
|
||||||
disabled={
|
disabled={
|
||||||
createRoutine.isPending ||
|
createRoutine.isPending ||
|
||||||
!draft.title.trim() ||
|
!draft.title.trim()
|
||||||
!draft.projectId ||
|
|
||||||
!draft.assigneeAgentId
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
|
@ -965,7 +973,10 @@ export function Routines() {
|
||||||
if (!next) setRunDialogRoutine(null);
|
if (!next) setRunDialogRoutine(null);
|
||||||
}}
|
}}
|
||||||
companyId={selectedCompanyId}
|
companyId={selectedCompanyId}
|
||||||
project={runDialogProject}
|
agents={agents ?? []}
|
||||||
|
projects={projects ?? []}
|
||||||
|
defaultProjectId={runDialogRoutine?.projectId ?? null}
|
||||||
|
defaultAssigneeAgentId={runDialogRoutine?.assigneeAgentId ?? null}
|
||||||
variables={runDialogRoutine?.variables ?? []}
|
variables={runDialogRoutine?.variables ?? []}
|
||||||
isPending={runRoutine.isPending}
|
isPending={runRoutine.isPending}
|
||||||
onSubmit={(data) => {
|
onSubmit={(data) => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue