Add routines automation workflows
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -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(", ")}`,
|
||||
|
||||
97
packages/db/src/migrations/0038_fat_magneto.sql
Normal file
97
packages/db/src/migrations/0038_fat_magneto.sql
Normal 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");
|
||||
5
packages/db/src/migrations/0039_eager_shotgun.sql
Normal file
5
packages/db/src/migrations/0039_eager_shotgun.sql
Normal 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");
|
||||
11312
packages/db/src/migrations/meta/0038_snapshot.json
Normal file
11312
packages/db/src/migrations/meta/0038_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
11355
packages/db/src/migrations/meta/0039_snapshot.json
Normal file
11355
packages/db/src/migrations/meta/0039_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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')`,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
110
packages/db/src/schema/routines.ts
Normal file
110
packages/db/src/schema/routines.ts
Normal 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),
|
||||
}),
|
||||
);
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
123
packages/shared/src/types/routine.ts
Normal file
123
packages/shared/src/types/routine.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
72
packages/shared/src/validators/routine.ts
Normal file
72
packages/shared/src/validators/routine.ts
Normal 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>;
|
||||
@@ -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));
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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> = {};
|
||||
|
||||
244
server/src/routes/routines.ts
Normal file
244
server/src/routes/routines.ts
Normal 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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
1114
server/src/services/routines.ts
Normal file
1114
server/src/services/routines.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -159,6 +159,7 @@ export function secretService(db: Db) {
|
||||
|
||||
getById,
|
||||
getByName,
|
||||
resolveSecretValue,
|
||||
|
||||
create: async (
|
||||
companyId: string,
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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`),
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
57
ui/src/api/routines.ts
Normal 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(),
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ const BOARD_ROUTE_ROOTS = new Set([
|
||||
"agents",
|
||||
"projects",
|
||||
"issues",
|
||||
"routines",
|
||||
"goals",
|
||||
"approvals",
|
||||
"costs",
|
||||
|
||||
@@ -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,
|
||||
|
||||
914
ui/src/pages/RoutineDetail.tsx
Normal file
914
ui/src/pages/RoutineDetail.tsx
Normal 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
372
ui/src/pages/Routines.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user