Add routines automation workflows

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta
2026-03-19 08:39:24 -05:00
parent 7a652b8998
commit 8f5196f7d6
35 changed files with 25977 additions and 5 deletions

View File

@@ -670,7 +670,18 @@ export async function applyPendingMigrations(url: string): Promise<void> {
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;
throw new Error(
`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");

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -267,6 +267,20 @@
"when": 1773756922363,
"tag": "0037_friendly_eddie_brock",
"breakpoints": true
},
{
"idx": 38,
"version": "7",
"when": 1773926116580,
"tag": "0038_fat_magneto",
"breakpoints": true
},
{
"idx": 39,
"version": "7",
"when": 1773927102783,
"tag": "0039_eager_shotgun",
"breakpoints": true
}
]
}

View File

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

View File

@@ -1,3 +1,4 @@
import { sql } from "drizzle-orm";
import {
type AnyPgColumn,
pgTable,
@@ -40,6 +41,9 @@ export const issues = pgTable(
createdByUserId: text("created_by_user_id"),
issueNumber: integer("issue_number"),
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),
billingCode: text("billing_code"),
assigneeAdapterOverrides: jsonb("assignee_adapter_overrides").$type<Record<string, unknown>>(),
@@ -68,8 +72,17 @@ export const issues = pgTable(
),
parentIdx: index("issues_company_parent_idx").on(table.companyId, table.parentId),
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),
executionWorkspaceIdx: index("issues_company_execution_workspace_idx").on(table.companyId, table.executionWorkspaceId),
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.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 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 type GoalLevel = (typeof GOAL_LEVELS)[number];
@@ -137,6 +140,34 @@ export const PROJECT_STATUSES = [
] as const;
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 type PauseReason = (typeof PAUSE_REASONS)[number];

View File

@@ -10,9 +10,17 @@ export {
AGENT_ICON_NAMES,
ISSUE_STATUSES,
ISSUE_PRIORITIES,
ISSUE_ORIGIN_KINDS,
GOAL_LEVELS,
GOAL_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,
PROJECT_COLORS,
APPROVAL_TYPES,
@@ -69,9 +77,17 @@ export {
type AgentIconName,
type IssueStatus,
type IssuePriority,
type IssueOriginKind,
type GoalLevel,
type GoalStatus,
type ProjectStatus,
type RoutineStatus,
type RoutineConcurrencyPolicy,
type RoutineCatchUpPolicy,
type RoutineTriggerKind,
type RoutineTriggerSigningMode,
type RoutineRunStatus,
type RoutineRunSource,
type PauseReason,
type ApprovalType,
type ApprovalStatus,
@@ -258,6 +274,14 @@ export type {
AgentEnvConfig,
CompanySecret,
SecretProviderDescriptor,
Routine,
RoutineTrigger,
RoutineRun,
RoutineTriggerSecretMaterial,
RoutineDetail,
RoutineRunSummary,
RoutineExecutionIssueOrigin,
RoutineListItem,
JsonSchema,
PluginJobDeclaration,
PluginWebhookDeclaration,
@@ -389,9 +413,21 @@ export {
createSecretSchema,
rotateSecretSchema,
updateSecretSchema,
createRoutineSchema,
updateRoutineSchema,
createRoutineTriggerSchema,
updateRoutineTriggerSchema,
runRoutineSchema,
rotateRoutineTriggerSecretSchema,
type CreateSecret,
type RotateSecret,
type UpdateSecret,
type CreateRoutine,
type UpdateRoutine,
type CreateRoutineTrigger,
type UpdateRoutineTrigger,
type RunRoutine,
type RotateRoutineTriggerSecret,
createCostEventSchema,
createFinanceEventSchema,
updateBudgetSchema,

View File

@@ -104,6 +104,16 @@ export type {
CompanySecret,
SecretProviderDescriptor,
} 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 { FinanceEvent, FinanceSummary, FinanceByBiller, FinanceByKind } from "./finance.js";
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 { Project, ProjectWorkspace } from "./project.js";
import type { ExecutionWorkspace, IssueExecutionWorkspaceSettings } from "./workspace-runtime.js";
@@ -116,6 +116,9 @@ export interface Issue {
createdByUserId: string | null;
issueNumber: number | null;
identifier: string | null;
originKind?: IssueOriginKind;
originId?: string | null;
originRunId?: string | null;
requestDepth: number;
billingCode: string | 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

@@ -184,6 +184,21 @@ export {
type UpdateSecret,
} 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 {
createCostEventSchema,
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

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

View File

@@ -26,7 +26,7 @@ import { createApp } from "./app.js";
import { loadConfig } from "./config.js";
import { logger } from "./middleware/logger.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 { printStartupBanner } from "./startup-banner.js";
import { getBoardClaimWarningUrl, initializeBoardClaimChallenge } from "./board-claim.js";
@@ -526,6 +526,7 @@ export async function startServer(): Promise<StartedServer> {
if (config.heartbeatSchedulerEnabled) {
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,
// 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) => {
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
// 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 { projectRoutes } from "./projects.js";
export { issueRoutes } from "./issues.js";
export { routineRoutes } from "./routines.js";
export { goalRoutes } from "./goals.js";
export { approvalRoutes } from "./approvals.js";
export { secretRoutes } from "./secrets.js";

View File

@@ -27,6 +27,7 @@ import {
documentService,
logActivity,
projectService,
routineService,
workProductService,
} from "../services/index.js";
import { logger } from "../middleware/logger.js";
@@ -49,6 +50,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
const executionWorkspacesSvc = executionWorkspaceService(db);
const workProductsSvc = workProductService(db);
const documentsSvc = documentService(db);
const routinesSvc = routineService(db);
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: MAX_ATTACHMENT_BYTES, files: 1 },
@@ -236,6 +238,10 @@ export function issueRoutes(db: Db, storage: StorageService) {
projectId: req.query.projectId as string | undefined,
parentId: req.query.parentId 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,
});
res.json(result);
@@ -855,6 +861,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
res.status(404).json({ error: "Issue not found" });
return;
}
await routinesSvc.syncRunStatusForIssue(issue.id);
// Build activity details with previous values for changed fields
const previous: Record<string, unknown> = {};

View File

@@ -0,0 +1,244 @@
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 { 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);
async 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 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;
}
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;
}
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;
}
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.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;
}
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

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

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 {
agents,
@@ -68,6 +68,9 @@ export interface IssueFilters {
projectId?: string;
parentId?: string;
labelId?: string;
originKind?: string;
originId?: string;
includeRoutineExecutions?: boolean;
q?: string;
}
@@ -516,6 +519,8 @@ export function issueService(db: Db) {
}
if (filters?.projectId) conditions.push(eq(issues.projectId, filters.projectId));
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) {
const labeledIssueIds = await db
.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));
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),
isNull(issues.hiddenAt),
unreadForUserCondition(companyId, userId),
ne(issues.originKind, "routine_execution"),
];
if (status) {
const statuses = status.split(",").map((s) => s.trim()).filter(Boolean);
@@ -753,6 +762,7 @@ export function issueService(db: Db) {
const values = {
...issueData,
originKind: issueData.originKind ?? "manual",
goalId: resolveIssueGoalId({
projectId: issueData.projectId,
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,
getByName,
resolveSecretValue,
create: async (
companyId: string,

View File

@@ -13,6 +13,8 @@ import { Projects } from "./pages/Projects";
import { ProjectDetail } from "./pages/ProjectDetail";
import { Issues } from "./pages/Issues";
import { IssueDetail } from "./pages/IssueDetail";
import { Routines } from "./pages/Routines";
import { RoutineDetail } from "./pages/RoutineDetail";
import { ExecutionWorkspaceDetail } from "./pages/ExecutionWorkspaceDetail";
import { Goals } from "./pages/Goals";
import { GoalDetail } from "./pages/GoalDetail";
@@ -149,6 +151,8 @@ function boardRoutes() {
<Route path="issues/done" element={<Navigate to="/issues" replace />} />
<Route path="issues/recent" element={<Navigate to="/issues" replace />} />
<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="goals" element={<Goals />} />
<Route path="goals/:goalId" element={<GoalDetail />} />
@@ -313,6 +317,8 @@ export function App() {
<Route path="companies" element={<UnprefixedBoardRedirect />} />
<Route path="issues" 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="settings" element={<LegacySettingsRedirect />} />
<Route path="settings/*" element={<LegacySettingsRedirect />} />

View File

@@ -22,7 +22,14 @@ export interface IssueForRun {
}
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`),
runsForIssue: (issueId: string) => api.get<RunForIssue[]>(`/issues/${issueId}/runs`),
issuesForRun: (runId: string) => api.get<IssueForRun[]>(`/heartbeat-runs/${runId}/issues`),

View File

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

View File

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

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

@@ -0,0 +1,57 @@
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),
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

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

View File

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

View File

@@ -48,6 +48,12 @@ export const queryKeys = {
activeRun: (issueId: string) => ["issues", "active-run", 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: {
list: (companyId: string, filters?: Record<string, string | boolean | undefined>) =>
["execution-workspaces", companyId, filters ?? {}] as const,

View File

@@ -0,0 +1,914 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Link, useLocation, useNavigate, useParams } from "@/lib/router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Clock3,
Copy,
Play,
RefreshCw,
Repeat,
Save,
Webhook,
Zap,
} from "lucide-react";
import { routinesApi, type RoutineTriggerResponse, type RotateRoutineTriggerResponse } from "../api/routines";
import { issuesApi } from "../api/issues";
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 { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton";
import { IssueRow } from "../components/IssueRow";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Badge } from "@/components/ui/badge";
import { timeAgo } from "../lib/timeAgo";
import type { RoutineTrigger } from "@paperclipai/shared";
const priorities = ["critical", "high", "medium", "low"];
const routineStatuses = ["active", "paused", "archived"];
const concurrencyPolicies = ["coalesce_if_active", "always_enqueue", "skip_if_active"];
const catchUpPolicies = ["skip_missed", "enqueue_missed_with_cap"];
const triggerKinds = ["schedule", "webhook", "api"];
const signingModes = ["bearer", "hmac_sha256"];
const routineTabs = ["triggers", "runs", "issues", "activity"] as const;
const concurrencyPolicyDescriptions: Record<string, string> = {
coalesce_if_active: "Keep one follow-up run queued while an active run is still working.",
always_enqueue: "Queue every trigger occurrence, even if several runs stack up.",
skip_if_active: "Drop overlapping trigger occurrences while the routine is already active.",
};
const catchUpPolicyDescriptions: Record<string, string> = {
skip_missed: "Ignore schedule windows that were missed while the routine or scheduler was paused.",
enqueue_missed_with_cap: "Catch up missed schedule windows with a capped backlog after recovery.",
};
const signingModeDescriptions: Record<string, string> = {
bearer: "Expect a shared bearer token in the Authorization header.",
hmac_sha256: "Expect an HMAC SHA-256 signature over the request using the shared secret.",
};
type RoutineTab = (typeof routineTabs)[number];
type SecretMessage = {
title: string;
webhookUrl: string;
webhookSecret: string;
};
function isRoutineTab(value: string | null): value is RoutineTab {
return value !== null && routineTabs.includes(value as RoutineTab);
}
function getRoutineTabFromSearch(search: string): RoutineTab {
const tab = new URLSearchParams(search).get("tab");
return isRoutineTab(tab) ? tab : "triggers";
}
function formatActivityDetailValue(value: unknown): string {
if (value === null) return "null";
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
if (Array.isArray(value)) return value.length === 0 ? "[]" : value.map((item) => formatActivityDetailValue(item)).join(", ");
try {
return JSON.stringify(value);
} catch {
return "[unserializable]";
}
}
function TriggerEditor({
trigger,
onSave,
onRotate,
}: {
trigger: RoutineTrigger;
onSave: (id: string, patch: Record<string, unknown>) => void;
onRotate: (id: string) => void;
}) {
const [draft, setDraft] = useState({
label: trigger.label ?? "",
enabled: trigger.enabled ? "true" : "false",
cronExpression: trigger.cronExpression ?? "",
timezone: trigger.timezone ?? "UTC",
signingMode: trigger.signingMode ?? "bearer",
replayWindowSec: String(trigger.replayWindowSec ?? 300),
});
useEffect(() => {
setDraft({
label: trigger.label ?? "",
enabled: trigger.enabled ? "true" : "false",
cronExpression: trigger.cronExpression ?? "",
timezone: trigger.timezone ?? "UTC",
signingMode: trigger.signingMode ?? "bearer",
replayWindowSec: String(trigger.replayWindowSec ?? 300),
});
}, [trigger]);
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
{trigger.kind === "schedule" ? <Clock3 className="h-4 w-4" /> : trigger.kind === "webhook" ? <Webhook className="h-4 w-4" /> : <Zap className="h-4 w-4" />}
{trigger.label ?? trigger.kind}
</CardTitle>
<CardDescription>
{trigger.kind === "schedule" && trigger.nextRunAt
? `Next run ${new Date(trigger.nextRunAt).toLocaleString()}`
: trigger.kind === "webhook"
? "Public webhook trigger"
: "Authenticated API/manual trigger"}
</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Label</Label>
<Input
value={draft.label}
onChange={(event) => setDraft((current) => ({ ...current, label: event.target.value }))}
/>
</div>
<div className="space-y-2">
<Label>Enabled</Label>
<Select value={draft.enabled} onValueChange={(enabled) => setDraft((current) => ({ ...current, enabled }))}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">Enabled</SelectItem>
<SelectItem value="false">Paused</SelectItem>
</SelectContent>
</Select>
</div>
{trigger.kind === "schedule" && (
<>
<div className="space-y-2">
<Label>Cron</Label>
<Input
value={draft.cronExpression}
onChange={(event) => setDraft((current) => ({ ...current, cronExpression: event.target.value }))}
placeholder="0 10 * * *"
/>
</div>
<div className="space-y-2">
<Label>Timezone</Label>
<Input
value={draft.timezone}
onChange={(event) => setDraft((current) => ({ ...current, timezone: event.target.value }))}
placeholder="America/Chicago"
/>
</div>
</>
)}
{trigger.kind === "webhook" && (
<>
<div className="space-y-2">
<Label>Signing mode</Label>
<Select
value={draft.signingMode}
onValueChange={(signingMode) => setDraft((current) => ({ ...current, signingMode }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{signingModes.map((mode) => (
<SelectItem key={mode} value={mode}>{mode}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Replay window seconds</Label>
<Input
value={draft.replayWindowSec}
onChange={(event) => setDraft((current) => ({ ...current, replayWindowSec: event.target.value }))}
/>
</div>
</>
)}
<div className="md:col-span-2 flex flex-wrap items-center gap-2">
<Button
variant="outline"
onClick={() =>
onSave(trigger.id, {
label: draft.label.trim() || null,
enabled: draft.enabled === "true",
...(trigger.kind === "schedule"
? { cronExpression: draft.cronExpression.trim(), timezone: draft.timezone.trim() }
: {}),
...(trigger.kind === "webhook"
? {
signingMode: draft.signingMode,
replayWindowSec: Number(draft.replayWindowSec || "300"),
}
: {}),
})
}
>
<Save className="mr-2 h-4 w-4" />
Save trigger
</Button>
{trigger.kind === "webhook" && (
<Button variant="outline" onClick={() => onRotate(trigger.id)}>
<RefreshCw className="mr-2 h-4 w-4" />
Rotate secret
</Button>
)}
{trigger.lastResult && <span className="text-sm text-muted-foreground">Last result: {trigger.lastResult}</span>}
</div>
</CardContent>
</Card>
);
}
export function RoutineDetail() {
const { routineId } = useParams<{ routineId: string }>();
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
const navigate = useNavigate();
const location = useLocation();
const { pushToast } = useToast();
const hydratedRoutineIdRef = useRef<string | null>(null);
const [secretMessage, setSecretMessage] = useState<SecretMessage | null>(null);
const [newTrigger, setNewTrigger] = useState({
kind: "schedule",
label: "",
cronExpression: "0 10 * * *",
timezone: "UTC",
signingMode: "bearer",
replayWindowSec: "300",
});
const [editDraft, setEditDraft] = useState({
title: "",
description: "",
projectId: "",
assigneeAgentId: "",
priority: "medium",
status: "active",
concurrencyPolicy: "coalesce_if_active",
catchUpPolicy: "skip_missed",
});
const activeTab = useMemo(() => getRoutineTabFromSearch(location.search), [location.search]);
const { data: routine, isLoading, error } = useQuery({
queryKey: queryKeys.routines.detail(routineId!),
queryFn: () => routinesApi.get(routineId!),
enabled: !!routineId,
});
const { data: routineRuns } = useQuery({
queryKey: queryKeys.routines.runs(routineId!),
queryFn: () => routinesApi.listRuns(routineId!),
enabled: !!routineId,
});
const relatedActivityIds = useMemo(
() => ({
triggerIds: routine?.triggers.map((trigger) => trigger.id) ?? [],
runIds: routineRuns?.map((run) => run.id) ?? [],
}),
[routine?.triggers, routineRuns],
);
const { data: executionIssues } = useQuery({
queryKey: ["routine-execution-issues", selectedCompanyId, routineId],
queryFn: () =>
issuesApi.list(selectedCompanyId!, {
originKind: "routine_execution",
originId: routineId!,
includeRoutineExecutions: true,
}),
enabled: !!selectedCompanyId && !!routineId,
});
const { data: activity } = useQuery({
queryKey: [
...queryKeys.routines.activity(selectedCompanyId!, routineId!),
relatedActivityIds.triggerIds.join(","),
relatedActivityIds.runIds.join(","),
],
queryFn: () => routinesApi.activity(selectedCompanyId!, routineId!, relatedActivityIds),
enabled: !!selectedCompanyId && !!routineId && !!routine,
});
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,
});
const routineDefaults = useMemo(
() =>
routine
? {
title: routine.title,
description: routine.description ?? "",
projectId: routine.projectId,
assigneeAgentId: routine.assigneeAgentId,
priority: routine.priority,
status: routine.status,
concurrencyPolicy: routine.concurrencyPolicy,
catchUpPolicy: routine.catchUpPolicy,
}
: null,
[routine],
);
const isEditDirty = useMemo(() => {
if (!routineDefaults) return false;
return (
editDraft.title !== routineDefaults.title ||
editDraft.description !== routineDefaults.description ||
editDraft.projectId !== routineDefaults.projectId ||
editDraft.assigneeAgentId !== routineDefaults.assigneeAgentId ||
editDraft.priority !== routineDefaults.priority ||
editDraft.status !== routineDefaults.status ||
editDraft.concurrencyPolicy !== routineDefaults.concurrencyPolicy ||
editDraft.catchUpPolicy !== routineDefaults.catchUpPolicy
);
}, [editDraft, routineDefaults]);
useEffect(() => {
if (!routine) return;
setBreadcrumbs([{ label: "Routines", href: "/routines" }, { label: routine.title }]);
if (!routineDefaults) return;
const changedRoutine = hydratedRoutineIdRef.current !== routine.id;
if (changedRoutine || !isEditDirty) {
setEditDraft(routineDefaults);
hydratedRoutineIdRef.current = routine.id;
}
}, [routine, routineDefaults, isEditDirty, setBreadcrumbs]);
const copySecretValue = async (label: string, value: string) => {
try {
await navigator.clipboard.writeText(value);
pushToast({ title: `${label} copied`, tone: "success" });
} catch (error) {
pushToast({
title: `Failed to copy ${label.toLowerCase()}`,
body: error instanceof Error ? error.message : "Clipboard access was denied.",
tone: "error",
});
}
};
const setActiveTab = (value: string) => {
if (!routineId || !isRoutineTab(value)) return;
const params = new URLSearchParams(location.search);
if (value === "triggers") {
params.delete("tab");
} else {
params.set("tab", value);
}
const search = params.toString();
navigate(
{
pathname: location.pathname,
search: search ? `?${search}` : "",
},
{ replace: true },
);
};
const saveRoutine = useMutation({
mutationFn: () =>
routinesApi.update(routineId!, {
...editDraft,
description: editDraft.description.trim() || null,
}),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(routineId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.routines.activity(selectedCompanyId!, routineId!) }),
]);
},
onError: (error) => {
pushToast({
title: "Failed to save routine",
body: error instanceof Error ? error.message : "Paperclip could not save the routine.",
tone: "error",
});
},
});
const runRoutine = useMutation({
mutationFn: () => routinesApi.run(routineId!),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(routineId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.routines.runs(routineId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.routines.activity(selectedCompanyId!, routineId!) }),
]);
},
onError: (error) => {
pushToast({
title: "Routine run failed",
body: error instanceof Error ? error.message : "Paperclip could not start the routine run.",
tone: "error",
});
},
});
const createTrigger = useMutation({
mutationFn: async (): Promise<RoutineTriggerResponse> =>
routinesApi.createTrigger(routineId!, {
kind: newTrigger.kind,
label: newTrigger.label.trim() || null,
...(newTrigger.kind === "schedule"
? { cronExpression: newTrigger.cronExpression.trim(), timezone: newTrigger.timezone.trim() }
: {}),
...(newTrigger.kind === "webhook"
? {
signingMode: newTrigger.signingMode,
replayWindowSec: Number(newTrigger.replayWindowSec || "300"),
}
: {}),
}),
onSuccess: async (result) => {
if (result.secretMaterial) {
setSecretMessage({
title: "Webhook trigger created",
webhookUrl: result.secretMaterial.webhookUrl,
webhookSecret: result.secretMaterial.webhookSecret,
});
}
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(routineId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.routines.activity(selectedCompanyId!, routineId!) }),
]);
},
onError: (error) => {
pushToast({
title: "Failed to add trigger",
body: error instanceof Error ? error.message : "Paperclip could not create the trigger.",
tone: "error",
});
},
});
const updateTrigger = useMutation({
mutationFn: ({ id, patch }: { id: string; patch: Record<string, unknown> }) => routinesApi.updateTrigger(id, patch),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(routineId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.routines.activity(selectedCompanyId!, routineId!) }),
]);
},
onError: (error) => {
pushToast({
title: "Failed to update trigger",
body: error instanceof Error ? error.message : "Paperclip could not update the trigger.",
tone: "error",
});
},
});
const rotateTrigger = useMutation({
mutationFn: (id: string): Promise<RotateRoutineTriggerResponse> => routinesApi.rotateTriggerSecret(id),
onSuccess: async (result) => {
setSecretMessage({
title: "Webhook secret rotated",
webhookUrl: result.secretMaterial.webhookUrl,
webhookSecret: result.secretMaterial.webhookSecret,
});
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(routineId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.routines.activity(selectedCompanyId!, routineId!) }),
]);
},
onError: (error) => {
pushToast({
title: "Failed to rotate webhook secret",
body: error instanceof Error ? error.message : "Paperclip could not rotate the webhook secret.",
tone: "error",
});
},
});
const agentName = useMemo(
() => new Map((agents ?? []).map((agent) => [agent.id, agent.name])),
[agents],
);
const projectName = useMemo(
() => new Map((projects ?? []).map((project) => [project.id, project.name])),
[projects],
);
if (!selectedCompanyId) {
return <EmptyState icon={Repeat} message="Select a company to view routines." />;
}
if (isLoading) {
return <PageSkeleton variant="issues-list" />;
}
if (error || !routine) {
return (
<Card>
<CardContent className="pt-6 text-sm text-destructive">
{error instanceof Error ? error.message : "Routine not found"}
</CardContent>
</Card>
);
}
return (
<div className="space-y-6">
{secretMessage && (
<Card className="border-blue-500/30 bg-blue-500/5">
<CardHeader>
<CardTitle className="text-base">{secretMessage.title}</CardTitle>
<CardDescription>
Save this now. Paperclip will not show the secret value again.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="space-y-1">
<Label>Webhook URL</Label>
<div className="flex items-center gap-2">
<Input value={secretMessage.webhookUrl} readOnly />
<Button variant="outline" onClick={() => copySecretValue("Webhook URL", secretMessage.webhookUrl)}>
<Copy className="h-4 w-4" />
Copy URL
</Button>
</div>
</div>
<div className="space-y-1">
<Label>Secret</Label>
<div className="flex items-center gap-2">
<Input value={secretMessage.webhookSecret} readOnly />
<Button variant="outline" onClick={() => copySecretValue("Webhook secret", secretMessage.webhookSecret)}>
<Copy className="h-4 w-4" />
Copy secret
</Button>
</div>
</div>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<CardTitle>{routine.title}</CardTitle>
<CardDescription>
Project {projectName.get(routine.projectId) ?? routine.projectId.slice(0, 8)} · Assignee {agentName.get(routine.assigneeAgentId) ?? routine.assigneeAgentId.slice(0, 8)}
</CardDescription>
</div>
<div className="flex gap-2">
<Badge variant={routine.status === "active" ? "default" : "secondary"}>
{routine.status.replaceAll("_", " ")}
</Badge>
<Button onClick={() => runRoutine.mutate()} disabled={runRoutine.isPending}>
<Play className="mr-2 h-4 w-4" />
Run now
</Button>
</div>
</div>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<div className="space-y-2 md:col-span-2">
<Label>Title</Label>
<Input value={editDraft.title} onChange={(event) => setEditDraft((current) => ({ ...current, title: event.target.value }))} />
</div>
<div className="space-y-2 md:col-span-2">
<Label>Instructions</Label>
<Textarea
rows={4}
value={editDraft.description}
onChange={(event) => setEditDraft((current) => ({ ...current, description: event.target.value }))}
/>
</div>
<div className="space-y-2">
<Label>Project</Label>
<Select value={editDraft.projectId} onValueChange={(projectId) => setEditDraft((current) => ({ ...current, projectId }))}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(projects ?? []).map((project) => (
<SelectItem key={project.id} value={project.id}>{project.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Assignee</Label>
<Select value={editDraft.assigneeAgentId} onValueChange={(assigneeAgentId) => setEditDraft((current) => ({ ...current, assigneeAgentId }))}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(agents ?? []).map((agent) => (
<SelectItem key={agent.id} value={agent.id}>{agent.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Status</Label>
<Select value={editDraft.status} onValueChange={(status) => setEditDraft((current) => ({ ...current, status }))}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{routineStatuses.map((status) => (
<SelectItem key={status} value={status}>{status.replaceAll("_", " ")}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Priority</Label>
<Select value={editDraft.priority} onValueChange={(priority) => setEditDraft((current) => ({ ...current, priority }))}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{priorities.map((priority) => (
<SelectItem key={priority} value={priority}>{priority}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Concurrency</Label>
<Select value={editDraft.concurrencyPolicy} onValueChange={(concurrencyPolicy) => setEditDraft((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[editDraft.concurrencyPolicy]}
</p>
</div>
<div className="space-y-2">
<Label>Catch-up</Label>
<Select value={editDraft.catchUpPolicy} onValueChange={(catchUpPolicy) => setEditDraft((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[editDraft.catchUpPolicy]}
</p>
</div>
<div className="md:col-span-2 flex items-center justify-between">
<div className="text-sm text-muted-foreground">
{routine.activeIssue ? (
<span>
Active issue:{" "}
<Link to={`/issues/${routine.activeIssue.identifier ?? routine.activeIssue.id}`} className="hover:underline">
{routine.activeIssue.identifier ?? routine.activeIssue.id.slice(0, 8)}
</Link>
</span>
) : (
"No active execution issue."
)}
</div>
<div className="flex items-center gap-3">
{isEditDirty && (
<span className="text-xs text-amber-600">
Unsaved routine edits stay local until you save.
</span>
)}
<Button onClick={() => saveRoutine.mutate()} disabled={saveRoutine.isPending}>
<Save className="mr-2 h-4 w-4" />
Save routine
</Button>
</div>
</div>
</CardContent>
</Card>
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
<TabsList variant="line" className="w-full justify-start gap-1">
<TabsTrigger value="triggers">Triggers</TabsTrigger>
<TabsTrigger value="runs">Runs</TabsTrigger>
<TabsTrigger value="issues">Execution Issues</TabsTrigger>
<TabsTrigger value="activity">Activity</TabsTrigger>
</TabsList>
<TabsContent value="triggers" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Add Trigger</CardTitle>
<CardDescription>
Schedules, public webhooks, or authenticated internal runs all flow into the same routine run log.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Trigger kind</Label>
<Select value={newTrigger.kind} onValueChange={(kind) => setNewTrigger((current) => ({ ...current, kind }))}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{triggerKinds.map((kind) => (
<SelectItem key={kind} value={kind}>{kind}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Label</Label>
<Input value={newTrigger.label} onChange={(event) => setNewTrigger((current) => ({ ...current, label: event.target.value }))} />
</div>
{newTrigger.kind === "schedule" && (
<>
<div className="space-y-2">
<Label>Cron</Label>
<Input value={newTrigger.cronExpression} onChange={(event) => setNewTrigger((current) => ({ ...current, cronExpression: event.target.value }))} />
<p className="text-xs text-muted-foreground">
Five fields, minute first. Example: <code>0 10 * * 1-5</code> runs at 10:00 on weekdays.
</p>
</div>
<div className="space-y-2">
<Label>Timezone</Label>
<Input value={newTrigger.timezone} onChange={(event) => setNewTrigger((current) => ({ ...current, timezone: event.target.value }))} />
<p className="text-xs text-muted-foreground">
Use an IANA timezone such as <code>America/Chicago</code> so schedules follow local time.
</p>
</div>
</>
)}
{newTrigger.kind === "webhook" && (
<>
<div className="space-y-2">
<Label>Signing mode</Label>
<Select value={newTrigger.signingMode} onValueChange={(signingMode) => setNewTrigger((current) => ({ ...current, signingMode }))}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{signingModes.map((mode) => (
<SelectItem key={mode} value={mode}>{mode}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{signingModeDescriptions[newTrigger.signingMode]}
</p>
</div>
<div className="space-y-2">
<Label>Replay window seconds</Label>
<Input value={newTrigger.replayWindowSec} onChange={(event) => setNewTrigger((current) => ({ ...current, replayWindowSec: event.target.value }))} />
<p className="text-xs text-muted-foreground">
Reject webhook requests that arrive too late. A common starting point is 300 seconds.
</p>
</div>
</>
)}
<div className="md:col-span-2 flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Webhook triggers return a one-time URL and secret. Copy them immediately.
</p>
<Button onClick={() => createTrigger.mutate()} disabled={createTrigger.isPending}>
{createTrigger.isPending ? "Adding..." : "Add trigger"}
</Button>
</div>
</CardContent>
</Card>
<div className="grid gap-4">
{routine.triggers.length === 0 ? (
<EmptyState icon={Repeat} message="No triggers configured yet. Add the first trigger above to make this routine run." />
) : (
routine.triggers.map((trigger) => (
<TriggerEditor
key={trigger.id}
trigger={trigger}
onSave={(id, patch) => updateTrigger.mutate({ id, patch })}
onRotate={(id) => rotateTrigger.mutate(id)}
/>
))
)}
</div>
</TabsContent>
<TabsContent value="runs">
<Card>
<CardHeader>
<CardTitle>Run History</CardTitle>
<CardDescription>Every trigger occurrence is captured here, whether it created work or was coalesced.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{(routineRuns ?? []).length === 0 ? (
<p className="text-sm text-muted-foreground">No runs yet.</p>
) : (
(routineRuns ?? []).map((run) => (
<div key={run.id} className="rounded-lg border border-border p-3">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">{run.source}</Badge>
<Badge variant={run.status === "failed" ? "destructive" : "secondary"}>
{run.status.replaceAll("_", " ")}
</Badge>
<span className="text-sm text-muted-foreground">{timeAgo(run.triggeredAt)}</span>
</div>
{run.trigger && (
<p className="mt-2 text-sm text-muted-foreground">
Trigger: {run.trigger.label ?? run.trigger.kind}
</p>
)}
{run.linkedIssue && (
<Link to={`/issues/${run.linkedIssue.identifier ?? run.linkedIssue.id}`} className="mt-2 block text-sm hover:underline">
{run.linkedIssue.identifier ?? run.linkedIssue.id.slice(0, 8)} · {run.linkedIssue.title}
</Link>
)}
{run.failureReason && (
<p className="mt-2 text-sm text-destructive">{run.failureReason}</p>
)}
</div>
))
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="issues">
<Card>
<CardHeader>
<CardTitle>Execution Issues</CardTitle>
<CardDescription>These are the actual issue records created from the routine.</CardDescription>
</CardHeader>
<CardContent>
{(executionIssues ?? []).length === 0 ? (
<p className="text-sm text-muted-foreground">No execution issues yet.</p>
) : (
<div>
{(executionIssues ?? []).map((issue) => (
<IssueRow key={issue.id} issue={issue} />
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="activity">
<Card>
<CardHeader>
<CardTitle>Activity</CardTitle>
<CardDescription>Routine-level mutations and operator actions.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{(activity ?? []).length === 0 ? (
<p className="text-sm text-muted-foreground">No activity yet.</p>
) : (
(activity ?? []).map((event) => (
<div key={event.id} className="rounded-lg border border-border p-3 text-sm">
<div className="flex items-center justify-between gap-3">
<span className="font-medium">{event.action.replaceAll(".", " ")}</span>
<span className="text-muted-foreground">{timeAgo(event.createdAt)}</span>
</div>
{event.details && Object.keys(event.details).length > 0 && (
<div className="mt-2 flex flex-wrap gap-2 text-xs text-muted-foreground">
{Object.entries(event.details).map(([key, value]) => (
<span key={key} className="rounded-full border border-border bg-muted/40 px-2 py-1">
<span className="font-medium text-foreground/80">{key.replaceAll("_", " ")}:</span>{" "}
{formatActivityDetailValue(value)}
</span>
))}
</div>
)}
</div>
))
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}

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

@@ -0,0 +1,372 @@
import { useEffect, useMemo, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Link, useNavigate } from "@/lib/router";
import { Repeat, Plus, Play, Clock3, Webhook } 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 { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { timeAgo } from "../lib/timeAgo";
const priorities = ["critical", "high", "medium", "low"];
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 with a capped backlog after recovery.",
};
function triggerIcon(kind: string) {
if (kind === "schedule") return <Clock3 className="h-3.5 w-3.5" />;
if (kind === "webhook") return <Webhook className="h-3.5 w-3.5" />;
return <Play className="h-3.5 w-3.5" />;
}
export function Routines() {
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
const navigate = useNavigate();
const { pushToast } = useToast();
const [runningRoutineId, setRunningRoutineId] = useState<string | null>(null);
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,
});
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",
});
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 runRoutine = useMutation({
mutationFn: (id: string) => routinesApi.run(id),
onMutate: (id) => {
setRunningRoutineId(id);
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) });
},
onSettled: () => {
setRunningRoutineId(null);
},
onError: (error) => {
pushToast({
title: "Routine run failed",
body: error instanceof Error ? error.message : "Paperclip could not start the routine run.",
tone: "error",
});
},
});
if (!selectedCompanyId) {
return <EmptyState icon={Repeat} message="Select a company to view routines." />;
}
if (isLoading) {
return <PageSkeleton variant="issues-list" />;
}
const agentName = new Map((agents ?? []).map((agent) => [agent.id, agent.name]));
const projectName = new Map((projects ?? []).map((project) => [project.id, project.name]));
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Create Routine</CardTitle>
<CardDescription>
Define recurring work once, then add the first trigger on the next screen to make it live.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<div className="space-y-2 md:col-span-2">
<Label htmlFor="routine-title">Title</Label>
<Input
id="routine-title"
value={draft.title}
onChange={(event) => setDraft((current) => ({ ...current, title: event.target.value }))}
placeholder="Review the last 24 hours of merged code"
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="routine-description">Instructions</Label>
<Textarea
id="routine-description"
value={draft.description}
onChange={(event) => setDraft((current) => ({ ...current, description: event.target.value }))}
rows={4}
placeholder="Summarize noteworthy changes, update docs if needed, and leave a concise report."
/>
</div>
<div className="space-y-2">
<Label>Project</Label>
<Select value={draft.projectId} onValueChange={(projectId) => setDraft((current) => ({ ...current, projectId }))}>
<SelectTrigger>
<SelectValue placeholder="Choose project" />
</SelectTrigger>
<SelectContent>
{(projects ?? []).map((project) => (
<SelectItem key={project.id} value={project.id}>{project.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Assignee</Label>
<Select
value={draft.assigneeAgentId}
onValueChange={(assigneeAgentId) => setDraft((current) => ({ ...current, assigneeAgentId }))}
>
<SelectTrigger>
<SelectValue placeholder="Choose assignee" />
</SelectTrigger>
<SelectContent>
{(agents ?? []).map((agent) => (
<SelectItem key={agent.id} value={agent.id}>{agent.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Priority</Label>
<Select value={draft.priority} onValueChange={(priority) => setDraft((current) => ({ ...current, priority }))}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{priorities.map((priority) => (
<SelectItem key={priority} value={priority}>{priority.replace("_", " ")}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Concurrency</Label>
<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">
<Label>Catch-up</Label>
<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 className="md:col-span-2 flex items-center justify-between">
<p className="text-sm text-muted-foreground">
After creation, Paperclip takes you straight to trigger setup for schedule, webhook, or API entrypoints.
</p>
<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>
</div>
{createRoutine.isError && (
<p className="md:col-span-2 text-sm text-destructive">
{createRoutine.error instanceof Error ? createRoutine.error.message : "Failed to create routine"}
</p>
)}
</CardContent>
</Card>
{error && (
<Card>
<CardContent className="pt-6 text-sm text-destructive">
{error instanceof Error ? error.message : "Failed to load routines"}
</CardContent>
</Card>
)}
<div className="grid gap-4">
{(routines ?? []).length === 0 ? (
<EmptyState
icon={Repeat}
message="No routines yet. Create the first recurring workflow above."
/>
) : (
(routines ?? []).map((routine) => (
<Card key={routine.id}>
<CardContent className="flex flex-col gap-4 pt-6 md:flex-row md:items-start md:justify-between">
<div className="space-y-3 min-w-0">
<div className="flex flex-wrap items-center gap-2">
<Link to={`/routines/${routine.id}`} className="text-base font-medium hover:underline">
{routine.title}
</Link>
<Badge variant={routine.status === "active" ? "default" : "secondary"}>
{routine.status.replaceAll("_", " ")}
</Badge>
<Badge variant="outline">{routine.priority}</Badge>
</div>
{routine.description && (
<p className="line-clamp-2 text-sm text-muted-foreground">
{routine.description}
</p>
)}
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<span>Project: {projectName.get(routine.projectId) ?? routine.projectId.slice(0, 8)}</span>
<span>Assignee: {agentName.get(routine.assigneeAgentId) ?? routine.assigneeAgentId.slice(0, 8)}</span>
<span>Concurrency: {routine.concurrencyPolicy.replaceAll("_", " ")}</span>
</div>
<div className="flex flex-wrap gap-2">
{routine.triggers.length === 0 ? (
<Badge variant="outline">No triggers</Badge>
) : (
routine.triggers.map((trigger) => (
<Badge key={trigger.id} variant="outline" className="gap-1">
{triggerIcon(trigger.kind)}
{trigger.label ?? trigger.kind}
{!trigger.enabled && " paused"}
</Badge>
))
)}
</div>
</div>
<div className="flex shrink-0 flex-col gap-3 md:min-w-[250px]">
<div className="rounded-lg border border-border bg-muted/30 p-3 text-sm">
<p className="font-medium">Last run</p>
{routine.lastRun ? (
<div className="mt-1 space-y-1 text-muted-foreground">
<p>{routine.lastRun.status.replaceAll("_", " ")}</p>
<p>{timeAgo(routine.lastRun.triggeredAt)}</p>
</div>
) : (
<p className="mt-1 text-muted-foreground">No executions yet.</p>
)}
</div>
<div className="rounded-lg border border-border bg-muted/30 p-3 text-sm">
<p className="font-medium">Active execution issue</p>
{routine.activeIssue ? (
<Link to={`/issues/${routine.activeIssue.identifier ?? routine.activeIssue.id}`} className="mt-1 block text-muted-foreground hover:underline">
{routine.activeIssue.identifier ?? routine.activeIssue.id.slice(0, 8)} · {routine.activeIssue.title}
</Link>
) : (
<p className="mt-1 text-muted-foreground">Nothing open.</p>
)}
</div>
<div className="flex gap-2">
<Button
variant="outline"
className="flex-1"
onClick={() => runRoutine.mutate(routine.id)}
disabled={runningRoutineId === routine.id}
>
<Play className="mr-2 h-4 w-4" />
{runningRoutineId === routine.id ? "Running..." : "Run now"}
</Button>
<Button asChild className="flex-1">
<Link to={`/routines/${routine.id}`}>Open</Link>
</Button>
</div>
</div>
</CardContent>
</Card>
))
)}
</div>
</div>
);
}