Merge pull request #1351 from paperclipai/paperclip-routines

WIP: routines management, triggers, and execution flow
This commit is contained in:
Dotta
2026-03-20 16:53:06 -05:00
committed by GitHub
59 changed files with 18128 additions and 79 deletions

View File

@@ -339,15 +339,15 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
if (provider) args.push("--provider", provider); if (provider) args.push("--provider", provider);
if (modelId) args.push("--model", modelId); if (modelId) args.push("--model", modelId);
if (thinking) args.push("--thinking", thinking); if (thinking) args.push("--thinking", thinking);
args.push("--tools", "read,bash,edit,write,grep,find,ls"); args.push("--tools", "read,bash,edit,write,grep,find,ls");
args.push("--session", sessionFile); args.push("--session", sessionFile);
// Add Paperclip skills directory so Pi can load the paperclip skill // Add Paperclip skills directory so Pi can load the paperclip skill
args.push("--skill", PI_AGENT_SKILLS_DIR); args.push("--skill", PI_AGENT_SKILLS_DIR);
if (extraArgs.length > 0) args.push(...extraArgs); if (extraArgs.length > 0) args.push(...extraArgs);
return args; return args;
}; };

View File

@@ -670,7 +670,18 @@ export async function applyPendingMigrations(url: string): Promise<void> {
await sql.end(); await sql.end();
} }
const bootstrappedState = await inspectMigrations(url); let bootstrappedState = await inspectMigrations(url);
if (bootstrappedState.status === "upToDate") return;
if (bootstrappedState.reason === "pending-migrations") {
const repair = await reconcilePendingMigrationHistory(url);
if (repair.repairedMigrations.length > 0) {
bootstrappedState = await inspectMigrations(url);
}
if (bootstrappedState.status === "needsMigrations" && bootstrappedState.reason === "pending-migrations") {
await applyPendingMigrationsManually(url, bootstrappedState.pendingMigrations);
bootstrappedState = await inspectMigrations(url);
}
}
if (bootstrappedState.status === "upToDate") return; if (bootstrappedState.status === "upToDate") return;
throw new Error( throw new Error(
`Failed to bootstrap migrations: ${bootstrappedState.pendingMigrations.join(", ")}`, `Failed to bootstrap migrations: ${bootstrappedState.pendingMigrations.join(", ")}`,

View File

@@ -0,0 +1,97 @@
CREATE TABLE "routine_runs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"routine_id" uuid NOT NULL,
"trigger_id" uuid,
"source" text NOT NULL,
"status" text DEFAULT 'received' NOT NULL,
"triggered_at" timestamp with time zone DEFAULT now() NOT NULL,
"idempotency_key" text,
"trigger_payload" jsonb,
"linked_issue_id" uuid,
"coalesced_into_run_id" uuid,
"failure_reason" text,
"completed_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "routine_triggers" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"routine_id" uuid NOT NULL,
"kind" text NOT NULL,
"label" text,
"enabled" boolean DEFAULT true NOT NULL,
"cron_expression" text,
"timezone" text,
"next_run_at" timestamp with time zone,
"last_fired_at" timestamp with time zone,
"public_id" text,
"secret_id" uuid,
"signing_mode" text,
"replay_window_sec" integer,
"last_rotated_at" timestamp with time zone,
"last_result" text,
"created_by_agent_id" uuid,
"created_by_user_id" text,
"updated_by_agent_id" uuid,
"updated_by_user_id" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "routines" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"project_id" uuid NOT NULL,
"goal_id" uuid,
"parent_issue_id" uuid,
"title" text NOT NULL,
"description" text,
"assignee_agent_id" uuid NOT NULL,
"priority" text DEFAULT 'medium' NOT NULL,
"status" text DEFAULT 'active' NOT NULL,
"concurrency_policy" text DEFAULT 'coalesce_if_active' NOT NULL,
"catch_up_policy" text DEFAULT 'skip_missed' NOT NULL,
"created_by_agent_id" uuid,
"created_by_user_id" text,
"updated_by_agent_id" uuid,
"updated_by_user_id" text,
"last_triggered_at" timestamp with time zone,
"last_enqueued_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "issues" ADD COLUMN "origin_kind" text DEFAULT 'manual' NOT NULL;--> statement-breakpoint
ALTER TABLE "issues" ADD COLUMN "origin_id" text;--> statement-breakpoint
ALTER TABLE "issues" ADD COLUMN "origin_run_id" text;--> statement-breakpoint
ALTER TABLE "routine_runs" ADD CONSTRAINT "routine_runs_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "routine_runs" ADD CONSTRAINT "routine_runs_routine_id_routines_id_fk" FOREIGN KEY ("routine_id") REFERENCES "public"."routines"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "routine_runs" ADD CONSTRAINT "routine_runs_trigger_id_routine_triggers_id_fk" FOREIGN KEY ("trigger_id") REFERENCES "public"."routine_triggers"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "routine_runs" ADD CONSTRAINT "routine_runs_linked_issue_id_issues_id_fk" FOREIGN KEY ("linked_issue_id") REFERENCES "public"."issues"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "routine_triggers" ADD CONSTRAINT "routine_triggers_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "routine_triggers" ADD CONSTRAINT "routine_triggers_routine_id_routines_id_fk" FOREIGN KEY ("routine_id") REFERENCES "public"."routines"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "routine_triggers" ADD CONSTRAINT "routine_triggers_secret_id_company_secrets_id_fk" FOREIGN KEY ("secret_id") REFERENCES "public"."company_secrets"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "routine_triggers" ADD CONSTRAINT "routine_triggers_created_by_agent_id_agents_id_fk" FOREIGN KEY ("created_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "routine_triggers" ADD CONSTRAINT "routine_triggers_updated_by_agent_id_agents_id_fk" FOREIGN KEY ("updated_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "routines" ADD CONSTRAINT "routines_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "routines" ADD CONSTRAINT "routines_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "routines" ADD CONSTRAINT "routines_goal_id_goals_id_fk" FOREIGN KEY ("goal_id") REFERENCES "public"."goals"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "routines" ADD CONSTRAINT "routines_parent_issue_id_issues_id_fk" FOREIGN KEY ("parent_issue_id") REFERENCES "public"."issues"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "routines" ADD CONSTRAINT "routines_assignee_agent_id_agents_id_fk" FOREIGN KEY ("assignee_agent_id") REFERENCES "public"."agents"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "routines" ADD CONSTRAINT "routines_created_by_agent_id_agents_id_fk" FOREIGN KEY ("created_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "routines" ADD CONSTRAINT "routines_updated_by_agent_id_agents_id_fk" FOREIGN KEY ("updated_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "routine_runs_company_routine_idx" ON "routine_runs" USING btree ("company_id","routine_id","created_at");--> statement-breakpoint
CREATE INDEX "routine_runs_trigger_idx" ON "routine_runs" USING btree ("trigger_id","created_at");--> statement-breakpoint
CREATE INDEX "routine_runs_linked_issue_idx" ON "routine_runs" USING btree ("linked_issue_id");--> statement-breakpoint
CREATE INDEX "routine_runs_trigger_idempotency_idx" ON "routine_runs" USING btree ("trigger_id","idempotency_key");--> statement-breakpoint
CREATE INDEX "routine_triggers_company_routine_idx" ON "routine_triggers" USING btree ("company_id","routine_id");--> statement-breakpoint
CREATE INDEX "routine_triggers_company_kind_idx" ON "routine_triggers" USING btree ("company_id","kind");--> statement-breakpoint
CREATE INDEX "routine_triggers_next_run_idx" ON "routine_triggers" USING btree ("next_run_at");--> statement-breakpoint
CREATE INDEX "routine_triggers_public_id_idx" ON "routine_triggers" USING btree ("public_id");--> statement-breakpoint
CREATE INDEX "routines_company_status_idx" ON "routines" USING btree ("company_id","status");--> statement-breakpoint
CREATE INDEX "routines_company_assignee_idx" ON "routines" USING btree ("company_id","assignee_agent_id");--> statement-breakpoint
CREATE INDEX "routines_company_project_idx" ON "routines" USING btree ("company_id","project_id");--> statement-breakpoint
CREATE INDEX "issues_company_origin_idx" ON "issues" USING btree ("company_id","origin_kind","origin_id");

View File

@@ -0,0 +1,5 @@
CREATE UNIQUE INDEX "issues_open_routine_execution_uq" ON "issues" USING btree ("company_id","origin_kind","origin_id") WHERE "issues"."origin_kind" = 'routine_execution'
and "issues"."origin_id" is not null
and "issues"."hidden_at" is null
and "issues"."status" in ('backlog', 'todo', 'in_progress', 'in_review', 'blocked');--> statement-breakpoint
CREATE UNIQUE INDEX "routine_triggers_public_id_uq" ON "routine_triggers" USING btree ("public_id");

View File

@@ -0,0 +1,6 @@
DROP INDEX "issues_open_routine_execution_uq";--> statement-breakpoint
CREATE UNIQUE INDEX "issues_open_routine_execution_uq" ON "issues" USING btree ("company_id","origin_kind","origin_id") WHERE "issues"."origin_kind" = 'routine_execution'
and "issues"."origin_id" is not null
and "issues"."hidden_at" is null
and "issues"."execution_run_id" is not null
and "issues"."status" in ('backlog', 'todo', 'in_progress', 'in_review', 'blocked');

File diff suppressed because it is too large Load Diff

View File

@@ -10305,4 +10305,4 @@
"schemas": {}, "schemas": {},
"tables": {} "tables": {}
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -278,16 +278,37 @@
{ {
"idx": 39, "idx": 39,
"version": "7", "version": "7",
"when": 1774011294562, "when": 1773926116580,
"tag": "0039_curly_maria_hill", "tag": "0038_fat_magneto",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 40, "idx": 40,
"version": "7", "version": "7",
"when": 1773927102783,
"tag": "0039_eager_shotgun",
"breakpoints": true
},
{
"idx": 41,
"version": "7",
"when": 1774011294562,
"tag": "0039_curly_maria_hill",
"breakpoints": true
},
{
"idx": 42,
"version": "7",
"when": 1774031825634, "when": 1774031825634,
"tag": "0040_spotty_the_renegades", "tag": "0040_spotty_the_renegades",
"breakpoints": true "breakpoints": true
},
{
"idx": 43,
"version": "7",
"when": 1774008910991,
"tag": "0041_reflective_captain_universe",
"breakpoints": true
} }
] ]
} }

View File

@@ -23,6 +23,7 @@ export { workspaceRuntimeServices } from "./workspace_runtime_services.js";
export { projectGoals } from "./project_goals.js"; export { projectGoals } from "./project_goals.js";
export { goals } from "./goals.js"; export { goals } from "./goals.js";
export { issues } from "./issues.js"; export { issues } from "./issues.js";
export { routines, routineTriggers, routineRuns } from "./routines.js";
export { issueWorkProducts } from "./issue_work_products.js"; export { issueWorkProducts } from "./issue_work_products.js";
export { labels } from "./labels.js"; export { labels } from "./labels.js";
export { issueLabels } from "./issue_labels.js"; export { issueLabels } from "./issue_labels.js";

View File

@@ -1,3 +1,4 @@
import { sql } from "drizzle-orm";
import { import {
type AnyPgColumn, type AnyPgColumn,
pgTable, pgTable,
@@ -40,6 +41,9 @@ export const issues = pgTable(
createdByUserId: text("created_by_user_id"), createdByUserId: text("created_by_user_id"),
issueNumber: integer("issue_number"), issueNumber: integer("issue_number"),
identifier: text("identifier"), identifier: text("identifier"),
originKind: text("origin_kind").notNull().default("manual"),
originId: text("origin_id"),
originRunId: text("origin_run_id"),
requestDepth: integer("request_depth").notNull().default(0), requestDepth: integer("request_depth").notNull().default(0),
billingCode: text("billing_code"), billingCode: text("billing_code"),
assigneeAdapterOverrides: jsonb("assignee_adapter_overrides").$type<Record<string, unknown>>(), assigneeAdapterOverrides: jsonb("assignee_adapter_overrides").$type<Record<string, unknown>>(),
@@ -68,8 +72,18 @@ export const issues = pgTable(
), ),
parentIdx: index("issues_company_parent_idx").on(table.companyId, table.parentId), parentIdx: index("issues_company_parent_idx").on(table.companyId, table.parentId),
projectIdx: index("issues_company_project_idx").on(table.companyId, table.projectId), projectIdx: index("issues_company_project_idx").on(table.companyId, table.projectId),
originIdx: index("issues_company_origin_idx").on(table.companyId, table.originKind, table.originId),
projectWorkspaceIdx: index("issues_company_project_workspace_idx").on(table.companyId, table.projectWorkspaceId), projectWorkspaceIdx: index("issues_company_project_workspace_idx").on(table.companyId, table.projectWorkspaceId),
executionWorkspaceIdx: index("issues_company_execution_workspace_idx").on(table.companyId, table.executionWorkspaceId), executionWorkspaceIdx: index("issues_company_execution_workspace_idx").on(table.companyId, table.executionWorkspaceId),
identifierIdx: uniqueIndex("issues_identifier_idx").on(table.identifier), identifierIdx: uniqueIndex("issues_identifier_idx").on(table.identifier),
openRoutineExecutionIdx: uniqueIndex("issues_open_routine_execution_uq")
.on(table.companyId, table.originKind, table.originId)
.where(
sql`${table.originKind} = 'routine_execution'
and ${table.originId} is not null
and ${table.hiddenAt} is null
and ${table.executionRunId} is not null
and ${table.status} in ('backlog', 'todo', 'in_progress', 'in_review', 'blocked')`,
),
}), }),
); );

View File

@@ -0,0 +1,110 @@
import {
boolean,
index,
integer,
jsonb,
pgTable,
text,
timestamp,
uniqueIndex,
uuid,
} from "drizzle-orm/pg-core";
import { agents } from "./agents.js";
import { companies } from "./companies.js";
import { companySecrets } from "./company_secrets.js";
import { issues } from "./issues.js";
import { projects } from "./projects.js";
import { goals } from "./goals.js";
export const routines = pgTable(
"routines",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
projectId: uuid("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
goalId: uuid("goal_id").references(() => goals.id, { onDelete: "set null" }),
parentIssueId: uuid("parent_issue_id").references(() => issues.id, { onDelete: "set null" }),
title: text("title").notNull(),
description: text("description"),
assigneeAgentId: uuid("assignee_agent_id").notNull().references(() => agents.id),
priority: text("priority").notNull().default("medium"),
status: text("status").notNull().default("active"),
concurrencyPolicy: text("concurrency_policy").notNull().default("coalesce_if_active"),
catchUpPolicy: text("catch_up_policy").notNull().default("skip_missed"),
createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
createdByUserId: text("created_by_user_id"),
updatedByAgentId: uuid("updated_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
updatedByUserId: text("updated_by_user_id"),
lastTriggeredAt: timestamp("last_triggered_at", { withTimezone: true }),
lastEnqueuedAt: timestamp("last_enqueued_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyStatusIdx: index("routines_company_status_idx").on(table.companyId, table.status),
companyAssigneeIdx: index("routines_company_assignee_idx").on(table.companyId, table.assigneeAgentId),
companyProjectIdx: index("routines_company_project_idx").on(table.companyId, table.projectId),
}),
);
export const routineTriggers = pgTable(
"routine_triggers",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
routineId: uuid("routine_id").notNull().references(() => routines.id, { onDelete: "cascade" }),
kind: text("kind").notNull(),
label: text("label"),
enabled: boolean("enabled").notNull().default(true),
cronExpression: text("cron_expression"),
timezone: text("timezone"),
nextRunAt: timestamp("next_run_at", { withTimezone: true }),
lastFiredAt: timestamp("last_fired_at", { withTimezone: true }),
publicId: text("public_id"),
secretId: uuid("secret_id").references(() => companySecrets.id, { onDelete: "set null" }),
signingMode: text("signing_mode"),
replayWindowSec: integer("replay_window_sec"),
lastRotatedAt: timestamp("last_rotated_at", { withTimezone: true }),
lastResult: text("last_result"),
createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
createdByUserId: text("created_by_user_id"),
updatedByAgentId: uuid("updated_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
updatedByUserId: text("updated_by_user_id"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyRoutineIdx: index("routine_triggers_company_routine_idx").on(table.companyId, table.routineId),
companyKindIdx: index("routine_triggers_company_kind_idx").on(table.companyId, table.kind),
nextRunIdx: index("routine_triggers_next_run_idx").on(table.nextRunAt),
publicIdIdx: index("routine_triggers_public_id_idx").on(table.publicId),
publicIdUq: uniqueIndex("routine_triggers_public_id_uq").on(table.publicId),
}),
);
export const routineRuns = pgTable(
"routine_runs",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
routineId: uuid("routine_id").notNull().references(() => routines.id, { onDelete: "cascade" }),
triggerId: uuid("trigger_id").references(() => routineTriggers.id, { onDelete: "set null" }),
source: text("source").notNull(),
status: text("status").notNull().default("received"),
triggeredAt: timestamp("triggered_at", { withTimezone: true }).notNull().defaultNow(),
idempotencyKey: text("idempotency_key"),
triggerPayload: jsonb("trigger_payload").$type<Record<string, unknown>>(),
linkedIssueId: uuid("linked_issue_id").references(() => issues.id, { onDelete: "set null" }),
coalescedIntoRunId: uuid("coalesced_into_run_id"),
failureReason: text("failure_reason"),
completedAt: timestamp("completed_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyRoutineIdx: index("routine_runs_company_routine_idx").on(table.companyId, table.routineId, table.createdAt),
triggerIdx: index("routine_runs_trigger_idx").on(table.triggerId, table.createdAt),
linkedIssueIdx: index("routine_runs_linked_issue_idx").on(table.linkedIssueId),
idempotencyIdx: index("routine_runs_trigger_idempotency_idx").on(table.triggerId, table.idempotencyKey),
}),
);

View File

@@ -122,6 +122,9 @@ export type IssueStatus = (typeof ISSUE_STATUSES)[number];
export const ISSUE_PRIORITIES = ["critical", "high", "medium", "low"] as const; export const ISSUE_PRIORITIES = ["critical", "high", "medium", "low"] as const;
export type IssuePriority = (typeof ISSUE_PRIORITIES)[number]; export type IssuePriority = (typeof ISSUE_PRIORITIES)[number];
export const ISSUE_ORIGIN_KINDS = ["manual", "routine_execution"] as const;
export type IssueOriginKind = (typeof ISSUE_ORIGIN_KINDS)[number];
export const GOAL_LEVELS = ["company", "team", "agent", "task"] as const; export const GOAL_LEVELS = ["company", "team", "agent", "task"] as const;
export type GoalLevel = (typeof GOAL_LEVELS)[number]; export type GoalLevel = (typeof GOAL_LEVELS)[number];
@@ -137,6 +140,34 @@ export const PROJECT_STATUSES = [
] as const; ] as const;
export type ProjectStatus = (typeof PROJECT_STATUSES)[number]; export type ProjectStatus = (typeof PROJECT_STATUSES)[number];
export const ROUTINE_STATUSES = ["active", "paused", "archived"] as const;
export type RoutineStatus = (typeof ROUTINE_STATUSES)[number];
export const ROUTINE_CONCURRENCY_POLICIES = ["coalesce_if_active", "always_enqueue", "skip_if_active"] as const;
export type RoutineConcurrencyPolicy = (typeof ROUTINE_CONCURRENCY_POLICIES)[number];
export const ROUTINE_CATCH_UP_POLICIES = ["skip_missed", "enqueue_missed_with_cap"] as const;
export type RoutineCatchUpPolicy = (typeof ROUTINE_CATCH_UP_POLICIES)[number];
export const ROUTINE_TRIGGER_KINDS = ["schedule", "webhook", "api"] as const;
export type RoutineTriggerKind = (typeof ROUTINE_TRIGGER_KINDS)[number];
export const ROUTINE_TRIGGER_SIGNING_MODES = ["bearer", "hmac_sha256"] as const;
export type RoutineTriggerSigningMode = (typeof ROUTINE_TRIGGER_SIGNING_MODES)[number];
export const ROUTINE_RUN_STATUSES = [
"received",
"coalesced",
"skipped",
"issue_created",
"completed",
"failed",
] as const;
export type RoutineRunStatus = (typeof ROUTINE_RUN_STATUSES)[number];
export const ROUTINE_RUN_SOURCES = ["schedule", "manual", "api", "webhook"] as const;
export type RoutineRunSource = (typeof ROUTINE_RUN_SOURCES)[number];
export const PAUSE_REASONS = ["manual", "budget", "system"] as const; export const PAUSE_REASONS = ["manual", "budget", "system"] as const;
export type PauseReason = (typeof PAUSE_REASONS)[number]; export type PauseReason = (typeof PAUSE_REASONS)[number];

View File

@@ -10,9 +10,17 @@ export {
AGENT_ICON_NAMES, AGENT_ICON_NAMES,
ISSUE_STATUSES, ISSUE_STATUSES,
ISSUE_PRIORITIES, ISSUE_PRIORITIES,
ISSUE_ORIGIN_KINDS,
GOAL_LEVELS, GOAL_LEVELS,
GOAL_STATUSES, GOAL_STATUSES,
PROJECT_STATUSES, PROJECT_STATUSES,
ROUTINE_STATUSES,
ROUTINE_CONCURRENCY_POLICIES,
ROUTINE_CATCH_UP_POLICIES,
ROUTINE_TRIGGER_KINDS,
ROUTINE_TRIGGER_SIGNING_MODES,
ROUTINE_RUN_STATUSES,
ROUTINE_RUN_SOURCES,
PAUSE_REASONS, PAUSE_REASONS,
PROJECT_COLORS, PROJECT_COLORS,
APPROVAL_TYPES, APPROVAL_TYPES,
@@ -69,9 +77,17 @@ export {
type AgentIconName, type AgentIconName,
type IssueStatus, type IssueStatus,
type IssuePriority, type IssuePriority,
type IssueOriginKind,
type GoalLevel, type GoalLevel,
type GoalStatus, type GoalStatus,
type ProjectStatus, type ProjectStatus,
type RoutineStatus,
type RoutineConcurrencyPolicy,
type RoutineCatchUpPolicy,
type RoutineTriggerKind,
type RoutineTriggerSigningMode,
type RoutineRunStatus,
type RoutineRunSource,
type PauseReason, type PauseReason,
type ApprovalType, type ApprovalType,
type ApprovalStatus, type ApprovalStatus,
@@ -262,6 +278,14 @@ export type {
AgentEnvConfig, AgentEnvConfig,
CompanySecret, CompanySecret,
SecretProviderDescriptor, SecretProviderDescriptor,
Routine,
RoutineTrigger,
RoutineRun,
RoutineTriggerSecretMaterial,
RoutineDetail,
RoutineRunSummary,
RoutineExecutionIssueOrigin,
RoutineListItem,
JsonSchema, JsonSchema,
PluginJobDeclaration, PluginJobDeclaration,
PluginWebhookDeclaration, PluginWebhookDeclaration,
@@ -396,9 +420,21 @@ export {
createSecretSchema, createSecretSchema,
rotateSecretSchema, rotateSecretSchema,
updateSecretSchema, updateSecretSchema,
createRoutineSchema,
updateRoutineSchema,
createRoutineTriggerSchema,
updateRoutineTriggerSchema,
runRoutineSchema,
rotateRoutineTriggerSecretSchema,
type CreateSecret, type CreateSecret,
type RotateSecret, type RotateSecret,
type UpdateSecret, type UpdateSecret,
type CreateRoutine,
type UpdateRoutine,
type CreateRoutineTrigger,
type UpdateRoutineTrigger,
type RunRoutine,
type RotateRoutineTriggerSecret,
createCostEventSchema, createCostEventSchema,
createFinanceEventSchema, createFinanceEventSchema,
updateBudgetSchema, updateBudgetSchema,

View File

@@ -107,6 +107,16 @@ export type {
CompanySecret, CompanySecret,
SecretProviderDescriptor, SecretProviderDescriptor,
} from "./secrets.js"; } from "./secrets.js";
export type {
Routine,
RoutineTrigger,
RoutineRun,
RoutineTriggerSecretMaterial,
RoutineDetail,
RoutineRunSummary,
RoutineExecutionIssueOrigin,
RoutineListItem,
} from "./routine.js";
export type { CostEvent, CostSummary, CostByAgent, CostByProviderModel, CostByBiller, CostByAgentModel, CostWindowSpendRow, CostByProject } from "./cost.js"; export type { CostEvent, CostSummary, CostByAgent, CostByProviderModel, CostByBiller, CostByAgentModel, CostWindowSpendRow, CostByProject } from "./cost.js";
export type { FinanceEvent, FinanceSummary, FinanceByBiller, FinanceByKind } from "./finance.js"; export type { FinanceEvent, FinanceSummary, FinanceByBiller, FinanceByKind } from "./finance.js";
export type { export type {

View File

@@ -1,4 +1,4 @@
import type { IssuePriority, IssueStatus } from "../constants.js"; import type { IssueOriginKind, IssuePriority, IssueStatus } from "../constants.js";
import type { Goal } from "./goal.js"; import type { Goal } from "./goal.js";
import type { Project, ProjectWorkspace } from "./project.js"; import type { Project, ProjectWorkspace } from "./project.js";
import type { ExecutionWorkspace, IssueExecutionWorkspaceSettings } from "./workspace-runtime.js"; import type { ExecutionWorkspace, IssueExecutionWorkspaceSettings } from "./workspace-runtime.js";
@@ -116,6 +116,9 @@ export interface Issue {
createdByUserId: string | null; createdByUserId: string | null;
issueNumber: number | null; issueNumber: number | null;
identifier: string | null; identifier: string | null;
originKind?: IssueOriginKind;
originId?: string | null;
originRunId?: string | null;
requestDepth: number; requestDepth: number;
billingCode: string | null; billingCode: string | null;
assigneeAdapterOverrides: IssueAssigneeAdapterOverrides | null; assigneeAdapterOverrides: IssueAssigneeAdapterOverrides | null;

View File

@@ -0,0 +1,123 @@
import type { IssueOriginKind } from "../constants.js";
export interface RoutineProjectSummary {
id: string;
name: string;
description: string | null;
status: string;
goalId?: string | null;
}
export interface RoutineAgentSummary {
id: string;
name: string;
role: string;
title: string | null;
urlKey?: string | null;
}
export interface RoutineIssueSummary {
id: string;
identifier: string | null;
title: string;
status: string;
priority: string;
updatedAt: Date;
}
export interface Routine {
id: string;
companyId: string;
projectId: string;
goalId: string | null;
parentIssueId: string | null;
title: string;
description: string | null;
assigneeAgentId: string;
priority: string;
status: string;
concurrencyPolicy: string;
catchUpPolicy: string;
createdByAgentId: string | null;
createdByUserId: string | null;
updatedByAgentId: string | null;
updatedByUserId: string | null;
lastTriggeredAt: Date | null;
lastEnqueuedAt: Date | null;
createdAt: Date;
updatedAt: Date;
}
export interface RoutineTrigger {
id: string;
companyId: string;
routineId: string;
kind: string;
label: string | null;
enabled: boolean;
cronExpression: string | null;
timezone: string | null;
nextRunAt: Date | null;
lastFiredAt: Date | null;
publicId: string | null;
secretId: string | null;
signingMode: string | null;
replayWindowSec: number | null;
lastRotatedAt: Date | null;
lastResult: string | null;
createdByAgentId: string | null;
createdByUserId: string | null;
updatedByAgentId: string | null;
updatedByUserId: string | null;
createdAt: Date;
updatedAt: Date;
}
export interface RoutineRun {
id: string;
companyId: string;
routineId: string;
triggerId: string | null;
source: string;
status: string;
triggeredAt: Date;
idempotencyKey: string | null;
triggerPayload: Record<string, unknown> | null;
linkedIssueId: string | null;
coalescedIntoRunId: string | null;
failureReason: string | null;
completedAt: Date | null;
createdAt: Date;
updatedAt: Date;
}
export interface RoutineTriggerSecretMaterial {
webhookUrl: string;
webhookSecret: string;
}
export interface RoutineDetail extends Routine {
project: RoutineProjectSummary | null;
assignee: RoutineAgentSummary | null;
parentIssue: RoutineIssueSummary | null;
triggers: RoutineTrigger[];
recentRuns: RoutineRunSummary[];
activeIssue: RoutineIssueSummary | null;
}
export interface RoutineRunSummary extends RoutineRun {
linkedIssue: RoutineIssueSummary | null;
trigger: Pick<RoutineTrigger, "id" | "kind" | "label"> | null;
}
export interface RoutineExecutionIssueOrigin {
kind: Extract<IssueOriginKind, "routine_execution">;
routineId: string;
runId: string | null;
}
export interface RoutineListItem extends Routine {
triggers: Pick<RoutineTrigger, "id" | "kind" | "label" | "enabled" | "nextRunAt" | "lastFiredAt" | "lastResult">[];
lastRun: RoutineRunSummary | null;
activeIssue: RoutineIssueSummary | null;
}

View File

@@ -188,6 +188,21 @@ export {
type UpdateSecret, type UpdateSecret,
} from "./secret.js"; } from "./secret.js";
export {
createRoutineSchema,
updateRoutineSchema,
createRoutineTriggerSchema,
updateRoutineTriggerSchema,
runRoutineSchema,
rotateRoutineTriggerSecretSchema,
type CreateRoutine,
type UpdateRoutine,
type CreateRoutineTrigger,
type UpdateRoutineTrigger,
type RunRoutine,
type RotateRoutineTriggerSecret,
} from "./routine.js";
export { export {
createCostEventSchema, createCostEventSchema,
updateBudgetSchema, updateBudgetSchema,

View File

@@ -0,0 +1,72 @@
import { z } from "zod";
import {
ISSUE_PRIORITIES,
ROUTINE_CATCH_UP_POLICIES,
ROUTINE_CONCURRENCY_POLICIES,
ROUTINE_STATUSES,
ROUTINE_TRIGGER_SIGNING_MODES,
} from "../constants.js";
export const createRoutineSchema = z.object({
projectId: z.string().uuid(),
goalId: z.string().uuid().optional().nullable(),
parentIssueId: z.string().uuid().optional().nullable(),
title: z.string().trim().min(1).max(200),
description: z.string().optional().nullable(),
assigneeAgentId: z.string().uuid(),
priority: z.enum(ISSUE_PRIORITIES).optional().default("medium"),
status: z.enum(ROUTINE_STATUSES).optional().default("active"),
concurrencyPolicy: z.enum(ROUTINE_CONCURRENCY_POLICIES).optional().default("coalesce_if_active"),
catchUpPolicy: z.enum(ROUTINE_CATCH_UP_POLICIES).optional().default("skip_missed"),
});
export type CreateRoutine = z.infer<typeof createRoutineSchema>;
export const updateRoutineSchema = createRoutineSchema.partial();
export type UpdateRoutine = z.infer<typeof updateRoutineSchema>;
const baseTriggerSchema = z.object({
label: z.string().trim().max(120).optional().nullable(),
enabled: z.boolean().optional().default(true),
});
export const createRoutineTriggerSchema = z.discriminatedUnion("kind", [
baseTriggerSchema.extend({
kind: z.literal("schedule"),
cronExpression: z.string().trim().min(1),
timezone: z.string().trim().min(1).default("UTC"),
}),
baseTriggerSchema.extend({
kind: z.literal("webhook"),
signingMode: z.enum(ROUTINE_TRIGGER_SIGNING_MODES).optional().default("bearer"),
replayWindowSec: z.number().int().min(30).max(86_400).optional().default(300),
}),
baseTriggerSchema.extend({
kind: z.literal("api"),
}),
]);
export type CreateRoutineTrigger = z.infer<typeof createRoutineTriggerSchema>;
export const updateRoutineTriggerSchema = z.object({
label: z.string().trim().max(120).optional().nullable(),
enabled: z.boolean().optional(),
cronExpression: z.string().trim().min(1).optional().nullable(),
timezone: z.string().trim().min(1).optional().nullable(),
signingMode: z.enum(ROUTINE_TRIGGER_SIGNING_MODES).optional().nullable(),
replayWindowSec: z.number().int().min(30).max(86_400).optional().nullable(),
});
export type UpdateRoutineTrigger = z.infer<typeof updateRoutineTriggerSchema>;
export const runRoutineSchema = z.object({
triggerId: z.string().uuid().optional().nullable(),
payload: z.record(z.unknown()).optional().nullable(),
idempotencyKey: z.string().trim().max(255).optional().nullable(),
source: z.enum(["manual", "api"]).optional().default("manual"),
});
export type RunRoutine = z.infer<typeof runRoutineSchema>;
export const rotateRoutineTriggerSecretSchema = z.object({});
export type RotateRoutineTriggerSecret = z.infer<typeof rotateRoutineTriggerSecretSchema>;

View File

@@ -151,6 +151,8 @@ describe("agent permission routes", () => {
mockAccessService.listPrincipalGrants.mockResolvedValue([]); mockAccessService.listPrincipalGrants.mockResolvedValue([]);
mockAccessService.ensureMembership.mockResolvedValue(undefined); mockAccessService.ensureMembership.mockResolvedValue(undefined);
mockAccessService.setPrincipalPermission.mockResolvedValue(undefined); mockAccessService.setPrincipalPermission.mockResolvedValue(undefined);
mockCompanySkillService.listRuntimeSkillEntries.mockResolvedValue([]);
mockCompanySkillService.resolveRequestedSkillKeys.mockImplementation(async (_companyId, requested) => requested);
mockBudgetService.upsertPolicy.mockResolvedValue(undefined); mockBudgetService.upsertPolicy.mockResolvedValue(undefined);
mockAgentInstructionsService.materializeManagedBundle.mockImplementation( mockAgentInstructionsService.materializeManagedBundle.mockImplementation(
async (agent: Record<string, unknown>, files: Record<string, string>) => ({ async (agent: Record<string, unknown>, files: Record<string, string>) => ({

View File

@@ -14,6 +14,8 @@ const mockAgentService = vi.hoisted(() => ({
const mockAccessService = vi.hoisted(() => ({ const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(), canUser: vi.fn(),
hasPermission: vi.fn(), hasPermission: vi.fn(),
getMembership: vi.fn(),
listPrincipalGrants: vi.fn(),
ensureMembership: vi.fn(), ensureMembership: vi.fn(),
setPrincipalPermission: vi.fn(), setPrincipalPermission: vi.fn(),
})); }));
@@ -203,6 +205,8 @@ describe("agent skill routes", () => {
mockLogActivity.mockResolvedValue(undefined); mockLogActivity.mockResolvedValue(undefined);
mockAccessService.canUser.mockResolvedValue(true); mockAccessService.canUser.mockResolvedValue(true);
mockAccessService.hasPermission.mockResolvedValue(true); mockAccessService.hasPermission.mockResolvedValue(true);
mockAccessService.getMembership.mockResolvedValue(null);
mockAccessService.listPrincipalGrants.mockResolvedValue([]);
mockAccessService.ensureMembership.mockResolvedValue(undefined); mockAccessService.ensureMembership.mockResolvedValue(undefined);
mockAccessService.setPrincipalPermission.mockResolvedValue(undefined); mockAccessService.setPrincipalPermission.mockResolvedValue(undefined);
}); });

View File

@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it, vi } from "vitest";
import express from "express"; import express from "express";
import request from "supertest"; import request from "supertest";
import { boardMutationGuard } from "../middleware/board-mutation-guard.js"; import { boardMutationGuard } from "../middleware/board-mutation-guard.js";
@@ -61,8 +61,21 @@ describe("boardMutationGuard", () => {
}); });
it("does not block authenticated agent mutations", async () => { it("does not block authenticated agent mutations", async () => {
const app = createApp("agent"); const middleware = boardMutationGuard();
const res = await request(app).post("/mutate").send({ ok: true }); const req = {
expect(res.status).toBe(204); method: "POST",
actor: { type: "agent", agentId: "agent-1" },
header: () => undefined,
} as any;
const res = {
status: vi.fn().mockReturnThis(),
json: vi.fn(),
} as any;
const next = vi.fn();
middleware(req, res, next);
expect(next).toHaveBeenCalledOnce();
expect(res.status).not.toHaveBeenCalled();
}); });
}); });

View File

@@ -1,8 +1,6 @@
import express from "express"; import express from "express";
import request from "supertest"; import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { companyRoutes } from "../routes/companies.js";
import { errorHandler } from "../middleware/index.js";
const mockCompanyService = vi.hoisted(() => ({ const mockCompanyService = vi.hoisted(() => ({
list: vi.fn(), list: vi.fn(),
@@ -44,7 +42,9 @@ vi.mock("../services/index.js", () => ({
logActivity: mockLogActivity, logActivity: mockLogActivity,
})); }));
function createApp(actor: Record<string, unknown>) { async function createApp(actor: Record<string, unknown>) {
const { companyRoutes } = await import("../routes/companies.js");
const { errorHandler } = await import("../middleware/index.js");
const app = express(); const app = express();
app.use(express.json()); app.use(express.json());
app.use((req, _res, next) => { app.use((req, _res, next) => {
@@ -58,6 +58,7 @@ function createApp(actor: Record<string, unknown>) {
describe("company portability routes", () => { describe("company portability routes", () => {
beforeEach(() => { beforeEach(() => {
vi.resetModules();
mockAgentService.getById.mockReset(); mockAgentService.getById.mockReset();
mockCompanyPortabilityService.exportBundle.mockReset(); mockCompanyPortabilityService.exportBundle.mockReset();
mockCompanyPortabilityService.previewExport.mockReset(); mockCompanyPortabilityService.previewExport.mockReset();
@@ -72,7 +73,7 @@ describe("company portability routes", () => {
companyId: "11111111-1111-4111-8111-111111111111", companyId: "11111111-1111-4111-8111-111111111111",
role: "engineer", role: "engineer",
}); });
const app = createApp({ const app = await createApp({
type: "agent", type: "agent",
agentId: "agent-1", agentId: "agent-1",
companyId: "11111111-1111-4111-8111-111111111111", companyId: "11111111-1111-4111-8111-111111111111",
@@ -104,7 +105,7 @@ describe("company portability routes", () => {
warnings: [], warnings: [],
paperclipExtensionPath: ".paperclip.yaml", paperclipExtensionPath: ".paperclip.yaml",
}); });
const app = createApp({ const app = await createApp({
type: "agent", type: "agent",
agentId: "agent-1", agentId: "agent-1",
companyId: "11111111-1111-4111-8111-111111111111", companyId: "11111111-1111-4111-8111-111111111111",
@@ -128,7 +129,7 @@ describe("company portability routes", () => {
companyId: "11111111-1111-4111-8111-111111111111", companyId: "11111111-1111-4111-8111-111111111111",
role: "ceo", role: "ceo",
}); });
const app = createApp({ const app = await createApp({
type: "agent", type: "agent",
agentId: "agent-1", agentId: "agent-1",
companyId: "11111111-1111-4111-8111-111111111111", companyId: "11111111-1111-4111-8111-111111111111",
@@ -151,7 +152,7 @@ describe("company portability routes", () => {
}); });
it("keeps global import preview routes board-only", async () => { it("keeps global import preview routes board-only", async () => {
const app = createApp({ const app = await createApp({
type: "agent", type: "agent",
agentId: "agent-1", agentId: "agent-1",
companyId: "11111111-1111-4111-8111-111111111111", companyId: "11111111-1111-4111-8111-111111111111",

View File

@@ -83,6 +83,10 @@ vi.mock("../services/agent-instructions.js", () => ({
agentInstructionsService: () => agentInstructionsSvc, agentInstructionsService: () => agentInstructionsSvc,
})); }));
vi.mock("../routes/org-chart-svg.js", () => ({
renderOrgChartPng: vi.fn(async () => Buffer.from("png")),
}));
const { companyPortabilityService } = await import("../services/company-portability.js"); const { companyPortabilityService } = await import("../services/company-portability.js");
function asTextFile(entry: CompanyPortabilityFileEntry | undefined) { function asTextFile(entry: CompanyPortabilityFileEntry | undefined) {
@@ -265,6 +269,7 @@ describe("company portability", () => {
assetSvc.getById.mockReset(); assetSvc.getById.mockReset();
assetSvc.getById.mockResolvedValue(null); assetSvc.getById.mockResolvedValue(null);
assetSvc.create.mockReset(); assetSvc.create.mockReset();
accessSvc.setPrincipalPermission.mockResolvedValue(undefined);
assetSvc.create.mockResolvedValue({ assetSvc.create.mockResolvedValue({
id: "asset-created", id: "asset-created",
}); });

View File

@@ -0,0 +1,340 @@
import { randomUUID } from "node:crypto";
import fs from "node:fs";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { eq } from "drizzle-orm";
import express from "express";
import request from "supertest";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
activityLog,
agentWakeupRequests,
agents,
applyPendingMigrations,
companies,
companyMemberships,
createDb,
ensurePostgresDatabase,
heartbeatRunEvents,
heartbeatRuns,
instanceSettings,
issues,
principalPermissionGrants,
projects,
routineRuns,
routines,
routineTriggers,
} from "@paperclipai/db";
import { errorHandler } from "../middleware/index.js";
import { accessService } from "../services/access.js";
vi.mock("../services/index.js", async () => {
const actual = await vi.importActual<typeof import("../services/index.js")>("../services/index.js");
const { randomUUID } = await import("node:crypto");
const { eq } = await import("drizzle-orm");
const { heartbeatRuns, issues } = await import("@paperclipai/db");
return {
...actual,
routineService: (db: any) =>
actual.routineService(db, {
heartbeat: {
wakeup: async (agentId: string, wakeupOpts: any) => {
const issueId =
(typeof wakeupOpts?.payload?.issueId === "string" && wakeupOpts.payload.issueId) ||
(typeof wakeupOpts?.contextSnapshot?.issueId === "string" && wakeupOpts.contextSnapshot.issueId) ||
null;
if (!issueId) return null;
const issue = await db
.select({ companyId: issues.companyId })
.from(issues)
.where(eq(issues.id, issueId))
.then((rows: Array<{ companyId: string }>) => rows[0] ?? null);
if (!issue) return null;
const queuedRunId = randomUUID();
await db.insert(heartbeatRuns).values({
id: queuedRunId,
companyId: issue.companyId,
agentId,
invocationSource: wakeupOpts?.source ?? "assignment",
triggerDetail: wakeupOpts?.triggerDetail ?? null,
status: "queued",
contextSnapshot: { ...(wakeupOpts?.contextSnapshot ?? {}), issueId },
});
await db
.update(issues)
.set({
executionRunId: queuedRunId,
executionLockedAt: new Date(),
})
.where(eq(issues.id, issueId));
return { id: queuedRunId };
},
},
}),
};
});
type EmbeddedPostgresInstance = {
initialise(): Promise<void>;
start(): Promise<void>;
stop(): Promise<void>;
};
type EmbeddedPostgresCtor = new (opts: {
databaseDir: string;
user: string;
password: string;
port: number;
persistent: boolean;
initdbFlags?: string[];
onLog?: (message: unknown) => void;
onError?: (message: unknown) => void;
}) => EmbeddedPostgresInstance;
async function getEmbeddedPostgresCtor(): Promise<EmbeddedPostgresCtor> {
const mod = await import("embedded-postgres");
return mod.default as EmbeddedPostgresCtor;
}
async function getAvailablePort(): Promise<number> {
return await new Promise((resolve, reject) => {
const server = net.createServer();
server.unref();
server.on("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close(() => reject(new Error("Failed to allocate test port")));
return;
}
const { port } = address;
server.close((error) => {
if (error) reject(error);
else resolve(port);
});
});
});
}
async function startTempDatabase() {
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-routines-e2e-"));
const port = await getAvailablePort();
const EmbeddedPostgres = await getEmbeddedPostgresCtor();
const instance = new EmbeddedPostgres({
databaseDir: dataDir,
user: "paperclip",
password: "paperclip",
port,
persistent: true,
initdbFlags: ["--encoding=UTF8", "--locale=C"],
onLog: () => {},
onError: () => {},
});
await instance.initialise();
await instance.start();
const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`;
await ensurePostgresDatabase(adminConnectionString, "paperclip");
const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`;
await applyPendingMigrations(connectionString);
return { connectionString, dataDir, instance };
}
describe("routine routes end-to-end", () => {
let db!: ReturnType<typeof createDb>;
let instance: EmbeddedPostgresInstance | null = null;
let dataDir = "";
beforeAll(async () => {
const started = await startTempDatabase();
db = createDb(started.connectionString);
instance = started.instance;
dataDir = started.dataDir;
}, 20_000);
afterEach(async () => {
await db.delete(activityLog);
await db.delete(routineRuns);
await db.delete(routineTriggers);
await db.delete(heartbeatRunEvents);
await db.delete(heartbeatRuns);
await db.delete(agentWakeupRequests);
await db.delete(issues);
await db.delete(principalPermissionGrants);
await db.delete(companyMemberships);
await db.delete(routines);
await db.delete(projects);
await db.delete(agents);
await db.delete(companies);
await db.delete(instanceSettings);
});
afterAll(async () => {
await instance?.stop();
if (dataDir) {
fs.rmSync(dataDir, { recursive: true, force: true });
}
});
async function createApp(actor: Record<string, unknown>) {
const { routineRoutes } = await import("../routes/routines.js");
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = actor;
next();
});
app.use("/api", routineRoutes(db));
app.use(errorHandler);
return app;
}
async function seedFixture() {
const companyId = randomUUID();
const agentId = randomUUID();
const projectId = randomUUID();
const userId = randomUUID();
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Routine Project",
status: "in_progress",
});
const access = accessService(db);
const membership = await access.ensureMembership(companyId, "user", userId, "owner", "active");
await access.setMemberPermissions(
companyId,
membership.id,
[{ permissionKey: "tasks:assign" }],
userId,
);
return { companyId, agentId, projectId, userId };
}
it("supports creating, scheduling, and manually running a routine through the API", async () => {
const { companyId, agentId, projectId, userId } = await seedFixture();
const app = await createApp({
type: "board",
userId,
source: "session",
isInstanceAdmin: false,
companyIds: [companyId],
});
const createRes = await request(app)
.post(`/api/companies/${companyId}/routines`)
.send({
projectId,
title: "Daily standup prep",
description: "Summarize blockers and open PRs",
assigneeAgentId: agentId,
priority: "high",
concurrencyPolicy: "coalesce_if_active",
catchUpPolicy: "skip_missed",
});
expect(createRes.status).toBe(201);
expect(createRes.body.title).toBe("Daily standup prep");
expect(createRes.body.assigneeAgentId).toBe(agentId);
const routineId = createRes.body.id as string;
const triggerRes = await request(app)
.post(`/api/routines/${routineId}/triggers`)
.send({
kind: "schedule",
label: "Weekday morning",
cronExpression: "0 10 * * 1-5",
timezone: "UTC",
});
expect(triggerRes.status).toBe(201);
expect(triggerRes.body.trigger.kind).toBe("schedule");
expect(triggerRes.body.trigger.enabled).toBe(true);
expect(triggerRes.body.secretMaterial).toBeNull();
const runRes = await request(app)
.post(`/api/routines/${routineId}/run`)
.send({
source: "manual",
payload: { origin: "e2e-test" },
});
expect(runRes.status).toBe(202);
expect(runRes.body.status).toBe("issue_created");
expect(runRes.body.source).toBe("manual");
expect(runRes.body.linkedIssueId).toBeTruthy();
const detailRes = await request(app).get(`/api/routines/${routineId}`);
expect(detailRes.status).toBe(200);
expect(detailRes.body.triggers).toHaveLength(1);
expect(detailRes.body.triggers[0]?.id).toBe(triggerRes.body.trigger.id);
expect(detailRes.body.recentRuns).toHaveLength(1);
expect(detailRes.body.recentRuns[0]?.id).toBe(runRes.body.id);
expect(detailRes.body.activeIssue?.id).toBe(runRes.body.linkedIssueId);
const runsRes = await request(app).get(`/api/routines/${routineId}/runs?limit=10`);
expect(runsRes.status).toBe(200);
expect(runsRes.body).toHaveLength(1);
expect(runsRes.body[0]?.id).toBe(runRes.body.id);
const [issue] = await db
.select({
id: issues.id,
originId: issues.originId,
originKind: issues.originKind,
executionRunId: issues.executionRunId,
})
.from(issues)
.where(eq(issues.id, runRes.body.linkedIssueId));
expect(issue).toMatchObject({
id: runRes.body.linkedIssueId,
originId: routineId,
originKind: "routine_execution",
});
expect(issue?.executionRunId).toBeTruthy();
const actions = await db
.select({
action: activityLog.action,
})
.from(activityLog)
.where(eq(activityLog.companyId, companyId));
expect(actions.map((entry) => entry.action)).toEqual(
expect.arrayContaining([
"routine.created",
"routine.trigger_created",
"routine.run_triggered",
]),
);
});
});

View File

@@ -0,0 +1,271 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { routineRoutes } from "../routes/routines.js";
import { errorHandler } from "../middleware/index.js";
const companyId = "22222222-2222-4222-8222-222222222222";
const agentId = "11111111-1111-4111-8111-111111111111";
const routineId = "33333333-3333-4333-8333-333333333333";
const projectId = "44444444-4444-4444-8444-444444444444";
const otherAgentId = "55555555-5555-4555-8555-555555555555";
const routine = {
id: routineId,
companyId,
projectId,
goalId: null,
parentIssueId: null,
title: "Daily routine",
description: null,
assigneeAgentId: agentId,
priority: "medium",
status: "active",
concurrencyPolicy: "coalesce_if_active",
catchUpPolicy: "skip_missed",
createdByAgentId: null,
createdByUserId: null,
updatedByAgentId: null,
updatedByUserId: null,
lastTriggeredAt: null,
lastEnqueuedAt: null,
createdAt: new Date("2026-03-20T00:00:00.000Z"),
updatedAt: new Date("2026-03-20T00:00:00.000Z"),
};
const pausedRoutine = {
...routine,
status: "paused",
};
const trigger = {
id: "66666666-6666-4666-8666-666666666666",
companyId,
routineId,
kind: "schedule",
label: "weekday",
enabled: false,
cronExpression: "0 10 * * 1-5",
timezone: "UTC",
nextRunAt: null,
lastFiredAt: null,
publicId: null,
secretId: null,
signingMode: null,
replayWindowSec: null,
lastRotatedAt: null,
lastResult: null,
createdByAgentId: null,
createdByUserId: null,
updatedByAgentId: null,
updatedByUserId: null,
createdAt: new Date("2026-03-20T00:00:00.000Z"),
updatedAt: new Date("2026-03-20T00:00:00.000Z"),
};
const mockRoutineService = vi.hoisted(() => ({
list: vi.fn(),
get: vi.fn(),
getDetail: vi.fn(),
update: vi.fn(),
create: vi.fn(),
listRuns: vi.fn(),
createTrigger: vi.fn(),
getTrigger: vi.fn(),
updateTrigger: vi.fn(),
deleteTrigger: vi.fn(),
rotateTriggerSecret: vi.fn(),
runRoutine: vi.fn(),
firePublicTrigger: vi.fn(),
}));
const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(),
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
vi.mock("../services/index.js", () => ({
accessService: () => mockAccessService,
logActivity: mockLogActivity,
routineService: () => mockRoutineService,
}));
function createApp(actor: Record<string, unknown>) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = actor;
next();
});
app.use("/api", routineRoutes({} as any));
app.use(errorHandler);
return app;
}
describe("routine routes", () => {
beforeEach(() => {
vi.clearAllMocks();
mockRoutineService.create.mockResolvedValue(routine);
mockRoutineService.get.mockResolvedValue(routine);
mockRoutineService.getTrigger.mockResolvedValue(trigger);
mockRoutineService.update.mockResolvedValue({ ...routine, assigneeAgentId: otherAgentId });
mockRoutineService.runRoutine.mockResolvedValue({
id: "run-1",
source: "manual",
status: "issue_created",
});
mockAccessService.canUser.mockResolvedValue(false);
mockLogActivity.mockResolvedValue(undefined);
});
it("requires tasks:assign permission for non-admin board routine creation", async () => {
const app = createApp({
type: "board",
userId: "board-user",
source: "session",
isInstanceAdmin: false,
companyIds: [companyId],
});
const res = await request(app)
.post(`/api/companies/${companyId}/routines`)
.send({
projectId,
title: "Daily routine",
assigneeAgentId: agentId,
});
expect(res.status).toBe(403);
expect(res.body.error).toContain("tasks:assign");
expect(mockRoutineService.create).not.toHaveBeenCalled();
});
it("requires tasks:assign permission to retarget a routine assignee", async () => {
const app = createApp({
type: "board",
userId: "board-user",
source: "session",
isInstanceAdmin: false,
companyIds: [companyId],
});
const res = await request(app)
.patch(`/api/routines/${routineId}`)
.send({
assigneeAgentId: otherAgentId,
});
expect(res.status).toBe(403);
expect(res.body.error).toContain("tasks:assign");
expect(mockRoutineService.update).not.toHaveBeenCalled();
});
it("requires tasks:assign permission to reactivate a routine", async () => {
mockRoutineService.get.mockResolvedValue(pausedRoutine);
const app = createApp({
type: "board",
userId: "board-user",
source: "session",
isInstanceAdmin: false,
companyIds: [companyId],
});
const res = await request(app)
.patch(`/api/routines/${routineId}`)
.send({
status: "active",
});
expect(res.status).toBe(403);
expect(res.body.error).toContain("tasks:assign");
expect(mockRoutineService.update).not.toHaveBeenCalled();
});
it("requires tasks:assign permission to create a trigger", async () => {
const app = createApp({
type: "board",
userId: "board-user",
source: "session",
isInstanceAdmin: false,
companyIds: [companyId],
});
const res = await request(app)
.post(`/api/routines/${routineId}/triggers`)
.send({
kind: "schedule",
cronExpression: "0 10 * * *",
timezone: "UTC",
});
expect(res.status).toBe(403);
expect(res.body.error).toContain("tasks:assign");
expect(mockRoutineService.createTrigger).not.toHaveBeenCalled();
});
it("requires tasks:assign permission to update a trigger", async () => {
const app = createApp({
type: "board",
userId: "board-user",
source: "session",
isInstanceAdmin: false,
companyIds: [companyId],
});
const res = await request(app)
.patch(`/api/routine-triggers/${trigger.id}`)
.send({
enabled: true,
});
expect(res.status).toBe(403);
expect(res.body.error).toContain("tasks:assign");
expect(mockRoutineService.updateTrigger).not.toHaveBeenCalled();
});
it("requires tasks:assign permission to manually run a routine", async () => {
const app = createApp({
type: "board",
userId: "board-user",
source: "session",
isInstanceAdmin: false,
companyIds: [companyId],
});
const res = await request(app)
.post(`/api/routines/${routineId}/run`)
.send({});
expect(res.status).toBe(403);
expect(res.body.error).toContain("tasks:assign");
expect(mockRoutineService.runRoutine).not.toHaveBeenCalled();
});
it("allows routine creation when the board user has tasks:assign", async () => {
mockAccessService.canUser.mockResolvedValue(true);
const app = createApp({
type: "board",
userId: "board-user",
source: "session",
isInstanceAdmin: false,
companyIds: [companyId],
});
const res = await request(app)
.post(`/api/companies/${companyId}/routines`)
.send({
projectId,
title: "Daily routine",
assigneeAgentId: agentId,
});
expect(res.status).toBe(201);
expect(mockRoutineService.create).toHaveBeenCalledWith(companyId, expect.objectContaining({
projectId,
title: "Daily routine",
assigneeAgentId: agentId,
}), {
agentId: null,
userId: "board-user",
});
});
});

View File

@@ -0,0 +1,488 @@
import { createHmac, randomUUID } from "node:crypto";
import fs from "node:fs";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
activityLog,
agents,
applyPendingMigrations,
companies,
companySecrets,
companySecretVersions,
createDb,
ensurePostgresDatabase,
heartbeatRuns,
issues,
projects,
routineRuns,
routines,
routineTriggers,
} from "@paperclipai/db";
import { issueService } from "../services/issues.ts";
import { routineService } from "../services/routines.ts";
type EmbeddedPostgresInstance = {
initialise(): Promise<void>;
start(): Promise<void>;
stop(): Promise<void>;
};
type EmbeddedPostgresCtor = new (opts: {
databaseDir: string;
user: string;
password: string;
port: number;
persistent: boolean;
initdbFlags?: string[];
onLog?: (message: unknown) => void;
onError?: (message: unknown) => void;
}) => EmbeddedPostgresInstance;
async function getEmbeddedPostgresCtor(): Promise<EmbeddedPostgresCtor> {
const mod = await import("embedded-postgres");
return mod.default as EmbeddedPostgresCtor;
}
async function getAvailablePort(): Promise<number> {
return await new Promise((resolve, reject) => {
const server = net.createServer();
server.unref();
server.on("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close(() => reject(new Error("Failed to allocate test port")));
return;
}
const { port } = address;
server.close((error) => {
if (error) reject(error);
else resolve(port);
});
});
});
}
async function startTempDatabase() {
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-routines-service-"));
const port = await getAvailablePort();
const EmbeddedPostgres = await getEmbeddedPostgresCtor();
const instance = new EmbeddedPostgres({
databaseDir: dataDir,
user: "paperclip",
password: "paperclip",
port,
persistent: true,
initdbFlags: ["--encoding=UTF8", "--locale=C"],
onLog: () => {},
onError: () => {},
});
await instance.initialise();
await instance.start();
const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`;
await ensurePostgresDatabase(adminConnectionString, "paperclip");
const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`;
await applyPendingMigrations(connectionString);
return { connectionString, dataDir, instance };
}
describe("routine service live-execution coalescing", () => {
let db!: ReturnType<typeof createDb>;
let instance: EmbeddedPostgresInstance | null = null;
let dataDir = "";
beforeAll(async () => {
const started = await startTempDatabase();
db = createDb(started.connectionString);
instance = started.instance;
dataDir = started.dataDir;
}, 20_000);
afterEach(async () => {
await db.delete(activityLog);
await db.delete(routineRuns);
await db.delete(routineTriggers);
await db.delete(routines);
await db.delete(companySecretVersions);
await db.delete(companySecrets);
await db.delete(heartbeatRuns);
await db.delete(issues);
await db.delete(projects);
await db.delete(agents);
await db.delete(companies);
});
afterAll(async () => {
await instance?.stop();
if (dataDir) {
fs.rmSync(dataDir, { recursive: true, force: true });
}
});
async function seedFixture(opts?: {
wakeup?: (
agentId: string,
wakeupOpts: {
source?: string;
triggerDetail?: string;
reason?: string | null;
payload?: Record<string, unknown> | null;
requestedByActorType?: "user" | "agent" | "system";
requestedByActorId?: string | null;
contextSnapshot?: Record<string, unknown>;
},
) => Promise<unknown>;
}) {
const companyId = randomUUID();
const agentId = randomUUID();
const projectId = randomUUID();
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
const wakeups: Array<{
agentId: string;
opts: {
source?: string;
triggerDetail?: string;
reason?: string | null;
payload?: Record<string, unknown> | null;
requestedByActorType?: "user" | "agent" | "system";
requestedByActorId?: string | null;
contextSnapshot?: Record<string, unknown>;
};
}> = [];
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Routines",
status: "in_progress",
});
const svc = routineService(db, {
heartbeat: {
wakeup: async (wakeupAgentId, wakeupOpts) => {
wakeups.push({ agentId: wakeupAgentId, opts: wakeupOpts });
if (opts?.wakeup) return opts.wakeup(wakeupAgentId, wakeupOpts);
const issueId =
(typeof wakeupOpts.payload?.issueId === "string" && wakeupOpts.payload.issueId) ||
(typeof wakeupOpts.contextSnapshot?.issueId === "string" && wakeupOpts.contextSnapshot.issueId) ||
null;
if (!issueId) return null;
const queuedRunId = randomUUID();
await db.insert(heartbeatRuns).values({
id: queuedRunId,
companyId,
agentId: wakeupAgentId,
invocationSource: wakeupOpts.source ?? "assignment",
triggerDetail: wakeupOpts.triggerDetail ?? null,
status: "queued",
contextSnapshot: { ...(wakeupOpts.contextSnapshot ?? {}), issueId },
});
await db
.update(issues)
.set({
executionRunId: queuedRunId,
executionLockedAt: new Date(),
})
.where(eq(issues.id, issueId));
return { id: queuedRunId };
},
},
});
const issueSvc = issueService(db);
const routine = await svc.create(
companyId,
{
projectId,
goalId: null,
parentIssueId: null,
title: "ascii frog",
description: "Run the frog routine",
assigneeAgentId: agentId,
priority: "medium",
status: "active",
concurrencyPolicy: "coalesce_if_active",
catchUpPolicy: "skip_missed",
},
{},
);
return { companyId, agentId, issueSvc, projectId, routine, svc, wakeups };
}
it("creates a fresh execution issue when the previous routine issue is open but idle", async () => {
const { companyId, issueSvc, routine, svc } = await seedFixture();
const previousRunId = randomUUID();
const previousIssue = await issueSvc.create(companyId, {
projectId: routine.projectId,
title: routine.title,
description: routine.description,
status: "todo",
priority: routine.priority,
assigneeAgentId: routine.assigneeAgentId,
originKind: "routine_execution",
originId: routine.id,
originRunId: previousRunId,
});
await db.insert(routineRuns).values({
id: previousRunId,
companyId,
routineId: routine.id,
triggerId: null,
source: "manual",
status: "issue_created",
triggeredAt: new Date("2026-03-20T12:00:00.000Z"),
linkedIssueId: previousIssue.id,
completedAt: new Date("2026-03-20T12:00:00.000Z"),
});
const detailBefore = await svc.getDetail(routine.id);
expect(detailBefore?.activeIssue).toBeNull();
const run = await svc.runRoutine(routine.id, { source: "manual" });
expect(run.status).toBe("issue_created");
expect(run.linkedIssueId).not.toBe(previousIssue.id);
const routineIssues = await db
.select({
id: issues.id,
originRunId: issues.originRunId,
})
.from(issues)
.where(eq(issues.originId, routine.id));
expect(routineIssues).toHaveLength(2);
expect(routineIssues.map((issue) => issue.id)).toContain(previousIssue.id);
expect(routineIssues.map((issue) => issue.id)).toContain(run.linkedIssueId);
});
it("wakes the assignee when a routine creates a fresh execution issue", async () => {
const { agentId, routine, svc, wakeups } = await seedFixture();
const run = await svc.runRoutine(routine.id, { source: "manual" });
expect(run.status).toBe("issue_created");
expect(run.linkedIssueId).toBeTruthy();
expect(wakeups).toEqual([
{
agentId,
opts: {
source: "assignment",
triggerDetail: "system",
reason: "issue_assigned",
payload: { issueId: run.linkedIssueId, mutation: "create" },
requestedByActorType: undefined,
requestedByActorId: null,
contextSnapshot: { issueId: run.linkedIssueId, source: "routine.dispatch" },
},
},
]);
});
it("waits for the assignee wakeup to be queued before returning the routine run", async () => {
let wakeupResolved = false;
const { routine, svc } = await seedFixture({
wakeup: async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
wakeupResolved = true;
return null;
},
});
const run = await svc.runRoutine(routine.id, { source: "manual" });
expect(run.status).toBe("issue_created");
expect(wakeupResolved).toBe(true);
});
it("coalesces only when the existing routine issue has a live execution run", async () => {
const { agentId, companyId, issueSvc, routine, svc } = await seedFixture();
const previousRunId = randomUUID();
const liveHeartbeatRunId = randomUUID();
const previousIssue = await issueSvc.create(companyId, {
projectId: routine.projectId,
title: routine.title,
description: routine.description,
status: "in_progress",
priority: routine.priority,
assigneeAgentId: routine.assigneeAgentId,
originKind: "routine_execution",
originId: routine.id,
originRunId: previousRunId,
});
await db.insert(routineRuns).values({
id: previousRunId,
companyId,
routineId: routine.id,
triggerId: null,
source: "manual",
status: "issue_created",
triggeredAt: new Date("2026-03-20T12:00:00.000Z"),
linkedIssueId: previousIssue.id,
});
await db.insert(heartbeatRuns).values({
id: liveHeartbeatRunId,
companyId,
agentId,
invocationSource: "assignment",
triggerDetail: "system",
status: "running",
contextSnapshot: { issueId: previousIssue.id },
startedAt: new Date("2026-03-20T12:01:00.000Z"),
});
await db
.update(issues)
.set({
checkoutRunId: liveHeartbeatRunId,
executionRunId: liveHeartbeatRunId,
executionLockedAt: new Date("2026-03-20T12:01:00.000Z"),
})
.where(eq(issues.id, previousIssue.id));
const detailBefore = await svc.getDetail(routine.id);
expect(detailBefore?.activeIssue?.id).toBe(previousIssue.id);
const run = await svc.runRoutine(routine.id, { source: "manual" });
expect(run.status).toBe("coalesced");
expect(run.linkedIssueId).toBe(previousIssue.id);
expect(run.coalescedIntoRunId).toBe(previousRunId);
const routineIssues = await db
.select({ id: issues.id })
.from(issues)
.where(eq(issues.originId, routine.id));
expect(routineIssues).toHaveLength(1);
expect(routineIssues[0]?.id).toBe(previousIssue.id);
});
it("serializes concurrent dispatches until the first execution issue is linked to a queued run", async () => {
const { routine, svc } = await seedFixture({
wakeup: async (wakeupAgentId, wakeupOpts) => {
const issueId =
(typeof wakeupOpts.payload?.issueId === "string" && wakeupOpts.payload.issueId) ||
(typeof wakeupOpts.contextSnapshot?.issueId === "string" && wakeupOpts.contextSnapshot.issueId) ||
null;
await new Promise((resolve) => setTimeout(resolve, 25));
if (!issueId) return null;
const queuedRunId = randomUUID();
await db.insert(heartbeatRuns).values({
id: queuedRunId,
companyId: routine.companyId,
agentId: wakeupAgentId,
invocationSource: wakeupOpts.source ?? "assignment",
triggerDetail: wakeupOpts.triggerDetail ?? null,
status: "queued",
contextSnapshot: { ...(wakeupOpts.contextSnapshot ?? {}), issueId },
});
await db
.update(issues)
.set({
executionRunId: queuedRunId,
executionLockedAt: new Date(),
})
.where(eq(issues.id, issueId));
return { id: queuedRunId };
},
});
const [first, second] = await Promise.all([
svc.runRoutine(routine.id, { source: "manual" }),
svc.runRoutine(routine.id, { source: "manual" }),
]);
expect([first.status, second.status].sort()).toEqual(["coalesced", "issue_created"]);
expect(first.linkedIssueId).toBeTruthy();
expect(second.linkedIssueId).toBeTruthy();
expect(first.linkedIssueId).toBe(second.linkedIssueId);
const routineIssues = await db
.select({ id: issues.id })
.from(issues)
.where(eq(issues.originId, routine.id));
expect(routineIssues).toHaveLength(1);
});
it("fails the run and cleans up the execution issue when wakeup queueing fails", async () => {
const { routine, svc } = await seedFixture({
wakeup: async () => {
throw new Error("queue unavailable");
},
});
const run = await svc.runRoutine(routine.id, { source: "manual" });
expect(run.status).toBe("failed");
expect(run.failureReason).toContain("queue unavailable");
expect(run.linkedIssueId).toBeNull();
const routineIssues = await db
.select({ id: issues.id })
.from(issues)
.where(eq(issues.originId, routine.id));
expect(routineIssues).toHaveLength(0);
});
it("accepts standard second-precision webhook timestamps for HMAC triggers", async () => {
const { routine, svc } = await seedFixture();
const { trigger, secretMaterial } = await svc.createTrigger(
routine.id,
{
kind: "webhook",
signingMode: "hmac_sha256",
replayWindowSec: 300,
},
{},
);
expect(trigger.publicId).toBeTruthy();
expect(secretMaterial?.webhookSecret).toBeTruthy();
const payload = { ok: true };
const rawBody = Buffer.from(JSON.stringify(payload));
const timestampSeconds = String(Math.floor(Date.now() / 1000));
const signature = `sha256=${createHmac("sha256", secretMaterial!.webhookSecret)
.update(`${timestampSeconds}.`)
.update(rawBody)
.digest("hex")}`;
const run = await svc.firePublicTrigger(trigger.publicId!, {
signatureHeader: signature,
timestampHeader: timestampSeconds,
rawBody,
payload,
});
expect(run.source).toBe("webhook");
expect(run.status).toBe("issue_created");
expect(run.linkedIssueId).toBeTruthy();
});
});

View File

@@ -15,6 +15,7 @@ import { companySkillRoutes } from "./routes/company-skills.js";
import { agentRoutes } from "./routes/agents.js"; import { agentRoutes } from "./routes/agents.js";
import { projectRoutes } from "./routes/projects.js"; import { projectRoutes } from "./routes/projects.js";
import { issueRoutes } from "./routes/issues.js"; import { issueRoutes } from "./routes/issues.js";
import { routineRoutes } from "./routes/routines.js";
import { executionWorkspaceRoutes } from "./routes/execution-workspaces.js"; import { executionWorkspaceRoutes } from "./routes/execution-workspaces.js";
import { goalRoutes } from "./routes/goals.js"; import { goalRoutes } from "./routes/goals.js";
import { approvalRoutes } from "./routes/approvals.js"; import { approvalRoutes } from "./routes/approvals.js";
@@ -142,6 +143,7 @@ export async function createApp(
api.use(assetRoutes(db, opts.storageService)); api.use(assetRoutes(db, opts.storageService));
api.use(projectRoutes(db)); api.use(projectRoutes(db));
api.use(issueRoutes(db, opts.storageService)); api.use(issueRoutes(db, opts.storageService));
api.use(routineRoutes(db));
api.use(executionWorkspaceRoutes(db)); api.use(executionWorkspaceRoutes(db));
api.use(goalRoutes(db)); api.use(goalRoutes(db));
api.use(approvalRoutes(db)); api.use(approvalRoutes(db));

View File

@@ -26,7 +26,7 @@ import { createApp } from "./app.js";
import { loadConfig } from "./config.js"; import { loadConfig } from "./config.js";
import { logger } from "./middleware/logger.js"; import { logger } from "./middleware/logger.js";
import { setupLiveEventsWebSocketServer } from "./realtime/live-events-ws.js"; import { setupLiveEventsWebSocketServer } from "./realtime/live-events-ws.js";
import { heartbeatService, reconcilePersistedRuntimeServicesOnStartup } from "./services/index.js"; import { heartbeatService, reconcilePersistedRuntimeServicesOnStartup, routineService } from "./services/index.js";
import { createStorageServiceFromConfig } from "./storage/index.js"; import { createStorageServiceFromConfig } from "./storage/index.js";
import { printStartupBanner } from "./startup-banner.js"; import { printStartupBanner } from "./startup-banner.js";
import { getBoardClaimWarningUrl, initializeBoardClaimChallenge } from "./board-claim.js"; import { getBoardClaimWarningUrl, initializeBoardClaimChallenge } from "./board-claim.js";
@@ -526,6 +526,7 @@ export async function startServer(): Promise<StartedServer> {
if (config.heartbeatSchedulerEnabled) { if (config.heartbeatSchedulerEnabled) {
const heartbeat = heartbeatService(db as any); const heartbeat = heartbeatService(db as any);
const routines = routineService(db as any);
// Reap orphaned running runs at startup while in-memory execution state is empty, // Reap orphaned running runs at startup while in-memory execution state is empty,
// then resume any persisted queued runs that were waiting on the previous process. // then resume any persisted queued runs that were waiting on the previous process.
@@ -546,6 +547,17 @@ export async function startServer(): Promise<StartedServer> {
.catch((err) => { .catch((err) => {
logger.error({ err }, "heartbeat timer tick failed"); logger.error({ err }, "heartbeat timer tick failed");
}); });
void routines
.tickScheduledTriggers(new Date())
.then((result) => {
if (result.triggered > 0) {
logger.info({ ...result }, "routine scheduler tick enqueued runs");
}
})
.catch((err) => {
logger.error({ err }, "routine scheduler tick failed");
});
// Periodically reap orphaned runs (5-min staleness threshold) and make sure // Periodically reap orphaned runs (5-min staleness threshold) and make sure
// persisted queued work is still being driven forward. // persisted queued work is still being driven forward.

View File

@@ -4,6 +4,7 @@ export { companySkillRoutes } from "./company-skills.js";
export { agentRoutes } from "./agents.js"; export { agentRoutes } from "./agents.js";
export { projectRoutes } from "./projects.js"; export { projectRoutes } from "./projects.js";
export { issueRoutes } from "./issues.js"; export { issueRoutes } from "./issues.js";
export { routineRoutes } from "./routines.js";
export { goalRoutes } from "./goals.js"; export { goalRoutes } from "./goals.js";
export { approvalRoutes } from "./approvals.js"; export { approvalRoutes } from "./approvals.js";
export { secretRoutes } from "./secrets.js"; export { secretRoutes } from "./secrets.js";

View File

@@ -27,6 +27,7 @@ import {
documentService, documentService,
logActivity, logActivity,
projectService, projectService,
routineService,
workProductService, workProductService,
} from "../services/index.js"; } from "../services/index.js";
import { logger } from "../middleware/logger.js"; import { logger } from "../middleware/logger.js";
@@ -34,6 +35,7 @@ import { forbidden, HttpError, unauthorized } from "../errors.js";
import { assertCompanyAccess, getActorInfo } from "./authz.js"; import { assertCompanyAccess, getActorInfo } from "./authz.js";
import { shouldWakeAssigneeOnCheckout } from "./issues-checkout-wakeup.js"; import { shouldWakeAssigneeOnCheckout } from "./issues-checkout-wakeup.js";
import { isAllowedContentType, MAX_ATTACHMENT_BYTES } from "../attachment-types.js"; import { isAllowedContentType, MAX_ATTACHMENT_BYTES } from "../attachment-types.js";
import { queueIssueAssignmentWakeup } from "../services/issue-assignment-wakeup.js";
const MAX_ISSUE_COMMENT_LIMIT = 500; const MAX_ISSUE_COMMENT_LIMIT = 500;
@@ -49,6 +51,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
const executionWorkspacesSvc = executionWorkspaceService(db); const executionWorkspacesSvc = executionWorkspaceService(db);
const workProductsSvc = workProductService(db); const workProductsSvc = workProductService(db);
const documentsSvc = documentService(db); const documentsSvc = documentService(db);
const routinesSvc = routineService(db);
const upload = multer({ const upload = multer({
storage: multer.memoryStorage(), storage: multer.memoryStorage(),
limits: { fileSize: MAX_ATTACHMENT_BYTES, files: 1 }, limits: { fileSize: MAX_ATTACHMENT_BYTES, files: 1 },
@@ -236,6 +239,10 @@ export function issueRoutes(db: Db, storage: StorageService) {
projectId: req.query.projectId as string | undefined, projectId: req.query.projectId as string | undefined,
parentId: req.query.parentId as string | undefined, parentId: req.query.parentId as string | undefined,
labelId: req.query.labelId as string | undefined, labelId: req.query.labelId as string | undefined,
originKind: req.query.originKind as string | undefined,
originId: req.query.originId as string | undefined,
includeRoutineExecutions:
req.query.includeRoutineExecutions === "true" || req.query.includeRoutineExecutions === "1",
q: req.query.q as string | undefined, q: req.query.q as string | undefined,
}); });
res.json(result); res.json(result);
@@ -775,19 +782,15 @@ export function issueRoutes(db: Db, storage: StorageService) {
details: { title: issue.title, identifier: issue.identifier }, details: { title: issue.title, identifier: issue.identifier },
}); });
if (issue.assigneeAgentId && issue.status !== "backlog") { void queueIssueAssignmentWakeup({
void heartbeat heartbeat,
.wakeup(issue.assigneeAgentId, { issue,
source: "assignment", reason: "issue_assigned",
triggerDetail: "system", mutation: "create",
reason: "issue_assigned", contextSource: "issue.create",
payload: { issueId: issue.id, mutation: "create" }, requestedByActorType: actor.actorType,
requestedByActorType: actor.actorType, requestedByActorId: actor.actorId,
requestedByActorId: actor.actorId, });
contextSnapshot: { issueId: issue.id, source: "issue.create" },
})
.catch((err) => logger.warn({ err, issueId: issue.id }, "failed to wake assignee on issue create"));
}
res.status(201).json(issue); res.status(201).json(issue);
}); });
@@ -856,6 +859,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
res.status(404).json({ error: "Issue not found" }); res.status(404).json({ error: "Issue not found" });
return; return;
} }
await routinesSvc.syncRunStatusForIssue(issue.id);
if (actor.runId) { if (actor.runId) {
await heartbeat.reportRunActivity(actor.runId).catch((err) => await heartbeat.reportRunActivity(actor.runId).catch((err) =>

View File

@@ -0,0 +1,299 @@
import { Router, type Request } from "express";
import type { Db } from "@paperclipai/db";
import {
createRoutineSchema,
createRoutineTriggerSchema,
rotateRoutineTriggerSecretSchema,
runRoutineSchema,
updateRoutineSchema,
updateRoutineTriggerSchema,
} from "@paperclipai/shared";
import { validate } from "../middleware/validate.js";
import { accessService, logActivity, routineService } from "../services/index.js";
import { assertCompanyAccess, getActorInfo } from "./authz.js";
import { forbidden, unauthorized } from "../errors.js";
export function routineRoutes(db: Db) {
const router = Router();
const svc = routineService(db);
const access = accessService(db);
async function assertBoardCanAssignTasks(req: Request, companyId: string) {
assertCompanyAccess(req, companyId);
if (req.actor.type !== "board") return;
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return;
const allowed = await access.canUser(companyId, req.actor.userId, "tasks:assign");
if (!allowed) {
throw forbidden("Missing permission: tasks:assign");
}
}
function assertCanManageCompanyRoutine(req: Request, companyId: string, assigneeAgentId?: string | null) {
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) {
throw forbidden("Agents can only manage routines assigned to themselves");
}
}
async function assertCanManageExistingRoutine(req: Request, routineId: string) {
const routine = await svc.get(routineId);
if (!routine) return null;
assertCompanyAccess(req, routine.companyId);
if (req.actor.type === "board") return routine;
if (req.actor.type !== "agent" || !req.actor.agentId) throw unauthorized();
if (routine.assigneeAgentId !== req.actor.agentId) {
throw forbidden("Agents can only manage routines assigned to themselves");
}
return routine;
}
router.get("/companies/:companyId/routines", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const result = await svc.list(companyId);
res.json(result);
});
router.post("/companies/:companyId/routines", validate(createRoutineSchema), async (req, res) => {
const companyId = req.params.companyId as string;
await assertBoardCanAssignTasks(req, companyId);
assertCanManageCompanyRoutine(req, companyId, req.body.assigneeAgentId);
const created = await svc.create(companyId, req.body, {
agentId: req.actor.type === "agent" ? req.actor.agentId : null,
userId: req.actor.type === "board" ? req.actor.userId ?? "board" : null,
});
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "routine.created",
entityType: "routine",
entityId: created.id,
details: { title: created.title, assigneeAgentId: created.assigneeAgentId },
});
res.status(201).json(created);
});
router.get("/routines/:id", async (req, res) => {
const detail = await svc.getDetail(req.params.id as string);
if (!detail) {
res.status(404).json({ error: "Routine not found" });
return;
}
assertCompanyAccess(req, detail.companyId);
res.json(detail);
});
router.patch("/routines/:id", validate(updateRoutineSchema), async (req, res) => {
const routine = await assertCanManageExistingRoutine(req, req.params.id as string);
if (!routine) {
res.status(404).json({ error: "Routine not found" });
return;
}
const assigneeWillChange =
req.body.assigneeAgentId !== undefined &&
req.body.assigneeAgentId !== routine.assigneeAgentId;
if (assigneeWillChange) {
await assertBoardCanAssignTasks(req, routine.companyId);
}
const statusWillActivate =
req.body.status !== undefined &&
req.body.status === "active" &&
routine.status !== "active";
if (statusWillActivate) {
await assertBoardCanAssignTasks(req, routine.companyId);
}
if (req.actor.type === "agent" && req.body.assigneeAgentId && req.body.assigneeAgentId !== req.actor.agentId) {
throw forbidden("Agents can only assign routines to themselves");
}
const updated = await svc.update(routine.id, req.body, {
agentId: req.actor.type === "agent" ? req.actor.agentId : null,
userId: req.actor.type === "board" ? req.actor.userId ?? "board" : null,
});
const actor = getActorInfo(req);
await logActivity(db, {
companyId: routine.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "routine.updated",
entityType: "routine",
entityId: routine.id,
details: { title: updated?.title ?? routine.title },
});
res.json(updated);
});
router.get("/routines/:id/runs", async (req, res) => {
const routine = await svc.get(req.params.id as string);
if (!routine) {
res.status(404).json({ error: "Routine not found" });
return;
}
assertCompanyAccess(req, routine.companyId);
const limit = Number(req.query.limit ?? 50);
const result = await svc.listRuns(routine.id, Number.isFinite(limit) ? limit : 50);
res.json(result);
});
router.post("/routines/:id/triggers", validate(createRoutineTriggerSchema), async (req, res) => {
const routine = await assertCanManageExistingRoutine(req, req.params.id as string);
if (!routine) {
res.status(404).json({ error: "Routine not found" });
return;
}
await assertBoardCanAssignTasks(req, routine.companyId);
const created = await svc.createTrigger(routine.id, req.body, {
agentId: req.actor.type === "agent" ? req.actor.agentId : null,
userId: req.actor.type === "board" ? req.actor.userId ?? "board" : null,
});
const actor = getActorInfo(req);
await logActivity(db, {
companyId: routine.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "routine.trigger_created",
entityType: "routine_trigger",
entityId: created.trigger.id,
details: { routineId: routine.id, kind: created.trigger.kind },
});
res.status(201).json(created);
});
router.patch("/routine-triggers/:id", validate(updateRoutineTriggerSchema), async (req, res) => {
const trigger = await svc.getTrigger(req.params.id as string);
if (!trigger) {
res.status(404).json({ error: "Routine trigger not found" });
return;
}
const routine = await assertCanManageExistingRoutine(req, trigger.routineId);
if (!routine) {
res.status(404).json({ error: "Routine not found" });
return;
}
await assertBoardCanAssignTasks(req, routine.companyId);
const updated = await svc.updateTrigger(trigger.id, req.body, {
agentId: req.actor.type === "agent" ? req.actor.agentId : null,
userId: req.actor.type === "board" ? req.actor.userId ?? "board" : null,
});
const actor = getActorInfo(req);
await logActivity(db, {
companyId: routine.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "routine.trigger_updated",
entityType: "routine_trigger",
entityId: trigger.id,
details: { routineId: routine.id, kind: updated?.kind ?? trigger.kind },
});
res.json(updated);
});
router.delete("/routine-triggers/:id", async (req, res) => {
const trigger = await svc.getTrigger(req.params.id as string);
if (!trigger) {
res.status(404).json({ error: "Routine trigger not found" });
return;
}
const routine = await assertCanManageExistingRoutine(req, trigger.routineId);
if (!routine) {
res.status(404).json({ error: "Routine not found" });
return;
}
await svc.deleteTrigger(trigger.id);
const actor = getActorInfo(req);
await logActivity(db, {
companyId: routine.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "routine.trigger_deleted",
entityType: "routine_trigger",
entityId: trigger.id,
details: { routineId: routine.id, kind: trigger.kind },
});
res.status(204).end();
});
router.post(
"/routine-triggers/:id/rotate-secret",
validate(rotateRoutineTriggerSecretSchema),
async (req, res) => {
const trigger = await svc.getTrigger(req.params.id as string);
if (!trigger) {
res.status(404).json({ error: "Routine trigger not found" });
return;
}
const routine = await assertCanManageExistingRoutine(req, trigger.routineId);
if (!routine) {
res.status(404).json({ error: "Routine not found" });
return;
}
const rotated = await svc.rotateTriggerSecret(trigger.id, {
agentId: req.actor.type === "agent" ? req.actor.agentId : null,
userId: req.actor.type === "board" ? req.actor.userId ?? "board" : null,
});
const actor = getActorInfo(req);
await logActivity(db, {
companyId: routine.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "routine.trigger_secret_rotated",
entityType: "routine_trigger",
entityId: trigger.id,
details: { routineId: routine.id },
});
res.json(rotated);
},
);
router.post("/routines/:id/run", validate(runRoutineSchema), async (req, res) => {
const routine = await assertCanManageExistingRoutine(req, req.params.id as string);
if (!routine) {
res.status(404).json({ error: "Routine not found" });
return;
}
await assertBoardCanAssignTasks(req, routine.companyId);
const run = await svc.runRoutine(routine.id, req.body);
const actor = getActorInfo(req);
await logActivity(db, {
companyId: routine.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "routine.run_triggered",
entityType: "routine_run",
entityId: run.id,
details: { routineId: routine.id, source: run.source, status: run.status },
});
res.status(202).json(run);
});
router.post("/routine-triggers/public/:publicId/fire", async (req, res) => {
const result = await svc.firePublicTrigger(req.params.publicId as string, {
authorizationHeader: req.header("authorization"),
signatureHeader: req.header("x-paperclip-signature"),
timestampHeader: req.header("x-paperclip-timestamp"),
idempotencyKey: req.header("idempotency-key"),
rawBody: (req as { rawBody?: Buffer }).rawBody ?? null,
payload: typeof req.body === "object" && req.body !== null ? req.body as Record<string, unknown> : null,
});
res.status(202).json(result);
});
return router;
}

View File

@@ -3123,15 +3123,6 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
} }
let created = await agents.create(targetCompany.id, patch); let created = await agents.create(targetCompany.id, patch);
try {
const materialized = await instructions.materializeManagedBundle(created, bundleFiles, {
clearLegacyPromptTemplate: true,
replaceExisting: true,
});
created = await agents.update(created.id, { adapterConfig: materialized.adapterConfig }) ?? created;
} catch (err) {
warnings.push(`Failed to materialize instructions bundle for ${manifestAgent.slug}: ${err instanceof Error ? err.message : String(err)}`);
}
await access.ensureMembership(targetCompany.id, "agent", created.id, "member", "active"); await access.ensureMembership(targetCompany.id, "agent", created.id, "member", "active");
await access.setPrincipalPermission( await access.setPrincipalPermission(
targetCompany.id, targetCompany.id,
@@ -3141,6 +3132,15 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
true, true,
actorUserId ?? null, actorUserId ?? null,
); );
try {
const materialized = await instructions.materializeManagedBundle(created, bundleFiles, {
clearLegacyPromptTemplate: true,
replaceExisting: true,
});
created = await agents.update(created.id, { adapterConfig: materialized.adapterConfig }) ?? created;
} catch (err) {
warnings.push(`Failed to materialize instructions bundle for ${manifestAgent.slug}: ${err instanceof Error ? err.message : String(err)}`);
}
importedSlugToAgentId.set(planAgent.slug, created.id); importedSlugToAgentId.set(planAgent.slug, created.id);
existingSlugToAgentId.set(normalizeAgentUrlKey(created.name) ?? created.id, created.id); existingSlugToAgentId.set(normalizeAgentUrlKey(created.name) ?? created.id, created.id);
resultAgents.push({ resultAgents.push({

View File

@@ -12,6 +12,7 @@ export { activityService, type ActivityFilters } from "./activity.js";
export { approvalService } from "./approvals.js"; export { approvalService } from "./approvals.js";
export { budgetService } from "./budgets.js"; export { budgetService } from "./budgets.js";
export { secretService } from "./secrets.js"; export { secretService } from "./secrets.js";
export { routineService } from "./routines.js";
export { costService } from "./costs.js"; export { costService } from "./costs.js";
export { financeService } from "./finance.js"; export { financeService } from "./finance.js";
export { heartbeatService } from "./heartbeat.js"; export { heartbeatService } from "./heartbeat.js";

View File

@@ -0,0 +1,48 @@
import { logger } from "../middleware/logger.js";
type WakeupTriggerDetail = "manual" | "ping" | "callback" | "system";
type WakeupSource = "timer" | "assignment" | "on_demand" | "automation";
export interface IssueAssignmentWakeupDeps {
wakeup: (
agentId: string,
opts: {
source?: WakeupSource;
triggerDetail?: WakeupTriggerDetail;
reason?: string | null;
payload?: Record<string, unknown> | null;
requestedByActorType?: "user" | "agent" | "system";
requestedByActorId?: string | null;
contextSnapshot?: Record<string, unknown>;
},
) => Promise<unknown>;
}
export function queueIssueAssignmentWakeup(input: {
heartbeat: IssueAssignmentWakeupDeps;
issue: { id: string; assigneeAgentId: string | null; status: string };
reason: string;
mutation: string;
contextSource: string;
requestedByActorType?: "user" | "agent" | "system";
requestedByActorId?: string | null;
rethrowOnError?: boolean;
}) {
if (!input.issue.assigneeAgentId || input.issue.status === "backlog") return;
return input.heartbeat
.wakeup(input.issue.assigneeAgentId, {
source: "assignment",
triggerDetail: "system",
reason: input.reason,
payload: { issueId: input.issue.id, mutation: input.mutation },
requestedByActorType: input.requestedByActorType,
requestedByActorId: input.requestedByActorId ?? null,
contextSnapshot: { issueId: input.issue.id, source: input.contextSource },
})
.catch((err) => {
logger.warn({ err, issueId: input.issue.id }, "failed to wake assignee on issue assignment");
if (input.rethrowOnError) throw err;
return null;
});
}

View File

@@ -1,4 +1,4 @@
import { and, asc, desc, eq, inArray, isNull, or, sql } from "drizzle-orm"; import { and, asc, desc, eq, inArray, isNull, ne, or, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db"; import type { Db } from "@paperclipai/db";
import { import {
agents, agents,
@@ -68,6 +68,9 @@ export interface IssueFilters {
projectId?: string; projectId?: string;
parentId?: string; parentId?: string;
labelId?: string; labelId?: string;
originKind?: string;
originId?: string;
includeRoutineExecutions?: boolean;
q?: string; q?: string;
} }
@@ -516,6 +519,8 @@ export function issueService(db: Db) {
} }
if (filters?.projectId) conditions.push(eq(issues.projectId, filters.projectId)); if (filters?.projectId) conditions.push(eq(issues.projectId, filters.projectId));
if (filters?.parentId) conditions.push(eq(issues.parentId, filters.parentId)); if (filters?.parentId) conditions.push(eq(issues.parentId, filters.parentId));
if (filters?.originKind) conditions.push(eq(issues.originKind, filters.originKind));
if (filters?.originId) conditions.push(eq(issues.originId, filters.originId));
if (filters?.labelId) { if (filters?.labelId) {
const labeledIssueIds = await db const labeledIssueIds = await db
.select({ issueId: issueLabels.issueId }) .select({ issueId: issueLabels.issueId })
@@ -534,6 +539,9 @@ export function issueService(db: Db) {
)!, )!,
); );
} }
if (!filters?.includeRoutineExecutions && !filters?.originKind && !filters?.originId) {
conditions.push(ne(issues.originKind, "routine_execution"));
}
conditions.push(isNull(issues.hiddenAt)); conditions.push(isNull(issues.hiddenAt));
const priorityOrder = sql`CASE ${issues.priority} WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END`; const priorityOrder = sql`CASE ${issues.priority} WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END`;
@@ -615,6 +623,7 @@ export function issueService(db: Db) {
eq(issues.companyId, companyId), eq(issues.companyId, companyId),
isNull(issues.hiddenAt), isNull(issues.hiddenAt),
unreadForUserCondition(companyId, userId), unreadForUserCondition(companyId, userId),
ne(issues.originKind, "routine_execution"),
]; ];
if (status) { if (status) {
const statuses = status.split(",").map((s) => s.trim()).filter(Boolean); const statuses = status.split(",").map((s) => s.trim()).filter(Boolean);
@@ -753,6 +762,7 @@ export function issueService(db: Db) {
const values = { const values = {
...issueData, ...issueData,
originKind: issueData.originKind ?? "manual",
goalId: resolveIssueGoalId({ goalId: resolveIssueGoalId({
projectId: issueData.projectId, projectId: issueData.projectId,
goalId: issueData.goalId, goalId: issueData.goalId,

File diff suppressed because it is too large Load Diff

View File

@@ -159,6 +159,7 @@ export function secretService(db: Db) {
getById, getById,
getByName, getByName,
resolveSecretValue,
create: async ( create: async (
companyId: string, companyId: string,

View File

@@ -80,6 +80,12 @@ curl -sS -X POST "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/skills/
}' }'
``` ```
You can also use source strings such as:
- `google-labs-code/stitch-skills/design-md`
- `vercel-labs/agent-browser/agent-browser`
- `npx skills add https://github.com/vercel-labs/agent-browser --skill agent-browser`
If the task is to discover skills from the company project workspaces first: If the task is to discover skills from the company project workspaces first:
```sh ```sh

View File

@@ -13,6 +13,8 @@ import { Projects } from "./pages/Projects";
import { ProjectDetail } from "./pages/ProjectDetail"; import { ProjectDetail } from "./pages/ProjectDetail";
import { Issues } from "./pages/Issues"; import { Issues } from "./pages/Issues";
import { IssueDetail } from "./pages/IssueDetail"; import { IssueDetail } from "./pages/IssueDetail";
import { Routines } from "./pages/Routines";
import { RoutineDetail } from "./pages/RoutineDetail";
import { ExecutionWorkspaceDetail } from "./pages/ExecutionWorkspaceDetail"; import { ExecutionWorkspaceDetail } from "./pages/ExecutionWorkspaceDetail";
import { Goals } from "./pages/Goals"; import { Goals } from "./pages/Goals";
import { GoalDetail } from "./pages/GoalDetail"; import { GoalDetail } from "./pages/GoalDetail";
@@ -150,6 +152,8 @@ function boardRoutes() {
<Route path="issues/done" element={<Navigate to="/issues" replace />} /> <Route path="issues/done" element={<Navigate to="/issues" replace />} />
<Route path="issues/recent" element={<Navigate to="/issues" replace />} /> <Route path="issues/recent" element={<Navigate to="/issues" replace />} />
<Route path="issues/:issueId" element={<IssueDetail />} /> <Route path="issues/:issueId" element={<IssueDetail />} />
<Route path="routines" element={<Routines />} />
<Route path="routines/:routineId" element={<RoutineDetail />} />
<Route path="execution-workspaces/:workspaceId" element={<ExecutionWorkspaceDetail />} /> <Route path="execution-workspaces/:workspaceId" element={<ExecutionWorkspaceDetail />} />
<Route path="goals" element={<Goals />} /> <Route path="goals" element={<Goals />} />
<Route path="goals/:goalId" element={<GoalDetail />} /> <Route path="goals/:goalId" element={<GoalDetail />} />
@@ -315,6 +319,8 @@ export function App() {
<Route path="companies" element={<UnprefixedBoardRedirect />} /> <Route path="companies" element={<UnprefixedBoardRedirect />} />
<Route path="issues" element={<UnprefixedBoardRedirect />} /> <Route path="issues" element={<UnprefixedBoardRedirect />} />
<Route path="issues/:issueId" element={<UnprefixedBoardRedirect />} /> <Route path="issues/:issueId" element={<UnprefixedBoardRedirect />} />
<Route path="routines" element={<UnprefixedBoardRedirect />} />
<Route path="routines/:routineId" element={<UnprefixedBoardRedirect />} />
<Route path="skills/*" element={<UnprefixedBoardRedirect />} /> <Route path="skills/*" element={<UnprefixedBoardRedirect />} />
<Route path="settings" element={<LegacySettingsRedirect />} /> <Route path="settings" element={<LegacySettingsRedirect />} />
<Route path="settings/*" element={<LegacySettingsRedirect />} /> <Route path="settings/*" element={<LegacySettingsRedirect />} />

View File

@@ -22,7 +22,14 @@ export interface IssueForRun {
} }
export const activityApi = { export const activityApi = {
list: (companyId: string) => api.get<ActivityEvent[]>(`/companies/${companyId}/activity`), list: (companyId: string, filters?: { entityType?: string; entityId?: string; agentId?: string }) => {
const params = new URLSearchParams();
if (filters?.entityType) params.set("entityType", filters.entityType);
if (filters?.entityId) params.set("entityId", filters.entityId);
if (filters?.agentId) params.set("agentId", filters.agentId);
const qs = params.toString();
return api.get<ActivityEvent[]>(`/companies/${companyId}/activity${qs ? `?${qs}` : ""}`);
},
forIssue: (issueId: string) => api.get<ActivityEvent[]>(`/issues/${issueId}/activity`), forIssue: (issueId: string) => api.get<ActivityEvent[]>(`/issues/${issueId}/activity`),
runsForIssue: (issueId: string) => api.get<RunForIssue[]>(`/issues/${issueId}/runs`), runsForIssue: (issueId: string) => api.get<RunForIssue[]>(`/issues/${issueId}/runs`),
issuesForRun: (runId: string) => api.get<IssueForRun[]>(`/heartbeat-runs/${runId}/issues`), issuesForRun: (runId: string) => api.get<IssueForRun[]>(`/heartbeat-runs/${runId}/issues`),

View File

@@ -1,9 +1,9 @@
import type { import type {
Agent, Agent,
AgentDetail,
AgentInstructionsBundle, AgentInstructionsBundle,
AgentInstructionsFileDetail, AgentInstructionsFileDetail,
AgentSkillSnapshot, AgentSkillSnapshot,
AgentDetail,
AdapterEnvironmentTestResult, AdapterEnvironmentTestResult,
AgentKeyCreated, AgentKeyCreated,
AgentRuntimeState, AgentRuntimeState,

View File

@@ -32,6 +32,7 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
errorBody, errorBody,
); );
} }
if (res.status === 204) return undefined as T;
return res.json(); return res.json();
} }

View File

@@ -6,6 +6,7 @@ export { companiesApi } from "./companies";
export { agentsApi } from "./agents"; export { agentsApi } from "./agents";
export { projectsApi } from "./projects"; export { projectsApi } from "./projects";
export { issuesApi } from "./issues"; export { issuesApi } from "./issues";
export { routinesApi } from "./routines";
export { goalsApi } from "./goals"; export { goalsApi } from "./goals";
export { approvalsApi } from "./approvals"; export { approvalsApi } from "./approvals";
export { costsApi } from "./costs"; export { costsApi } from "./costs";

View File

@@ -22,6 +22,9 @@ export const issuesApi = {
touchedByUserId?: string; touchedByUserId?: string;
unreadForUserId?: string; unreadForUserId?: string;
labelId?: string; labelId?: string;
originKind?: string;
originId?: string;
includeRoutineExecutions?: boolean;
q?: string; q?: string;
}, },
) => { ) => {
@@ -33,6 +36,9 @@ export const issuesApi = {
if (filters?.touchedByUserId) params.set("touchedByUserId", filters.touchedByUserId); if (filters?.touchedByUserId) params.set("touchedByUserId", filters.touchedByUserId);
if (filters?.unreadForUserId) params.set("unreadForUserId", filters.unreadForUserId); if (filters?.unreadForUserId) params.set("unreadForUserId", filters.unreadForUserId);
if (filters?.labelId) params.set("labelId", filters.labelId); if (filters?.labelId) params.set("labelId", filters.labelId);
if (filters?.originKind) params.set("originKind", filters.originKind);
if (filters?.originId) params.set("originId", filters.originId);
if (filters?.includeRoutineExecutions) params.set("includeRoutineExecutions", "true");
if (filters?.q) params.set("q", filters.q); if (filters?.q) params.set("q", filters.q);
const qs = params.toString(); const qs = params.toString();
return api.get<Issue[]>(`/companies/${companyId}/issues${qs ? `?${qs}` : ""}`); return api.get<Issue[]>(`/companies/${companyId}/issues${qs ? `?${qs}` : ""}`);

58
ui/src/api/routines.ts Normal file
View File

@@ -0,0 +1,58 @@
import type {
ActivityEvent,
Routine,
RoutineDetail,
RoutineListItem,
RoutineRun,
RoutineRunSummary,
RoutineTrigger,
RoutineTriggerSecretMaterial,
} from "@paperclipai/shared";
import { activityApi } from "./activity";
import { api } from "./client";
export interface RoutineTriggerResponse {
trigger: RoutineTrigger;
secretMaterial: RoutineTriggerSecretMaterial | null;
}
export interface RotateRoutineTriggerResponse {
trigger: RoutineTrigger;
secretMaterial: RoutineTriggerSecretMaterial;
}
export const routinesApi = {
list: (companyId: string) => api.get<RoutineListItem[]>(`/companies/${companyId}/routines`),
create: (companyId: string, data: Record<string, unknown>) =>
api.post<Routine>(`/companies/${companyId}/routines`, data),
get: (id: string) => api.get<RoutineDetail>(`/routines/${id}`),
update: (id: string, data: Record<string, unknown>) => api.patch<Routine>(`/routines/${id}`, data),
listRuns: (id: string, limit: number = 50) => api.get<RoutineRunSummary[]>(`/routines/${id}/runs?limit=${limit}`),
createTrigger: (id: string, data: Record<string, unknown>) =>
api.post<RoutineTriggerResponse>(`/routines/${id}/triggers`, data),
updateTrigger: (id: string, data: Record<string, unknown>) =>
api.patch<RoutineTrigger>(`/routine-triggers/${id}`, data),
deleteTrigger: (id: string) => api.delete<void>(`/routine-triggers/${id}`),
rotateTriggerSecret: (id: string) =>
api.post<RotateRoutineTriggerResponse>(`/routine-triggers/${id}/rotate-secret`, {}),
run: (id: string, data?: Record<string, unknown>) =>
api.post<RoutineRun>(`/routines/${id}/run`, data ?? {}),
activity: async (
companyId: string,
routineId: string,
related?: { triggerIds?: string[]; runIds?: string[] },
) => {
const requests = [
activityApi.list(companyId, { entityType: "routine", entityId: routineId }),
...(related?.triggerIds ?? []).map((triggerId) =>
activityApi.list(companyId, { entityType: "routine_trigger", entityId: triggerId })),
...(related?.runIds ?? []).map((runId) =>
activityApi.list(companyId, { entityType: "routine_run", entityId: runId })),
];
const events = (await Promise.all(requests)).flat();
const deduped = new Map(events.map((event) => [event.id, event]));
return [...deduped.values()].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
);
},
};

View File

@@ -0,0 +1,51 @@
import { Pause, Play } from "lucide-react";
import { Button } from "@/components/ui/button";
export function RunButton({
onClick,
disabled,
label = "Run now",
size = "sm",
}: {
onClick: () => void;
disabled?: boolean;
label?: string;
size?: "sm" | "default";
}) {
return (
<Button variant="outline" size={size} onClick={onClick} disabled={disabled}>
<Play className="h-3.5 w-3.5 sm:mr-1" />
<span className="hidden sm:inline">{label}</span>
</Button>
);
}
export function PauseResumeButton({
isPaused,
onPause,
onResume,
disabled,
size = "sm",
}: {
isPaused: boolean;
onPause: () => void;
onResume: () => void;
disabled?: boolean;
size?: "sm" | "default";
}) {
if (isPaused) {
return (
<Button variant="outline" size={size} onClick={onResume} disabled={disabled}>
<Play className="h-3.5 w-3.5 sm:mr-1" />
<span className="hidden sm:inline">Resume</span>
</Button>
);
}
return (
<Button variant="outline" size={size} onClick={onPause} disabled={disabled}>
<Pause className="h-3.5 w-3.5 sm:mr-1" />
<span className="hidden sm:inline">Pause</span>
</Button>
);
}

View File

@@ -0,0 +1,344 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import { ChevronDown, ChevronRight } from "lucide-react";
type SchedulePreset = "every_minute" | "every_hour" | "every_day" | "weekdays" | "weekly" | "monthly" | "custom";
const PRESETS: { value: SchedulePreset; label: string }[] = [
{ value: "every_minute", label: "Every minute" },
{ value: "every_hour", label: "Every hour" },
{ value: "every_day", label: "Every day" },
{ value: "weekdays", label: "Weekdays" },
{ value: "weekly", label: "Weekly" },
{ value: "monthly", label: "Monthly" },
{ value: "custom", label: "Custom (cron)" },
];
const HOURS = Array.from({ length: 24 }, (_, i) => ({
value: String(i),
label: i === 0 ? "12 AM" : i < 12 ? `${i} AM` : i === 12 ? "12 PM" : `${i - 12} PM`,
}));
const MINUTES = Array.from({ length: 12 }, (_, i) => ({
value: String(i * 5),
label: String(i * 5).padStart(2, "0"),
}));
const DAYS_OF_WEEK = [
{ value: "1", label: "Mon" },
{ value: "2", label: "Tue" },
{ value: "3", label: "Wed" },
{ value: "4", label: "Thu" },
{ value: "5", label: "Fri" },
{ value: "6", label: "Sat" },
{ value: "0", label: "Sun" },
];
const DAYS_OF_MONTH = Array.from({ length: 31 }, (_, i) => ({
value: String(i + 1),
label: String(i + 1),
}));
function parseCronToPreset(cron: string): {
preset: SchedulePreset;
hour: string;
minute: string;
dayOfWeek: string;
dayOfMonth: string;
} {
const defaults = { hour: "10", minute: "0", dayOfWeek: "1", dayOfMonth: "1" };
if (!cron || !cron.trim()) {
return { preset: "every_day", ...defaults };
}
const parts = cron.trim().split(/\s+/);
if (parts.length !== 5) {
return { preset: "custom", ...defaults };
}
const [min, hr, dom, , dow] = parts;
// Every minute: "* * * * *"
if (min === "*" && hr === "*" && dom === "*" && dow === "*") {
return { preset: "every_minute", ...defaults };
}
// Every hour: "0 * * * *"
if (hr === "*" && dom === "*" && dow === "*") {
return { preset: "every_hour", ...defaults, minute: min === "*" ? "0" : min };
}
// Every day: "M H * * *"
if (dom === "*" && dow === "*" && hr !== "*") {
return { preset: "every_day", ...defaults, hour: hr, minute: min === "*" ? "0" : min };
}
// Weekdays: "M H * * 1-5"
if (dom === "*" && dow === "1-5" && hr !== "*") {
return { preset: "weekdays", ...defaults, hour: hr, minute: min === "*" ? "0" : min };
}
// Weekly: "M H * * D" (single day)
if (dom === "*" && /^\d$/.test(dow) && hr !== "*") {
return { preset: "weekly", ...defaults, hour: hr, minute: min === "*" ? "0" : min, dayOfWeek: dow };
}
// Monthly: "M H D * *"
if (/^\d{1,2}$/.test(dom) && dow === "*" && hr !== "*") {
return { preset: "monthly", ...defaults, hour: hr, minute: min === "*" ? "0" : min, dayOfMonth: dom };
}
return { preset: "custom", ...defaults };
}
function buildCron(preset: SchedulePreset, hour: string, minute: string, dayOfWeek: string, dayOfMonth: string): string {
switch (preset) {
case "every_minute":
return "* * * * *";
case "every_hour":
return `${minute} * * * *`;
case "every_day":
return `${minute} ${hour} * * *`;
case "weekdays":
return `${minute} ${hour} * * 1-5`;
case "weekly":
return `${minute} ${hour} * * ${dayOfWeek}`;
case "monthly":
return `${minute} ${hour} ${dayOfMonth} * *`;
case "custom":
return "";
}
}
function describeSchedule(cron: string): string {
const { preset, hour, minute, dayOfWeek, dayOfMonth } = parseCronToPreset(cron);
const hourLabel = HOURS.find((h) => h.value === hour)?.label ?? `${hour}`;
const timeStr = `${hourLabel.replace(/ (AM|PM)$/, "")}:${minute.padStart(2, "0")} ${hourLabel.match(/(AM|PM)$/)?.[0] ?? ""}`;
switch (preset) {
case "every_minute":
return "Every minute";
case "every_hour":
return `Every hour at :${minute.padStart(2, "0")}`;
case "every_day":
return `Every day at ${timeStr}`;
case "weekdays":
return `Weekdays at ${timeStr}`;
case "weekly": {
const day = DAYS_OF_WEEK.find((d) => d.value === dayOfWeek)?.label ?? dayOfWeek;
return `Every ${day} at ${timeStr}`;
}
case "monthly":
return `Monthly on the ${dayOfMonth}${ordinalSuffix(Number(dayOfMonth))} at ${timeStr}`;
case "custom":
return cron || "No schedule set";
}
}
function ordinalSuffix(n: number): string {
const s = ["th", "st", "nd", "rd"];
const v = n % 100;
return s[(v - 20) % 10] || s[v] || s[0];
}
export { describeSchedule };
export function ScheduleEditor({
value,
onChange,
}: {
value: string;
onChange: (cron: string) => void;
}) {
const parsed = useMemo(() => parseCronToPreset(value), [value]);
const [preset, setPreset] = useState<SchedulePreset>(parsed.preset);
const [hour, setHour] = useState(parsed.hour);
const [minute, setMinute] = useState(parsed.minute);
const [dayOfWeek, setDayOfWeek] = useState(parsed.dayOfWeek);
const [dayOfMonth, setDayOfMonth] = useState(parsed.dayOfMonth);
const [customCron, setCustomCron] = useState(preset === "custom" ? value : "");
// Sync from external value changes
useEffect(() => {
const p = parseCronToPreset(value);
setPreset(p.preset);
setHour(p.hour);
setMinute(p.minute);
setDayOfWeek(p.dayOfWeek);
setDayOfMonth(p.dayOfMonth);
if (p.preset === "custom") setCustomCron(value);
}, [value]);
const emitChange = useCallback(
(p: SchedulePreset, h: string, m: string, dow: string, dom: string, custom: string) => {
if (p === "custom") {
onChange(custom);
} else {
onChange(buildCron(p, h, m, dow, dom));
}
},
[onChange],
);
const handlePresetChange = (newPreset: SchedulePreset) => {
setPreset(newPreset);
if (newPreset === "custom") {
setCustomCron(value);
} else {
emitChange(newPreset, hour, minute, dayOfWeek, dayOfMonth, customCron);
}
};
return (
<div className="space-y-3">
<Select value={preset} onValueChange={(v) => handlePresetChange(v as SchedulePreset)}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Choose frequency..." />
</SelectTrigger>
<SelectContent>
{PRESETS.map((p) => (
<SelectItem key={p.value} value={p.value}>
{p.label}
</SelectItem>
))}
</SelectContent>
</Select>
{preset === "custom" ? (
<div className="space-y-1.5">
<Input
value={customCron}
onChange={(e) => {
setCustomCron(e.target.value);
emitChange("custom", hour, minute, dayOfWeek, dayOfMonth, e.target.value);
}}
placeholder="0 10 * * *"
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
Five fields: minute hour day-of-month month day-of-week
</p>
</div>
) : (
<div className="flex flex-wrap items-center gap-2">
{preset !== "every_minute" && preset !== "every_hour" && (
<>
<span className="text-sm text-muted-foreground">at</span>
<Select
value={hour}
onValueChange={(h) => {
setHour(h);
emitChange(preset, h, minute, dayOfWeek, dayOfMonth, customCron);
}}
>
<SelectTrigger className="w-[120px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{HOURS.map((h) => (
<SelectItem key={h.value} value={h.value}>
{h.label}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-sm text-muted-foreground">:</span>
<Select
value={minute}
onValueChange={(m) => {
setMinute(m);
emitChange(preset, hour, m, dayOfWeek, dayOfMonth, customCron);
}}
>
<SelectTrigger className="w-[80px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{MINUTES.map((m) => (
<SelectItem key={m.value} value={m.value}>
{m.label}
</SelectItem>
))}
</SelectContent>
</Select>
</>
)}
{preset === "every_hour" && (
<>
<span className="text-sm text-muted-foreground">at minute</span>
<Select
value={minute}
onValueChange={(m) => {
setMinute(m);
emitChange(preset, hour, m, dayOfWeek, dayOfMonth, customCron);
}}
>
<SelectTrigger className="w-[80px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{MINUTES.map((m) => (
<SelectItem key={m.value} value={m.value}>
:{m.label}
</SelectItem>
))}
</SelectContent>
</Select>
</>
)}
{preset === "weekly" && (
<>
<span className="text-sm text-muted-foreground">on</span>
<div className="flex gap-1">
{DAYS_OF_WEEK.map((d) => (
<Button
key={d.value}
type="button"
variant={dayOfWeek === d.value ? "default" : "outline"}
size="sm"
className="h-7 px-2 text-xs"
onClick={() => {
setDayOfWeek(d.value);
emitChange(preset, hour, minute, d.value, dayOfMonth, customCron);
}}
>
{d.label}
</Button>
))}
</div>
</>
)}
{preset === "monthly" && (
<>
<span className="text-sm text-muted-foreground">on day</span>
<Select
value={dayOfMonth}
onValueChange={(dom) => {
setDayOfMonth(dom);
emitChange(preset, hour, minute, dayOfWeek, dom, customCron);
}}
>
<SelectTrigger className="w-[80px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DAYS_OF_MONTH.map((d) => (
<SelectItem key={d.value} value={d.value}>
{d.label}
</SelectItem>
))}
</SelectContent>
</Select>
</>
)}
</div>
)}
</div>
);
}

View File

@@ -9,6 +9,7 @@ import {
SquarePen, SquarePen,
Network, Network,
Boxes, Boxes,
Repeat,
Settings, Settings,
} from "lucide-react"; } from "lucide-react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
@@ -98,6 +99,7 @@ export function Sidebar() {
<SidebarSection label="Work"> <SidebarSection label="Work">
<SidebarNavItem to="/issues" label="Issues" icon={CircleDot} /> <SidebarNavItem to="/issues" label="Issues" icon={CircleDot} />
<SidebarNavItem to="/routines" label="Routines" icon={Repeat} textBadge="Beta" textBadgeTone="amber" />
<SidebarNavItem to="/goals" label="Goals" icon={Target} /> <SidebarNavItem to="/goals" label="Goals" icon={Target} />
</SidebarSection> </SidebarSection>

View File

@@ -11,6 +11,8 @@ interface SidebarNavItemProps {
className?: string; className?: string;
badge?: number; badge?: number;
badgeTone?: "default" | "danger"; badgeTone?: "default" | "danger";
textBadge?: string;
textBadgeTone?: "default" | "amber";
alert?: boolean; alert?: boolean;
liveCount?: number; liveCount?: number;
} }
@@ -23,6 +25,8 @@ export function SidebarNavItem({
className, className,
badge, badge,
badgeTone = "default", badgeTone = "default",
textBadge,
textBadgeTone = "default",
alert = false, alert = false,
liveCount, liveCount,
}: SidebarNavItemProps) { }: SidebarNavItemProps) {
@@ -50,6 +54,18 @@ export function SidebarNavItem({
)} )}
</span> </span>
<span className="flex-1 truncate">{label}</span> <span className="flex-1 truncate">{label}</span>
{textBadge && (
<span
className={cn(
"ml-auto rounded-full px-1.5 py-0.5 text-[10px] font-medium leading-none",
textBadgeTone === "amber"
? "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400"
: "bg-muted text-muted-foreground",
)}
>
{textBadge}
</span>
)}
{liveCount != null && liveCount > 0 && ( {liveCount != null && liveCount > 0 && (
<span className="ml-auto flex items-center gap-1.5"> <span className="ml-auto flex items-center gap-1.5">
<span className="relative flex h-2 w-2"> <span className="relative flex h-2 w-2">

View File

@@ -422,6 +422,11 @@ function invalidateActivityQueries(
return; return;
} }
if (entityType === "routine" || entityType === "routine_trigger" || entityType === "routine_run") {
queryClient.invalidateQueries({ queryKey: ["routines"] });
return;
}
if (entityType === "company") { if (entityType === "company") {
queryClient.invalidateQueries({ queryKey: queryKeys.companies.all }); queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
} }

View File

@@ -7,6 +7,7 @@ const BOARD_ROUTE_ROOTS = new Set([
"agents", "agents",
"projects", "projects",
"issues", "issues",
"routines",
"goals", "goals",
"approvals", "approvals",
"costs", "costs",

View File

@@ -48,6 +48,12 @@ export const queryKeys = {
activeRun: (issueId: string) => ["issues", "active-run", issueId] as const, activeRun: (issueId: string) => ["issues", "active-run", issueId] as const,
workProducts: (issueId: string) => ["issues", "work-products", issueId] as const, workProducts: (issueId: string) => ["issues", "work-products", issueId] as const,
}, },
routines: {
list: (companyId: string) => ["routines", companyId] as const,
detail: (id: string) => ["routines", "detail", id] as const,
runs: (id: string) => ["routines", "runs", id] as const,
activity: (companyId: string, id: string) => ["routines", "activity", companyId, id] as const,
},
executionWorkspaces: { executionWorkspaces: {
list: (companyId: string, filters?: Record<string, string | boolean | undefined>) => list: (companyId: string, filters?: Record<string, string | boolean | undefined>) =>
["execution-workspaces", companyId, filters ?? {}] as const, ["execution-workspaces", companyId, filters ?? {}] as const,

View File

@@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";
import type { RoutineTrigger } from "@paperclipai/shared";
import { buildRoutineTriggerPatch } from "./routine-trigger-patch";
function makeScheduleTrigger(overrides: Partial<RoutineTrigger> = {}): RoutineTrigger {
return {
id: "trigger-1",
companyId: "company-1",
routineId: "routine-1",
kind: "schedule",
label: "Daily",
enabled: true,
cronExpression: "0 10 * * *",
timezone: "UTC",
nextRunAt: null,
lastFiredAt: null,
publicId: null,
secretId: null,
signingMode: null,
replayWindowSec: null,
lastRotatedAt: null,
lastResult: null,
createdByAgentId: null,
createdByUserId: null,
updatedByAgentId: null,
updatedByUserId: null,
createdAt: new Date("2026-03-20T00:00:00.000Z"),
updatedAt: new Date("2026-03-20T00:00:00.000Z"),
...overrides,
};
}
describe("buildRoutineTriggerPatch", () => {
it("preserves an existing schedule trigger timezone when saving edits", () => {
const patch = buildRoutineTriggerPatch(
makeScheduleTrigger({ timezone: "UTC" }),
{
label: "Daily label edit",
cronExpression: "0 10 * * *",
signingMode: "bearer",
replayWindowSec: "300",
},
"America/Chicago",
);
expect(patch).toEqual({
label: "Daily label edit",
cronExpression: "0 10 * * *",
timezone: "UTC",
});
});
it("falls back to the local timezone when a schedule trigger has none", () => {
const patch = buildRoutineTriggerPatch(
makeScheduleTrigger({ timezone: null }),
{
label: "",
cronExpression: "15 9 * * 1-5",
signingMode: "bearer",
replayWindowSec: "300",
},
"America/Chicago",
);
expect(patch).toEqual({
label: null,
cronExpression: "15 9 * * 1-5",
timezone: "America/Chicago",
});
});
});

View File

@@ -0,0 +1,30 @@
import type { RoutineTrigger } from "@paperclipai/shared";
export type RoutineTriggerEditorDraft = {
label: string;
cronExpression: string;
signingMode: string;
replayWindowSec: string;
};
export function buildRoutineTriggerPatch(
trigger: RoutineTrigger,
draft: RoutineTriggerEditorDraft,
fallbackTimezone: string,
) {
const patch: Record<string, unknown> = {
label: draft.label.trim() || null,
};
if (trigger.kind === "schedule") {
patch.cronExpression = draft.cronExpression.trim();
patch.timezone = trigger.timezone ?? fallbackTimezone;
}
if (trigger.kind === "webhook") {
patch.signingMode = draft.signingMode;
patch.replayWindowSec = Number(draft.replayWindowSec || "300");
}
return patch;
}

View File

@@ -34,6 +34,7 @@ import { CopyText } from "../components/CopyText";
import { EntityRow } from "../components/EntityRow"; import { EntityRow } from "../components/EntityRow";
import { Identity } from "../components/Identity"; import { Identity } from "../components/Identity";
import { PageSkeleton } from "../components/PageSkeleton"; import { PageSkeleton } from "../components/PageSkeleton";
import { RunButton, PauseResumeButton } from "../components/AgentActionButtons";
import { BudgetPolicyCard } from "../components/BudgetPolicyCard"; import { BudgetPolicyCard } from "../components/BudgetPolicyCard";
import { PackageFileTree, buildFileTree } from "../components/PackageFileTree"; import { PackageFileTree, buildFileTree } from "../components/PackageFileTree";
import { ScrollToBottom } from "../components/ScrollToBottom"; import { ScrollToBottom } from "../components/ScrollToBottom";
@@ -50,8 +51,6 @@ import {
} from "@/components/ui/popover"; } from "@/components/ui/popover";
import { import {
MoreHorizontal, MoreHorizontal,
Play,
Pause,
CheckCircle2, CheckCircle2,
XCircle, XCircle,
Clock, Clock,
@@ -833,36 +832,17 @@ export function AgentDetail() {
<Plus className="h-3.5 w-3.5 sm:mr-1" /> <Plus className="h-3.5 w-3.5 sm:mr-1" />
<span className="hidden sm:inline">Assign Task</span> <span className="hidden sm:inline">Assign Task</span>
</Button> </Button>
<Button <RunButton
variant="outline"
size="sm"
onClick={() => agentAction.mutate("invoke")} onClick={() => agentAction.mutate("invoke")}
disabled={agentAction.isPending || isPendingApproval} disabled={agentAction.isPending || isPendingApproval}
> label="Run Heartbeat"
<Play className="h-3.5 w-3.5 sm:mr-1" /> />
<span className="hidden sm:inline">Run Heartbeat</span> <PauseResumeButton
</Button> isPaused={agent.status === "paused"}
{agent.status === "paused" ? ( onPause={() => agentAction.mutate("pause")}
<Button onResume={() => agentAction.mutate("resume")}
variant="outline" disabled={agentAction.isPending || isPendingApproval}
size="sm" />
onClick={() => agentAction.mutate("resume")}
disabled={agentAction.isPending || isPendingApproval}
>
<Play className="h-3.5 w-3.5 sm:mr-1" />
<span className="hidden sm:inline">Resume</span>
</Button>
) : (
<Button
variant="outline"
size="sm"
onClick={() => agentAction.mutate("pause")}
disabled={agentAction.isPending || isPendingApproval}
>
<Pause className="h-3.5 w-3.5 sm:mr-1" />
<span className="hidden sm:inline">Pause</span>
</Button>
)}
<span className="hidden sm:inline"><StatusBadge status={agent.status} /></span> <span className="hidden sm:inline"><StatusBadge status={agent.status} /></span>
{mobileLiveRun && ( {mobileLiveRun && (
<Link <Link

View File

@@ -48,6 +48,7 @@ import {
MessageSquare, MessageSquare,
MoreHorizontal, MoreHorizontal,
Paperclip, Paperclip,
Repeat,
SlidersHorizontal, SlidersHorizontal,
Trash2, Trash2,
} from "lucide-react"; } from "lucide-react";
@@ -726,6 +727,16 @@ export function IssueDetail() {
</span> </span>
)} )}
{issue.originKind === "routine_execution" && issue.originId && (
<Link
to={`/routines/${issue.originId}`}
className="inline-flex items-center gap-1 rounded-full bg-violet-500/10 border border-violet-500/30 px-2 py-0.5 text-[10px] font-medium text-violet-600 dark:text-violet-400 shrink-0 hover:bg-violet-500/20 transition-colors"
>
<Repeat className="h-3 w-3" />
Routine
</Link>
)}
{issue.projectId ? ( {issue.projectId ? (
<Link <Link
to={`/projects/${issue.projectId}`} to={`/projects/${issue.projectId}`}

File diff suppressed because it is too large Load Diff

660
ui/src/pages/Routines.tsx Normal file
View File

@@ -0,0 +1,660 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "@/lib/router";
import { ChevronDown, ChevronRight, MoreHorizontal, Play, Plus, Repeat } from "lucide-react";
import { routinesApi } from "../api/routines";
import { agentsApi } from "../api/agents";
import { projectsApi } from "../api/projects";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useToast } from "../context/ToastContext";
import { queryKeys } from "../lib/queryKeys";
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton";
import { AgentIcon } from "../components/AgentIconPicker";
import { InlineEntitySelector, type InlineEntityOption } from "../components/InlineEntitySelector";
import { MarkdownEditor, type MarkdownEditorRef } from "../components/MarkdownEditor";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
const concurrencyPolicies = ["coalesce_if_active", "always_enqueue", "skip_if_active"];
const catchUpPolicies = ["skip_missed", "enqueue_missed_with_cap"];
const concurrencyPolicyDescriptions: Record<string, string> = {
coalesce_if_active: "If a run is already active, keep just one follow-up run queued.",
always_enqueue: "Queue every trigger occurrence, even if the routine is already running.",
skip_if_active: "Drop new trigger occurrences while a run is still active.",
};
const catchUpPolicyDescriptions: Record<string, string> = {
skip_missed: "Ignore windows that were missed while the scheduler or routine was paused.",
enqueue_missed_with_cap: "Catch up missed schedule windows in capped batches after recovery.",
};
function autoResizeTextarea(element: HTMLTextAreaElement | null) {
if (!element) return;
element.style.height = "auto";
element.style.height = `${element.scrollHeight}px`;
}
function formatLastRunTimestamp(value: Date | string | null | undefined) {
if (!value) return "Never";
return new Date(value).toLocaleString();
}
function nextRoutineStatus(currentStatus: string, enabled: boolean) {
if (currentStatus === "archived" && enabled) return "active";
return enabled ? "active" : "paused";
}
export function Routines() {
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
const navigate = useNavigate();
const { pushToast } = useToast();
const descriptionEditorRef = useRef<MarkdownEditorRef>(null);
const titleInputRef = useRef<HTMLTextAreaElement | null>(null);
const assigneeSelectorRef = useRef<HTMLButtonElement | null>(null);
const projectSelectorRef = useRef<HTMLButtonElement | null>(null);
const [runningRoutineId, setRunningRoutineId] = useState<string | null>(null);
const [statusMutationRoutineId, setStatusMutationRoutineId] = useState<string | null>(null);
const [composerOpen, setComposerOpen] = useState(false);
const [advancedOpen, setAdvancedOpen] = useState(false);
const [draft, setDraft] = useState({
title: "",
description: "",
projectId: "",
assigneeAgentId: "",
priority: "medium",
concurrencyPolicy: "coalesce_if_active",
catchUpPolicy: "skip_missed",
});
useEffect(() => {
setBreadcrumbs([{ label: "Routines" }]);
}, [setBreadcrumbs]);
const { data: routines, isLoading, error } = useQuery({
queryKey: queryKeys.routines.list(selectedCompanyId!),
queryFn: () => routinesApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const { data: agents } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!),
queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const { data: projects } = useQuery({
queryKey: queryKeys.projects.list(selectedCompanyId!),
queryFn: () => projectsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
useEffect(() => {
autoResizeTextarea(titleInputRef.current);
}, [draft.title, composerOpen]);
const createRoutine = useMutation({
mutationFn: () =>
routinesApi.create(selectedCompanyId!, {
...draft,
description: draft.description.trim() || null,
}),
onSuccess: async (routine) => {
setDraft({
title: "",
description: "",
projectId: "",
assigneeAgentId: "",
priority: "medium",
concurrencyPolicy: "coalesce_if_active",
catchUpPolicy: "skip_missed",
});
setComposerOpen(false);
setAdvancedOpen(false);
await queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) });
pushToast({
title: "Routine created",
body: "Add the first trigger to turn it into a live workflow.",
tone: "success",
});
navigate(`/routines/${routine.id}?tab=triggers`);
},
});
const updateRoutineStatus = useMutation({
mutationFn: ({ id, status }: { id: string; status: string }) => routinesApi.update(id, { status }),
onMutate: ({ id }) => {
setStatusMutationRoutineId(id);
},
onSuccess: async (_, variables) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(variables.id) }),
]);
},
onSettled: () => {
setStatusMutationRoutineId(null);
},
onError: (mutationError) => {
pushToast({
title: "Failed to update routine",
body: mutationError instanceof Error ? mutationError.message : "Paperclip could not update the routine.",
tone: "error",
});
},
});
const runRoutine = useMutation({
mutationFn: (id: string) => routinesApi.run(id),
onMutate: (id) => {
setRunningRoutineId(id);
},
onSuccess: async (_, id) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(id) }),
]);
},
onSettled: () => {
setRunningRoutineId(null);
},
onError: (mutationError) => {
pushToast({
title: "Routine run failed",
body: mutationError instanceof Error ? mutationError.message : "Paperclip could not start the routine run.",
tone: "error",
});
},
});
const recentAssigneeIds = useMemo(() => getRecentAssigneeIds(), [composerOpen]);
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 agentById = useMemo(
() => new Map((agents ?? []).map((agent) => [agent.id, agent])),
[agents],
);
const projectById = useMemo(
() => new Map((projects ?? []).map((project) => [project.id, project])),
[projects],
);
const currentAssignee = draft.assigneeAgentId ? agentById.get(draft.assigneeAgentId) ?? null : null;
const currentProject = draft.projectId ? projectById.get(draft.projectId) ?? null : null;
if (!selectedCompanyId) {
return <EmptyState icon={Repeat} message="Select a company to view routines." />;
}
if (isLoading) {
return <PageSkeleton variant="issues-list" />;
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div className="space-y-1">
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
Routines
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-800 dark:bg-amber-900/30 dark:text-amber-400">Beta</span>
</h1>
<p className="text-sm text-muted-foreground">
Recurring work definitions that materialize into auditable execution issues.
</p>
</div>
<Button onClick={() => setComposerOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Create routine
</Button>
</div>
<Dialog
open={composerOpen}
onOpenChange={(open) => {
if (!createRoutine.isPending) {
setComposerOpen(open);
}
}}
>
<DialogContent showCloseButton={false} className="max-w-3xl gap-0 overflow-hidden p-0">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border/60 px-5 py-3">
<div>
<p className="text-xs font-medium uppercase tracking-[0.2em] text-muted-foreground">New routine</p>
<p className="text-sm text-muted-foreground">
Define the recurring work first. Trigger setup comes next on the detail page.
</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
setComposerOpen(false);
setAdvancedOpen(false);
}}
disabled={createRoutine.isPending}
>
Cancel
</Button>
</div>
<div className="px-5 pt-5 pb-3">
<textarea
ref={titleInputRef}
className="w-full resize-none overflow-hidden bg-transparent text-xl font-semibold outline-none placeholder:text-muted-foreground/50"
placeholder="Routine title"
rows={1}
value={draft.title}
onChange={(event) => {
setDraft((current) => ({ ...current, title: event.target.value }));
autoResizeTextarea(event.target);
}}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.metaKey && !event.ctrlKey && !event.nativeEvent.isComposing) {
event.preventDefault();
descriptionEditorRef.current?.focus();
return;
}
if (event.key === "Tab" && !event.shiftKey) {
event.preventDefault();
if (draft.assigneeAgentId) {
if (draft.projectId) {
descriptionEditorRef.current?.focus();
} else {
projectSelectorRef.current?.focus();
}
} else {
assigneeSelectorRef.current?.focus();
}
}
}}
autoFocus
/>
</div>
<div className="px-5 pb-3">
<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">
<span>For</span>
<InlineEntitySelector
ref={assigneeSelectorRef}
value={draft.assigneeAgentId}
options={assigneeOptions}
placeholder="Assignee"
noneLabel="No assignee"
searchPlaceholder="Search assignees..."
emptyMessage="No assignees found."
onChange={(assigneeAgentId) => {
if (assigneeAgentId) trackRecentAssignee(assigneeAgentId);
setDraft((current) => ({ ...current, assigneeAgentId }));
}}
onConfirm={() => {
if (draft.projectId) {
descriptionEditorRef.current?.focus();
} else {
projectSelectorRef.current?.focus();
}
}}
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">Assignee</span>
)
}
renderOption={(option) => {
if (!option.id) return <span className="truncate">{option.label}</span>;
const assignee = agentById.get(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>
</>
);
}}
/>
<span>in</span>
<InlineEntitySelector
ref={projectSelectorRef}
value={draft.projectId}
options={projectOptions}
placeholder="Project"
noneLabel="No project"
searchPlaceholder="Search projects..."
emptyMessage="No projects found."
onChange={(projectId) => setDraft((current) => ({ ...current, projectId }))}
onConfirm={() => descriptionEditorRef.current?.focus()}
renderTriggerValue={(option) =>
option && currentProject ? (
<>
<span
className="h-3.5 w-3.5 shrink-0 rounded-sm"
style={{ backgroundColor: currentProject.color ?? "#64748b" }}
/>
<span className="truncate">{option.label}</span>
</>
) : (
<span className="text-muted-foreground">Project</span>
)
}
renderOption={(option) => {
if (!option.id) return <span className="truncate">{option.label}</span>;
const project = projectById.get(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>
</div>
<div className="border-t border-border/60 px-5 py-4">
<MarkdownEditor
ref={descriptionEditorRef}
value={draft.description}
onChange={(description) => setDraft((current) => ({ ...current, description }))}
placeholder="Add instructions..."
bordered={false}
contentClassName="min-h-[160px] text-sm text-muted-foreground"
onSubmit={() => {
if (!createRoutine.isPending && draft.title.trim() && draft.projectId && draft.assigneeAgentId) {
createRoutine.mutate();
}
}}
/>
</div>
<div className="border-t border-border/60 px-5 py-3">
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger className="flex w-full items-center justify-between text-left">
<div>
<p className="text-sm font-medium">Advanced delivery settings</p>
<p className="text-sm text-muted-foreground">Keep policy controls secondary to the work definition.</p>
</div>
{advancedOpen ? <ChevronDown className="h-4 w-4 text-muted-foreground" /> : <ChevronRight className="h-4 w-4 text-muted-foreground" />}
</CollapsibleTrigger>
<CollapsibleContent className="pt-3">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">Concurrency</p>
<Select
value={draft.concurrencyPolicy}
onValueChange={(concurrencyPolicy) => setDraft((current) => ({ ...current, concurrencyPolicy }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{concurrencyPolicies.map((value) => (
<SelectItem key={value} value={value}>{value.replaceAll("_", " ")}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{concurrencyPolicyDescriptions[draft.concurrencyPolicy]}</p>
</div>
<div className="space-y-2">
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">Catch-up</p>
<Select
value={draft.catchUpPolicy}
onValueChange={(catchUpPolicy) => setDraft((current) => ({ ...current, catchUpPolicy }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{catchUpPolicies.map((value) => (
<SelectItem key={value} value={value}>{value.replaceAll("_", " ")}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{catchUpPolicyDescriptions[draft.catchUpPolicy]}</p>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
<div className="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">
After creation, Paperclip takes you straight to trigger setup for schedules, webhooks, or internal runs.
</div>
<div className="flex flex-col gap-2 sm:items-end">
<Button
onClick={() => createRoutine.mutate()}
disabled={
createRoutine.isPending ||
!draft.title.trim() ||
!draft.projectId ||
!draft.assigneeAgentId
}
>
<Plus className="mr-2 h-4 w-4" />
{createRoutine.isPending ? "Creating..." : "Create routine"}
</Button>
{createRoutine.isError ? (
<p className="text-sm text-destructive">
{createRoutine.error instanceof Error ? createRoutine.error.message : "Failed to create routine"}
</p>
) : null}
</div>
</div>
</DialogContent>
</Dialog>
{error ? (
<Card>
<CardContent className="pt-6 text-sm text-destructive">
{error instanceof Error ? error.message : "Failed to load routines"}
</CardContent>
</Card>
) : null}
<div>
{(routines ?? []).length === 0 ? (
<div className="py-12">
<EmptyState
icon={Repeat}
message="No routines yet. Use Create routine to define the first recurring workflow."
/>
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead>
<tr className="text-left text-xs text-muted-foreground border-b border-border">
<th className="px-3 py-2 font-medium">Name</th>
<th className="px-3 py-2 font-medium">Project</th>
<th className="px-3 py-2 font-medium">Agent</th>
<th className="px-3 py-2 font-medium">Last run</th>
<th className="px-3 py-2 font-medium">Enabled</th>
<th className="w-12 px-3 py-2" />
</tr>
</thead>
<tbody>
{(routines ?? []).map((routine) => {
const enabled = routine.status === "active";
const isArchived = routine.status === "archived";
const isStatusPending = statusMutationRoutineId === routine.id;
return (
<tr
key={routine.id}
className="align-middle border-b border-border transition-colors hover:bg-accent/50 last:border-b-0 cursor-pointer"
onClick={() => navigate(`/routines/${routine.id}`)}
>
<td className="px-3 py-2.5">
<div className="min-w-[180px]">
<span className="font-medium">
{routine.title}
</span>
{(isArchived || routine.status === "paused") && (
<div className="mt-1 text-xs text-muted-foreground">
{isArchived ? "archived" : "paused"}
</div>
)}
</div>
</td>
<td className="px-3 py-2.5">
{routine.projectId ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span
className="shrink-0 h-3 w-3 rounded-sm"
style={{ backgroundColor: projectById.get(routine.projectId)?.color ?? "#6366f1" }}
/>
<span className="truncate">{projectById.get(routine.projectId)?.name ?? "Unknown"}</span>
</div>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</td>
<td className="px-3 py-2.5">
{routine.assigneeAgentId ? (() => {
const agent = agentById.get(routine.assigneeAgentId);
return agent ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<AgentIcon icon={agent.icon} className="h-4 w-4 shrink-0" />
<span className="truncate">{agent.name}</span>
</div>
) : (
<span className="text-xs text-muted-foreground">Unknown</span>
);
})() : (
<span className="text-xs text-muted-foreground"></span>
)}
</td>
<td className="px-3 py-2.5 text-muted-foreground">
<div>{formatLastRunTimestamp(routine.lastRun?.triggeredAt)}</div>
{routine.lastRun ? (
<div className="mt-1 text-xs">{routine.lastRun.status.replaceAll("_", " ")}</div>
) : null}
</td>
<td className="px-3 py-2.5" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-3">
<button
type="button"
role="switch"
aria-checked={enabled}
aria-label={enabled ? `Disable ${routine.title}` : `Enable ${routine.title}`}
disabled={isStatusPending || isArchived}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
enabled ? "bg-foreground" : "bg-muted"
} ${isStatusPending || isArchived ? "cursor-not-allowed opacity-50" : ""}`}
onClick={() =>
updateRoutineStatus.mutate({
id: routine.id,
status: nextRoutineStatus(routine.status, !enabled),
})
}
>
<span
className={`inline-block h-5 w-5 rounded-full bg-background shadow-sm transition-transform ${
enabled ? "translate-x-5" : "translate-x-0.5"
}`}
/>
</button>
<span className="text-xs text-muted-foreground">
{isArchived ? "Archived" : enabled ? "On" : "Off"}
</span>
</div>
</td>
<td className="px-3 py-2.5 text-right" onClick={(e) => e.stopPropagation()}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon-sm" aria-label={`More actions for ${routine.title}`}>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => navigate(`/routines/${routine.id}`)}>
Edit
</DropdownMenuItem>
<DropdownMenuItem
disabled={runningRoutineId === routine.id || isArchived}
onClick={() => runRoutine.mutate(routine.id)}
>
{runningRoutineId === routine.id ? "Running..." : "Run now"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() =>
updateRoutineStatus.mutate({
id: routine.id,
status: enabled ? "paused" : "active",
})
}
disabled={isStatusPending || isArchived}
>
{enabled ? "Pause" : "Enable"}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
updateRoutineStatus.mutate({
id: routine.id,
status: routine.status === "archived" ? "active" : "archived",
})
}
disabled={isStatusPending}
>
{routine.status === "archived" ? "Restore" : "Archive"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}