Merge pull request #1351 from paperclipai/paperclip-routines
WIP: routines management, triggers, and execution flow
This commit is contained in:
@@ -670,7 +670,18 @@ export async function applyPendingMigrations(url: string): Promise<void> {
|
|||||||
await sql.end();
|
await sql.end();
|
||||||
}
|
}
|
||||||
|
|
||||||
const bootstrappedState = await inspectMigrations(url);
|
let bootstrappedState = await inspectMigrations(url);
|
||||||
|
if (bootstrappedState.status === "upToDate") return;
|
||||||
|
if (bootstrappedState.reason === "pending-migrations") {
|
||||||
|
const repair = await reconcilePendingMigrationHistory(url);
|
||||||
|
if (repair.repairedMigrations.length > 0) {
|
||||||
|
bootstrappedState = await inspectMigrations(url);
|
||||||
|
}
|
||||||
|
if (bootstrappedState.status === "needsMigrations" && bootstrappedState.reason === "pending-migrations") {
|
||||||
|
await applyPendingMigrationsManually(url, bootstrappedState.pendingMigrations);
|
||||||
|
bootstrappedState = await inspectMigrations(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (bootstrappedState.status === "upToDate") return;
|
if (bootstrappedState.status === "upToDate") return;
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to bootstrap migrations: ${bootstrappedState.pendingMigrations.join(", ")}`,
|
`Failed to bootstrap migrations: ${bootstrappedState.pendingMigrations.join(", ")}`,
|
||||||
|
|||||||
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");
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
DROP INDEX "issues_open_routine_execution_uq";--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "issues_open_routine_execution_uq" ON "issues" USING btree ("company_id","origin_kind","origin_id") WHERE "issues"."origin_kind" = 'routine_execution'
|
||||||
|
and "issues"."origin_id" is not null
|
||||||
|
and "issues"."hidden_at" is null
|
||||||
|
and "issues"."execution_run_id" is not null
|
||||||
|
and "issues"."status" in ('backlog', 'todo', 'in_progress', 'in_review', 'blocked');
|
||||||
File diff suppressed because it is too large
Load Diff
11393
packages/db/src/migrations/meta/0041_snapshot.json
Normal file
11393
packages/db/src/migrations/meta/0041_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -278,16 +278,37 @@
|
|||||||
{
|
{
|
||||||
"idx": 39,
|
"idx": 39,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1774011294562,
|
"when": 1773926116580,
|
||||||
"tag": "0039_curly_maria_hill",
|
"tag": "0038_fat_magneto",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 40,
|
"idx": 40,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
|
"when": 1773927102783,
|
||||||
|
"tag": "0039_eager_shotgun",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 41,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1774011294562,
|
||||||
|
"tag": "0039_curly_maria_hill",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 42,
|
||||||
|
"version": "7",
|
||||||
"when": 1774031825634,
|
"when": 1774031825634,
|
||||||
"tag": "0040_spotty_the_renegades",
|
"tag": "0040_spotty_the_renegades",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 43,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1774008910991,
|
||||||
|
"tag": "0041_reflective_captain_universe",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -23,6 +23,7 @@ export { workspaceRuntimeServices } from "./workspace_runtime_services.js";
|
|||||||
export { projectGoals } from "./project_goals.js";
|
export { projectGoals } from "./project_goals.js";
|
||||||
export { goals } from "./goals.js";
|
export { goals } from "./goals.js";
|
||||||
export { issues } from "./issues.js";
|
export { issues } from "./issues.js";
|
||||||
|
export { routines, routineTriggers, routineRuns } from "./routines.js";
|
||||||
export { issueWorkProducts } from "./issue_work_products.js";
|
export { issueWorkProducts } from "./issue_work_products.js";
|
||||||
export { labels } from "./labels.js";
|
export { labels } from "./labels.js";
|
||||||
export { issueLabels } from "./issue_labels.js";
|
export { issueLabels } from "./issue_labels.js";
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { sql } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
type AnyPgColumn,
|
type AnyPgColumn,
|
||||||
pgTable,
|
pgTable,
|
||||||
@@ -40,6 +41,9 @@ export const issues = pgTable(
|
|||||||
createdByUserId: text("created_by_user_id"),
|
createdByUserId: text("created_by_user_id"),
|
||||||
issueNumber: integer("issue_number"),
|
issueNumber: integer("issue_number"),
|
||||||
identifier: text("identifier"),
|
identifier: text("identifier"),
|
||||||
|
originKind: text("origin_kind").notNull().default("manual"),
|
||||||
|
originId: text("origin_id"),
|
||||||
|
originRunId: text("origin_run_id"),
|
||||||
requestDepth: integer("request_depth").notNull().default(0),
|
requestDepth: integer("request_depth").notNull().default(0),
|
||||||
billingCode: text("billing_code"),
|
billingCode: text("billing_code"),
|
||||||
assigneeAdapterOverrides: jsonb("assignee_adapter_overrides").$type<Record<string, unknown>>(),
|
assigneeAdapterOverrides: jsonb("assignee_adapter_overrides").$type<Record<string, unknown>>(),
|
||||||
@@ -68,8 +72,18 @@ export const issues = pgTable(
|
|||||||
),
|
),
|
||||||
parentIdx: index("issues_company_parent_idx").on(table.companyId, table.parentId),
|
parentIdx: index("issues_company_parent_idx").on(table.companyId, table.parentId),
|
||||||
projectIdx: index("issues_company_project_idx").on(table.companyId, table.projectId),
|
projectIdx: index("issues_company_project_idx").on(table.companyId, table.projectId),
|
||||||
|
originIdx: index("issues_company_origin_idx").on(table.companyId, table.originKind, table.originId),
|
||||||
projectWorkspaceIdx: index("issues_company_project_workspace_idx").on(table.companyId, table.projectWorkspaceId),
|
projectWorkspaceIdx: index("issues_company_project_workspace_idx").on(table.companyId, table.projectWorkspaceId),
|
||||||
executionWorkspaceIdx: index("issues_company_execution_workspace_idx").on(table.companyId, table.executionWorkspaceId),
|
executionWorkspaceIdx: index("issues_company_execution_workspace_idx").on(table.companyId, table.executionWorkspaceId),
|
||||||
identifierIdx: uniqueIndex("issues_identifier_idx").on(table.identifier),
|
identifierIdx: uniqueIndex("issues_identifier_idx").on(table.identifier),
|
||||||
|
openRoutineExecutionIdx: uniqueIndex("issues_open_routine_execution_uq")
|
||||||
|
.on(table.companyId, table.originKind, table.originId)
|
||||||
|
.where(
|
||||||
|
sql`${table.originKind} = 'routine_execution'
|
||||||
|
and ${table.originId} is not null
|
||||||
|
and ${table.hiddenAt} is null
|
||||||
|
and ${table.executionRunId} is not null
|
||||||
|
and ${table.status} in ('backlog', 'todo', 'in_progress', 'in_review', 'blocked')`,
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
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 const ISSUE_PRIORITIES = ["critical", "high", "medium", "low"] as const;
|
||||||
export type IssuePriority = (typeof ISSUE_PRIORITIES)[number];
|
export type IssuePriority = (typeof ISSUE_PRIORITIES)[number];
|
||||||
|
|
||||||
|
export const ISSUE_ORIGIN_KINDS = ["manual", "routine_execution"] as const;
|
||||||
|
export type IssueOriginKind = (typeof ISSUE_ORIGIN_KINDS)[number];
|
||||||
|
|
||||||
export const GOAL_LEVELS = ["company", "team", "agent", "task"] as const;
|
export const GOAL_LEVELS = ["company", "team", "agent", "task"] as const;
|
||||||
export type GoalLevel = (typeof GOAL_LEVELS)[number];
|
export type GoalLevel = (typeof GOAL_LEVELS)[number];
|
||||||
|
|
||||||
@@ -137,6 +140,34 @@ export const PROJECT_STATUSES = [
|
|||||||
] as const;
|
] as const;
|
||||||
export type ProjectStatus = (typeof PROJECT_STATUSES)[number];
|
export type ProjectStatus = (typeof PROJECT_STATUSES)[number];
|
||||||
|
|
||||||
|
export const ROUTINE_STATUSES = ["active", "paused", "archived"] as const;
|
||||||
|
export type RoutineStatus = (typeof ROUTINE_STATUSES)[number];
|
||||||
|
|
||||||
|
export const ROUTINE_CONCURRENCY_POLICIES = ["coalesce_if_active", "always_enqueue", "skip_if_active"] as const;
|
||||||
|
export type RoutineConcurrencyPolicy = (typeof ROUTINE_CONCURRENCY_POLICIES)[number];
|
||||||
|
|
||||||
|
export const ROUTINE_CATCH_UP_POLICIES = ["skip_missed", "enqueue_missed_with_cap"] as const;
|
||||||
|
export type RoutineCatchUpPolicy = (typeof ROUTINE_CATCH_UP_POLICIES)[number];
|
||||||
|
|
||||||
|
export const ROUTINE_TRIGGER_KINDS = ["schedule", "webhook", "api"] as const;
|
||||||
|
export type RoutineTriggerKind = (typeof ROUTINE_TRIGGER_KINDS)[number];
|
||||||
|
|
||||||
|
export const ROUTINE_TRIGGER_SIGNING_MODES = ["bearer", "hmac_sha256"] as const;
|
||||||
|
export type RoutineTriggerSigningMode = (typeof ROUTINE_TRIGGER_SIGNING_MODES)[number];
|
||||||
|
|
||||||
|
export const ROUTINE_RUN_STATUSES = [
|
||||||
|
"received",
|
||||||
|
"coalesced",
|
||||||
|
"skipped",
|
||||||
|
"issue_created",
|
||||||
|
"completed",
|
||||||
|
"failed",
|
||||||
|
] as const;
|
||||||
|
export type RoutineRunStatus = (typeof ROUTINE_RUN_STATUSES)[number];
|
||||||
|
|
||||||
|
export const ROUTINE_RUN_SOURCES = ["schedule", "manual", "api", "webhook"] as const;
|
||||||
|
export type RoutineRunSource = (typeof ROUTINE_RUN_SOURCES)[number];
|
||||||
|
|
||||||
export const PAUSE_REASONS = ["manual", "budget", "system"] as const;
|
export const PAUSE_REASONS = ["manual", "budget", "system"] as const;
|
||||||
export type PauseReason = (typeof PAUSE_REASONS)[number];
|
export type PauseReason = (typeof PAUSE_REASONS)[number];
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,17 @@ export {
|
|||||||
AGENT_ICON_NAMES,
|
AGENT_ICON_NAMES,
|
||||||
ISSUE_STATUSES,
|
ISSUE_STATUSES,
|
||||||
ISSUE_PRIORITIES,
|
ISSUE_PRIORITIES,
|
||||||
|
ISSUE_ORIGIN_KINDS,
|
||||||
GOAL_LEVELS,
|
GOAL_LEVELS,
|
||||||
GOAL_STATUSES,
|
GOAL_STATUSES,
|
||||||
PROJECT_STATUSES,
|
PROJECT_STATUSES,
|
||||||
|
ROUTINE_STATUSES,
|
||||||
|
ROUTINE_CONCURRENCY_POLICIES,
|
||||||
|
ROUTINE_CATCH_UP_POLICIES,
|
||||||
|
ROUTINE_TRIGGER_KINDS,
|
||||||
|
ROUTINE_TRIGGER_SIGNING_MODES,
|
||||||
|
ROUTINE_RUN_STATUSES,
|
||||||
|
ROUTINE_RUN_SOURCES,
|
||||||
PAUSE_REASONS,
|
PAUSE_REASONS,
|
||||||
PROJECT_COLORS,
|
PROJECT_COLORS,
|
||||||
APPROVAL_TYPES,
|
APPROVAL_TYPES,
|
||||||
@@ -69,9 +77,17 @@ export {
|
|||||||
type AgentIconName,
|
type AgentIconName,
|
||||||
type IssueStatus,
|
type IssueStatus,
|
||||||
type IssuePriority,
|
type IssuePriority,
|
||||||
|
type IssueOriginKind,
|
||||||
type GoalLevel,
|
type GoalLevel,
|
||||||
type GoalStatus,
|
type GoalStatus,
|
||||||
type ProjectStatus,
|
type ProjectStatus,
|
||||||
|
type RoutineStatus,
|
||||||
|
type RoutineConcurrencyPolicy,
|
||||||
|
type RoutineCatchUpPolicy,
|
||||||
|
type RoutineTriggerKind,
|
||||||
|
type RoutineTriggerSigningMode,
|
||||||
|
type RoutineRunStatus,
|
||||||
|
type RoutineRunSource,
|
||||||
type PauseReason,
|
type PauseReason,
|
||||||
type ApprovalType,
|
type ApprovalType,
|
||||||
type ApprovalStatus,
|
type ApprovalStatus,
|
||||||
@@ -262,6 +278,14 @@ export type {
|
|||||||
AgentEnvConfig,
|
AgentEnvConfig,
|
||||||
CompanySecret,
|
CompanySecret,
|
||||||
SecretProviderDescriptor,
|
SecretProviderDescriptor,
|
||||||
|
Routine,
|
||||||
|
RoutineTrigger,
|
||||||
|
RoutineRun,
|
||||||
|
RoutineTriggerSecretMaterial,
|
||||||
|
RoutineDetail,
|
||||||
|
RoutineRunSummary,
|
||||||
|
RoutineExecutionIssueOrigin,
|
||||||
|
RoutineListItem,
|
||||||
JsonSchema,
|
JsonSchema,
|
||||||
PluginJobDeclaration,
|
PluginJobDeclaration,
|
||||||
PluginWebhookDeclaration,
|
PluginWebhookDeclaration,
|
||||||
@@ -396,9 +420,21 @@ export {
|
|||||||
createSecretSchema,
|
createSecretSchema,
|
||||||
rotateSecretSchema,
|
rotateSecretSchema,
|
||||||
updateSecretSchema,
|
updateSecretSchema,
|
||||||
|
createRoutineSchema,
|
||||||
|
updateRoutineSchema,
|
||||||
|
createRoutineTriggerSchema,
|
||||||
|
updateRoutineTriggerSchema,
|
||||||
|
runRoutineSchema,
|
||||||
|
rotateRoutineTriggerSecretSchema,
|
||||||
type CreateSecret,
|
type CreateSecret,
|
||||||
type RotateSecret,
|
type RotateSecret,
|
||||||
type UpdateSecret,
|
type UpdateSecret,
|
||||||
|
type CreateRoutine,
|
||||||
|
type UpdateRoutine,
|
||||||
|
type CreateRoutineTrigger,
|
||||||
|
type UpdateRoutineTrigger,
|
||||||
|
type RunRoutine,
|
||||||
|
type RotateRoutineTriggerSecret,
|
||||||
createCostEventSchema,
|
createCostEventSchema,
|
||||||
createFinanceEventSchema,
|
createFinanceEventSchema,
|
||||||
updateBudgetSchema,
|
updateBudgetSchema,
|
||||||
|
|||||||
@@ -107,6 +107,16 @@ export type {
|
|||||||
CompanySecret,
|
CompanySecret,
|
||||||
SecretProviderDescriptor,
|
SecretProviderDescriptor,
|
||||||
} from "./secrets.js";
|
} from "./secrets.js";
|
||||||
|
export type {
|
||||||
|
Routine,
|
||||||
|
RoutineTrigger,
|
||||||
|
RoutineRun,
|
||||||
|
RoutineTriggerSecretMaterial,
|
||||||
|
RoutineDetail,
|
||||||
|
RoutineRunSummary,
|
||||||
|
RoutineExecutionIssueOrigin,
|
||||||
|
RoutineListItem,
|
||||||
|
} from "./routine.js";
|
||||||
export type { CostEvent, CostSummary, CostByAgent, CostByProviderModel, CostByBiller, CostByAgentModel, CostWindowSpendRow, CostByProject } from "./cost.js";
|
export type { CostEvent, CostSummary, CostByAgent, CostByProviderModel, CostByBiller, CostByAgentModel, CostWindowSpendRow, CostByProject } from "./cost.js";
|
||||||
export type { FinanceEvent, FinanceSummary, FinanceByBiller, FinanceByKind } from "./finance.js";
|
export type { FinanceEvent, FinanceSummary, FinanceByBiller, FinanceByKind } from "./finance.js";
|
||||||
export type {
|
export type {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { IssuePriority, IssueStatus } from "../constants.js";
|
import type { IssueOriginKind, IssuePriority, IssueStatus } from "../constants.js";
|
||||||
import type { Goal } from "./goal.js";
|
import type { Goal } from "./goal.js";
|
||||||
import type { Project, ProjectWorkspace } from "./project.js";
|
import type { Project, ProjectWorkspace } from "./project.js";
|
||||||
import type { ExecutionWorkspace, IssueExecutionWorkspaceSettings } from "./workspace-runtime.js";
|
import type { ExecutionWorkspace, IssueExecutionWorkspaceSettings } from "./workspace-runtime.js";
|
||||||
@@ -116,6 +116,9 @@ export interface Issue {
|
|||||||
createdByUserId: string | null;
|
createdByUserId: string | null;
|
||||||
issueNumber: number | null;
|
issueNumber: number | null;
|
||||||
identifier: string | null;
|
identifier: string | null;
|
||||||
|
originKind?: IssueOriginKind;
|
||||||
|
originId?: string | null;
|
||||||
|
originRunId?: string | null;
|
||||||
requestDepth: number;
|
requestDepth: number;
|
||||||
billingCode: string | null;
|
billingCode: string | null;
|
||||||
assigneeAdapterOverrides: IssueAssigneeAdapterOverrides | null;
|
assigneeAdapterOverrides: IssueAssigneeAdapterOverrides | null;
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
@@ -188,6 +188,21 @@ export {
|
|||||||
type UpdateSecret,
|
type UpdateSecret,
|
||||||
} from "./secret.js";
|
} from "./secret.js";
|
||||||
|
|
||||||
|
export {
|
||||||
|
createRoutineSchema,
|
||||||
|
updateRoutineSchema,
|
||||||
|
createRoutineTriggerSchema,
|
||||||
|
updateRoutineTriggerSchema,
|
||||||
|
runRoutineSchema,
|
||||||
|
rotateRoutineTriggerSecretSchema,
|
||||||
|
type CreateRoutine,
|
||||||
|
type UpdateRoutine,
|
||||||
|
type CreateRoutineTrigger,
|
||||||
|
type UpdateRoutineTrigger,
|
||||||
|
type RunRoutine,
|
||||||
|
type RotateRoutineTriggerSecret,
|
||||||
|
} from "./routine.js";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
createCostEventSchema,
|
createCostEventSchema,
|
||||||
updateBudgetSchema,
|
updateBudgetSchema,
|
||||||
|
|||||||
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>;
|
||||||
@@ -151,6 +151,8 @@ describe("agent permission routes", () => {
|
|||||||
mockAccessService.listPrincipalGrants.mockResolvedValue([]);
|
mockAccessService.listPrincipalGrants.mockResolvedValue([]);
|
||||||
mockAccessService.ensureMembership.mockResolvedValue(undefined);
|
mockAccessService.ensureMembership.mockResolvedValue(undefined);
|
||||||
mockAccessService.setPrincipalPermission.mockResolvedValue(undefined);
|
mockAccessService.setPrincipalPermission.mockResolvedValue(undefined);
|
||||||
|
mockCompanySkillService.listRuntimeSkillEntries.mockResolvedValue([]);
|
||||||
|
mockCompanySkillService.resolveRequestedSkillKeys.mockImplementation(async (_companyId, requested) => requested);
|
||||||
mockBudgetService.upsertPolicy.mockResolvedValue(undefined);
|
mockBudgetService.upsertPolicy.mockResolvedValue(undefined);
|
||||||
mockAgentInstructionsService.materializeManagedBundle.mockImplementation(
|
mockAgentInstructionsService.materializeManagedBundle.mockImplementation(
|
||||||
async (agent: Record<string, unknown>, files: Record<string, string>) => ({
|
async (agent: Record<string, unknown>, files: Record<string, string>) => ({
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ const mockAgentService = vi.hoisted(() => ({
|
|||||||
const mockAccessService = vi.hoisted(() => ({
|
const mockAccessService = vi.hoisted(() => ({
|
||||||
canUser: vi.fn(),
|
canUser: vi.fn(),
|
||||||
hasPermission: vi.fn(),
|
hasPermission: vi.fn(),
|
||||||
|
getMembership: vi.fn(),
|
||||||
|
listPrincipalGrants: vi.fn(),
|
||||||
ensureMembership: vi.fn(),
|
ensureMembership: vi.fn(),
|
||||||
setPrincipalPermission: vi.fn(),
|
setPrincipalPermission: vi.fn(),
|
||||||
}));
|
}));
|
||||||
@@ -203,6 +205,8 @@ describe("agent skill routes", () => {
|
|||||||
mockLogActivity.mockResolvedValue(undefined);
|
mockLogActivity.mockResolvedValue(undefined);
|
||||||
mockAccessService.canUser.mockResolvedValue(true);
|
mockAccessService.canUser.mockResolvedValue(true);
|
||||||
mockAccessService.hasPermission.mockResolvedValue(true);
|
mockAccessService.hasPermission.mockResolvedValue(true);
|
||||||
|
mockAccessService.getMembership.mockResolvedValue(null);
|
||||||
|
mockAccessService.listPrincipalGrants.mockResolvedValue([]);
|
||||||
mockAccessService.ensureMembership.mockResolvedValue(undefined);
|
mockAccessService.ensureMembership.mockResolvedValue(undefined);
|
||||||
mockAccessService.setPrincipalPermission.mockResolvedValue(undefined);
|
mockAccessService.setPrincipalPermission.mockResolvedValue(undefined);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import request from "supertest";
|
import request from "supertest";
|
||||||
import { boardMutationGuard } from "../middleware/board-mutation-guard.js";
|
import { boardMutationGuard } from "../middleware/board-mutation-guard.js";
|
||||||
@@ -61,8 +61,21 @@ describe("boardMutationGuard", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("does not block authenticated agent mutations", async () => {
|
it("does not block authenticated agent mutations", async () => {
|
||||||
const app = createApp("agent");
|
const middleware = boardMutationGuard();
|
||||||
const res = await request(app).post("/mutate").send({ ok: true });
|
const req = {
|
||||||
expect(res.status).toBe(204);
|
method: "POST",
|
||||||
|
actor: { type: "agent", agentId: "agent-1" },
|
||||||
|
header: () => undefined,
|
||||||
|
} as any;
|
||||||
|
const res = {
|
||||||
|
status: vi.fn().mockReturnThis(),
|
||||||
|
json: vi.fn(),
|
||||||
|
} as any;
|
||||||
|
const next = vi.fn();
|
||||||
|
|
||||||
|
middleware(req, res, next);
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalledOnce();
|
||||||
|
expect(res.status).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import express from "express";
|
import express from "express";
|
||||||
import request from "supertest";
|
import request from "supertest";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { companyRoutes } from "../routes/companies.js";
|
|
||||||
import { errorHandler } from "../middleware/index.js";
|
|
||||||
|
|
||||||
const mockCompanyService = vi.hoisted(() => ({
|
const mockCompanyService = vi.hoisted(() => ({
|
||||||
list: vi.fn(),
|
list: vi.fn(),
|
||||||
@@ -44,7 +42,9 @@ vi.mock("../services/index.js", () => ({
|
|||||||
logActivity: mockLogActivity,
|
logActivity: mockLogActivity,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function createApp(actor: Record<string, unknown>) {
|
async function createApp(actor: Record<string, unknown>) {
|
||||||
|
const { companyRoutes } = await import("../routes/companies.js");
|
||||||
|
const { errorHandler } = await import("../middleware/index.js");
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use((req, _res, next) => {
|
app.use((req, _res, next) => {
|
||||||
@@ -58,6 +58,7 @@ function createApp(actor: Record<string, unknown>) {
|
|||||||
|
|
||||||
describe("company portability routes", () => {
|
describe("company portability routes", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
mockAgentService.getById.mockReset();
|
mockAgentService.getById.mockReset();
|
||||||
mockCompanyPortabilityService.exportBundle.mockReset();
|
mockCompanyPortabilityService.exportBundle.mockReset();
|
||||||
mockCompanyPortabilityService.previewExport.mockReset();
|
mockCompanyPortabilityService.previewExport.mockReset();
|
||||||
@@ -72,7 +73,7 @@ describe("company portability routes", () => {
|
|||||||
companyId: "11111111-1111-4111-8111-111111111111",
|
companyId: "11111111-1111-4111-8111-111111111111",
|
||||||
role: "engineer",
|
role: "engineer",
|
||||||
});
|
});
|
||||||
const app = createApp({
|
const app = await createApp({
|
||||||
type: "agent",
|
type: "agent",
|
||||||
agentId: "agent-1",
|
agentId: "agent-1",
|
||||||
companyId: "11111111-1111-4111-8111-111111111111",
|
companyId: "11111111-1111-4111-8111-111111111111",
|
||||||
@@ -104,7 +105,7 @@ describe("company portability routes", () => {
|
|||||||
warnings: [],
|
warnings: [],
|
||||||
paperclipExtensionPath: ".paperclip.yaml",
|
paperclipExtensionPath: ".paperclip.yaml",
|
||||||
});
|
});
|
||||||
const app = createApp({
|
const app = await createApp({
|
||||||
type: "agent",
|
type: "agent",
|
||||||
agentId: "agent-1",
|
agentId: "agent-1",
|
||||||
companyId: "11111111-1111-4111-8111-111111111111",
|
companyId: "11111111-1111-4111-8111-111111111111",
|
||||||
@@ -128,7 +129,7 @@ describe("company portability routes", () => {
|
|||||||
companyId: "11111111-1111-4111-8111-111111111111",
|
companyId: "11111111-1111-4111-8111-111111111111",
|
||||||
role: "ceo",
|
role: "ceo",
|
||||||
});
|
});
|
||||||
const app = createApp({
|
const app = await createApp({
|
||||||
type: "agent",
|
type: "agent",
|
||||||
agentId: "agent-1",
|
agentId: "agent-1",
|
||||||
companyId: "11111111-1111-4111-8111-111111111111",
|
companyId: "11111111-1111-4111-8111-111111111111",
|
||||||
@@ -151,7 +152,7 @@ describe("company portability routes", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("keeps global import preview routes board-only", async () => {
|
it("keeps global import preview routes board-only", async () => {
|
||||||
const app = createApp({
|
const app = await createApp({
|
||||||
type: "agent",
|
type: "agent",
|
||||||
agentId: "agent-1",
|
agentId: "agent-1",
|
||||||
companyId: "11111111-1111-4111-8111-111111111111",
|
companyId: "11111111-1111-4111-8111-111111111111",
|
||||||
|
|||||||
@@ -83,6 +83,10 @@ vi.mock("../services/agent-instructions.js", () => ({
|
|||||||
agentInstructionsService: () => agentInstructionsSvc,
|
agentInstructionsService: () => agentInstructionsSvc,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../routes/org-chart-svg.js", () => ({
|
||||||
|
renderOrgChartPng: vi.fn(async () => Buffer.from("png")),
|
||||||
|
}));
|
||||||
|
|
||||||
const { companyPortabilityService } = await import("../services/company-portability.js");
|
const { companyPortabilityService } = await import("../services/company-portability.js");
|
||||||
|
|
||||||
function asTextFile(entry: CompanyPortabilityFileEntry | undefined) {
|
function asTextFile(entry: CompanyPortabilityFileEntry | undefined) {
|
||||||
@@ -265,6 +269,7 @@ describe("company portability", () => {
|
|||||||
assetSvc.getById.mockReset();
|
assetSvc.getById.mockReset();
|
||||||
assetSvc.getById.mockResolvedValue(null);
|
assetSvc.getById.mockResolvedValue(null);
|
||||||
assetSvc.create.mockReset();
|
assetSvc.create.mockReset();
|
||||||
|
accessSvc.setPrincipalPermission.mockResolvedValue(undefined);
|
||||||
assetSvc.create.mockResolvedValue({
|
assetSvc.create.mockResolvedValue({
|
||||||
id: "asset-created",
|
id: "asset-created",
|
||||||
});
|
});
|
||||||
|
|||||||
340
server/src/__tests__/routines-e2e.test.ts
Normal file
340
server/src/__tests__/routines-e2e.test.ts
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import net from "node:net";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import express from "express";
|
||||||
|
import request from "supertest";
|
||||||
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
activityLog,
|
||||||
|
agentWakeupRequests,
|
||||||
|
agents,
|
||||||
|
applyPendingMigrations,
|
||||||
|
companies,
|
||||||
|
companyMemberships,
|
||||||
|
createDb,
|
||||||
|
ensurePostgresDatabase,
|
||||||
|
heartbeatRunEvents,
|
||||||
|
heartbeatRuns,
|
||||||
|
instanceSettings,
|
||||||
|
issues,
|
||||||
|
principalPermissionGrants,
|
||||||
|
projects,
|
||||||
|
routineRuns,
|
||||||
|
routines,
|
||||||
|
routineTriggers,
|
||||||
|
} from "@paperclipai/db";
|
||||||
|
import { errorHandler } from "../middleware/index.js";
|
||||||
|
import { accessService } from "../services/access.js";
|
||||||
|
|
||||||
|
vi.mock("../services/index.js", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("../services/index.js")>("../services/index.js");
|
||||||
|
const { randomUUID } = await import("node:crypto");
|
||||||
|
const { eq } = await import("drizzle-orm");
|
||||||
|
const { heartbeatRuns, issues } = await import("@paperclipai/db");
|
||||||
|
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
routineService: (db: any) =>
|
||||||
|
actual.routineService(db, {
|
||||||
|
heartbeat: {
|
||||||
|
wakeup: async (agentId: string, wakeupOpts: any) => {
|
||||||
|
const issueId =
|
||||||
|
(typeof wakeupOpts?.payload?.issueId === "string" && wakeupOpts.payload.issueId) ||
|
||||||
|
(typeof wakeupOpts?.contextSnapshot?.issueId === "string" && wakeupOpts.contextSnapshot.issueId) ||
|
||||||
|
null;
|
||||||
|
if (!issueId) return null;
|
||||||
|
|
||||||
|
const issue = await db
|
||||||
|
.select({ companyId: issues.companyId })
|
||||||
|
.from(issues)
|
||||||
|
.where(eq(issues.id, issueId))
|
||||||
|
.then((rows: Array<{ companyId: string }>) => rows[0] ?? null);
|
||||||
|
if (!issue) return null;
|
||||||
|
|
||||||
|
const queuedRunId = randomUUID();
|
||||||
|
await db.insert(heartbeatRuns).values({
|
||||||
|
id: queuedRunId,
|
||||||
|
companyId: issue.companyId,
|
||||||
|
agentId,
|
||||||
|
invocationSource: wakeupOpts?.source ?? "assignment",
|
||||||
|
triggerDetail: wakeupOpts?.triggerDetail ?? null,
|
||||||
|
status: "queued",
|
||||||
|
contextSnapshot: { ...(wakeupOpts?.contextSnapshot ?? {}), issueId },
|
||||||
|
});
|
||||||
|
await db
|
||||||
|
.update(issues)
|
||||||
|
.set({
|
||||||
|
executionRunId: queuedRunId,
|
||||||
|
executionLockedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(issues.id, issueId));
|
||||||
|
return { id: queuedRunId };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
type EmbeddedPostgresInstance = {
|
||||||
|
initialise(): Promise<void>;
|
||||||
|
start(): Promise<void>;
|
||||||
|
stop(): Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EmbeddedPostgresCtor = new (opts: {
|
||||||
|
databaseDir: string;
|
||||||
|
user: string;
|
||||||
|
password: string;
|
||||||
|
port: number;
|
||||||
|
persistent: boolean;
|
||||||
|
initdbFlags?: string[];
|
||||||
|
onLog?: (message: unknown) => void;
|
||||||
|
onError?: (message: unknown) => void;
|
||||||
|
}) => EmbeddedPostgresInstance;
|
||||||
|
|
||||||
|
async function getEmbeddedPostgresCtor(): Promise<EmbeddedPostgresCtor> {
|
||||||
|
const mod = await import("embedded-postgres");
|
||||||
|
return mod.default as EmbeddedPostgresCtor;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAvailablePort(): Promise<number> {
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const server = net.createServer();
|
||||||
|
server.unref();
|
||||||
|
server.on("error", reject);
|
||||||
|
server.listen(0, "127.0.0.1", () => {
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === "string") {
|
||||||
|
server.close(() => reject(new Error("Failed to allocate test port")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { port } = address;
|
||||||
|
server.close((error) => {
|
||||||
|
if (error) reject(error);
|
||||||
|
else resolve(port);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startTempDatabase() {
|
||||||
|
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-routines-e2e-"));
|
||||||
|
const port = await getAvailablePort();
|
||||||
|
const EmbeddedPostgres = await getEmbeddedPostgresCtor();
|
||||||
|
const instance = new EmbeddedPostgres({
|
||||||
|
databaseDir: dataDir,
|
||||||
|
user: "paperclip",
|
||||||
|
password: "paperclip",
|
||||||
|
port,
|
||||||
|
persistent: true,
|
||||||
|
initdbFlags: ["--encoding=UTF8", "--locale=C"],
|
||||||
|
onLog: () => {},
|
||||||
|
onError: () => {},
|
||||||
|
});
|
||||||
|
await instance.initialise();
|
||||||
|
await instance.start();
|
||||||
|
|
||||||
|
const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`;
|
||||||
|
await ensurePostgresDatabase(adminConnectionString, "paperclip");
|
||||||
|
const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`;
|
||||||
|
await applyPendingMigrations(connectionString);
|
||||||
|
return { connectionString, dataDir, instance };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("routine routes end-to-end", () => {
|
||||||
|
let db!: ReturnType<typeof createDb>;
|
||||||
|
let instance: EmbeddedPostgresInstance | null = null;
|
||||||
|
let dataDir = "";
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const started = await startTempDatabase();
|
||||||
|
db = createDb(started.connectionString);
|
||||||
|
instance = started.instance;
|
||||||
|
dataDir = started.dataDir;
|
||||||
|
}, 20_000);
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await db.delete(activityLog);
|
||||||
|
await db.delete(routineRuns);
|
||||||
|
await db.delete(routineTriggers);
|
||||||
|
await db.delete(heartbeatRunEvents);
|
||||||
|
await db.delete(heartbeatRuns);
|
||||||
|
await db.delete(agentWakeupRequests);
|
||||||
|
await db.delete(issues);
|
||||||
|
await db.delete(principalPermissionGrants);
|
||||||
|
await db.delete(companyMemberships);
|
||||||
|
await db.delete(routines);
|
||||||
|
await db.delete(projects);
|
||||||
|
await db.delete(agents);
|
||||||
|
await db.delete(companies);
|
||||||
|
await db.delete(instanceSettings);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await instance?.stop();
|
||||||
|
if (dataDir) {
|
||||||
|
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createApp(actor: Record<string, unknown>) {
|
||||||
|
const { routineRoutes } = await import("../routes/routines.js");
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use((req, _res, next) => {
|
||||||
|
(req as any).actor = actor;
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
app.use("/api", routineRoutes(db));
|
||||||
|
app.use(errorHandler);
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedFixture() {
|
||||||
|
const companyId = randomUUID();
|
||||||
|
const agentId = randomUUID();
|
||||||
|
const projectId = randomUUID();
|
||||||
|
const userId = randomUUID();
|
||||||
|
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
||||||
|
|
||||||
|
await db.insert(companies).values({
|
||||||
|
id: companyId,
|
||||||
|
name: "Paperclip",
|
||||||
|
issuePrefix,
|
||||||
|
requireBoardApprovalForNewAgents: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.insert(agents).values({
|
||||||
|
id: agentId,
|
||||||
|
companyId,
|
||||||
|
name: "CodexCoder",
|
||||||
|
role: "engineer",
|
||||||
|
status: "active",
|
||||||
|
adapterType: "codex_local",
|
||||||
|
adapterConfig: {},
|
||||||
|
runtimeConfig: {},
|
||||||
|
permissions: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.insert(projects).values({
|
||||||
|
id: projectId,
|
||||||
|
companyId,
|
||||||
|
name: "Routine Project",
|
||||||
|
status: "in_progress",
|
||||||
|
});
|
||||||
|
|
||||||
|
const access = accessService(db);
|
||||||
|
const membership = await access.ensureMembership(companyId, "user", userId, "owner", "active");
|
||||||
|
await access.setMemberPermissions(
|
||||||
|
companyId,
|
||||||
|
membership.id,
|
||||||
|
[{ permissionKey: "tasks:assign" }],
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { companyId, agentId, projectId, userId };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("supports creating, scheduling, and manually running a routine through the API", async () => {
|
||||||
|
const { companyId, agentId, projectId, userId } = await seedFixture();
|
||||||
|
const app = await createApp({
|
||||||
|
type: "board",
|
||||||
|
userId,
|
||||||
|
source: "session",
|
||||||
|
isInstanceAdmin: false,
|
||||||
|
companyIds: [companyId],
|
||||||
|
});
|
||||||
|
|
||||||
|
const createRes = await request(app)
|
||||||
|
.post(`/api/companies/${companyId}/routines`)
|
||||||
|
.send({
|
||||||
|
projectId,
|
||||||
|
title: "Daily standup prep",
|
||||||
|
description: "Summarize blockers and open PRs",
|
||||||
|
assigneeAgentId: agentId,
|
||||||
|
priority: "high",
|
||||||
|
concurrencyPolicy: "coalesce_if_active",
|
||||||
|
catchUpPolicy: "skip_missed",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(createRes.status).toBe(201);
|
||||||
|
expect(createRes.body.title).toBe("Daily standup prep");
|
||||||
|
expect(createRes.body.assigneeAgentId).toBe(agentId);
|
||||||
|
|
||||||
|
const routineId = createRes.body.id as string;
|
||||||
|
|
||||||
|
const triggerRes = await request(app)
|
||||||
|
.post(`/api/routines/${routineId}/triggers`)
|
||||||
|
.send({
|
||||||
|
kind: "schedule",
|
||||||
|
label: "Weekday morning",
|
||||||
|
cronExpression: "0 10 * * 1-5",
|
||||||
|
timezone: "UTC",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(triggerRes.status).toBe(201);
|
||||||
|
expect(triggerRes.body.trigger.kind).toBe("schedule");
|
||||||
|
expect(triggerRes.body.trigger.enabled).toBe(true);
|
||||||
|
expect(triggerRes.body.secretMaterial).toBeNull();
|
||||||
|
|
||||||
|
const runRes = await request(app)
|
||||||
|
.post(`/api/routines/${routineId}/run`)
|
||||||
|
.send({
|
||||||
|
source: "manual",
|
||||||
|
payload: { origin: "e2e-test" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(runRes.status).toBe(202);
|
||||||
|
expect(runRes.body.status).toBe("issue_created");
|
||||||
|
expect(runRes.body.source).toBe("manual");
|
||||||
|
expect(runRes.body.linkedIssueId).toBeTruthy();
|
||||||
|
|
||||||
|
const detailRes = await request(app).get(`/api/routines/${routineId}`);
|
||||||
|
expect(detailRes.status).toBe(200);
|
||||||
|
expect(detailRes.body.triggers).toHaveLength(1);
|
||||||
|
expect(detailRes.body.triggers[0]?.id).toBe(triggerRes.body.trigger.id);
|
||||||
|
expect(detailRes.body.recentRuns).toHaveLength(1);
|
||||||
|
expect(detailRes.body.recentRuns[0]?.id).toBe(runRes.body.id);
|
||||||
|
expect(detailRes.body.activeIssue?.id).toBe(runRes.body.linkedIssueId);
|
||||||
|
|
||||||
|
const runsRes = await request(app).get(`/api/routines/${routineId}/runs?limit=10`);
|
||||||
|
expect(runsRes.status).toBe(200);
|
||||||
|
expect(runsRes.body).toHaveLength(1);
|
||||||
|
expect(runsRes.body[0]?.id).toBe(runRes.body.id);
|
||||||
|
|
||||||
|
const [issue] = await db
|
||||||
|
.select({
|
||||||
|
id: issues.id,
|
||||||
|
originId: issues.originId,
|
||||||
|
originKind: issues.originKind,
|
||||||
|
executionRunId: issues.executionRunId,
|
||||||
|
})
|
||||||
|
.from(issues)
|
||||||
|
.where(eq(issues.id, runRes.body.linkedIssueId));
|
||||||
|
|
||||||
|
expect(issue).toMatchObject({
|
||||||
|
id: runRes.body.linkedIssueId,
|
||||||
|
originId: routineId,
|
||||||
|
originKind: "routine_execution",
|
||||||
|
});
|
||||||
|
expect(issue?.executionRunId).toBeTruthy();
|
||||||
|
|
||||||
|
const actions = await db
|
||||||
|
.select({
|
||||||
|
action: activityLog.action,
|
||||||
|
})
|
||||||
|
.from(activityLog)
|
||||||
|
.where(eq(activityLog.companyId, companyId));
|
||||||
|
|
||||||
|
expect(actions.map((entry) => entry.action)).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
"routine.created",
|
||||||
|
"routine.trigger_created",
|
||||||
|
"routine.run_triggered",
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
271
server/src/__tests__/routines-routes.test.ts
Normal file
271
server/src/__tests__/routines-routes.test.ts
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
import express from "express";
|
||||||
|
import request from "supertest";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { routineRoutes } from "../routes/routines.js";
|
||||||
|
import { errorHandler } from "../middleware/index.js";
|
||||||
|
|
||||||
|
const companyId = "22222222-2222-4222-8222-222222222222";
|
||||||
|
const agentId = "11111111-1111-4111-8111-111111111111";
|
||||||
|
const routineId = "33333333-3333-4333-8333-333333333333";
|
||||||
|
const projectId = "44444444-4444-4444-8444-444444444444";
|
||||||
|
const otherAgentId = "55555555-5555-4555-8555-555555555555";
|
||||||
|
|
||||||
|
const routine = {
|
||||||
|
id: routineId,
|
||||||
|
companyId,
|
||||||
|
projectId,
|
||||||
|
goalId: null,
|
||||||
|
parentIssueId: null,
|
||||||
|
title: "Daily routine",
|
||||||
|
description: null,
|
||||||
|
assigneeAgentId: agentId,
|
||||||
|
priority: "medium",
|
||||||
|
status: "active",
|
||||||
|
concurrencyPolicy: "coalesce_if_active",
|
||||||
|
catchUpPolicy: "skip_missed",
|
||||||
|
createdByAgentId: null,
|
||||||
|
createdByUserId: null,
|
||||||
|
updatedByAgentId: null,
|
||||||
|
updatedByUserId: null,
|
||||||
|
lastTriggeredAt: null,
|
||||||
|
lastEnqueuedAt: null,
|
||||||
|
createdAt: new Date("2026-03-20T00:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-03-20T00:00:00.000Z"),
|
||||||
|
};
|
||||||
|
const pausedRoutine = {
|
||||||
|
...routine,
|
||||||
|
status: "paused",
|
||||||
|
};
|
||||||
|
const trigger = {
|
||||||
|
id: "66666666-6666-4666-8666-666666666666",
|
||||||
|
companyId,
|
||||||
|
routineId,
|
||||||
|
kind: "schedule",
|
||||||
|
label: "weekday",
|
||||||
|
enabled: false,
|
||||||
|
cronExpression: "0 10 * * 1-5",
|
||||||
|
timezone: "UTC",
|
||||||
|
nextRunAt: null,
|
||||||
|
lastFiredAt: null,
|
||||||
|
publicId: null,
|
||||||
|
secretId: null,
|
||||||
|
signingMode: null,
|
||||||
|
replayWindowSec: null,
|
||||||
|
lastRotatedAt: null,
|
||||||
|
lastResult: null,
|
||||||
|
createdByAgentId: null,
|
||||||
|
createdByUserId: null,
|
||||||
|
updatedByAgentId: null,
|
||||||
|
updatedByUserId: null,
|
||||||
|
createdAt: new Date("2026-03-20T00:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-03-20T00:00:00.000Z"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockRoutineService = vi.hoisted(() => ({
|
||||||
|
list: vi.fn(),
|
||||||
|
get: vi.fn(),
|
||||||
|
getDetail: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
listRuns: vi.fn(),
|
||||||
|
createTrigger: vi.fn(),
|
||||||
|
getTrigger: vi.fn(),
|
||||||
|
updateTrigger: vi.fn(),
|
||||||
|
deleteTrigger: vi.fn(),
|
||||||
|
rotateTriggerSecret: vi.fn(),
|
||||||
|
runRoutine: vi.fn(),
|
||||||
|
firePublicTrigger: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockAccessService = vi.hoisted(() => ({
|
||||||
|
canUser: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock("../services/index.js", () => ({
|
||||||
|
accessService: () => mockAccessService,
|
||||||
|
logActivity: mockLogActivity,
|
||||||
|
routineService: () => mockRoutineService,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function createApp(actor: Record<string, unknown>) {
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use((req, _res, next) => {
|
||||||
|
(req as any).actor = actor;
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
app.use("/api", routineRoutes({} as any));
|
||||||
|
app.use(errorHandler);
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("routine routes", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockRoutineService.create.mockResolvedValue(routine);
|
||||||
|
mockRoutineService.get.mockResolvedValue(routine);
|
||||||
|
mockRoutineService.getTrigger.mockResolvedValue(trigger);
|
||||||
|
mockRoutineService.update.mockResolvedValue({ ...routine, assigneeAgentId: otherAgentId });
|
||||||
|
mockRoutineService.runRoutine.mockResolvedValue({
|
||||||
|
id: "run-1",
|
||||||
|
source: "manual",
|
||||||
|
status: "issue_created",
|
||||||
|
});
|
||||||
|
mockAccessService.canUser.mockResolvedValue(false);
|
||||||
|
mockLogActivity.mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires tasks:assign permission for non-admin board routine creation", async () => {
|
||||||
|
const app = createApp({
|
||||||
|
type: "board",
|
||||||
|
userId: "board-user",
|
||||||
|
source: "session",
|
||||||
|
isInstanceAdmin: false,
|
||||||
|
companyIds: [companyId],
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/companies/${companyId}/routines`)
|
||||||
|
.send({
|
||||||
|
projectId,
|
||||||
|
title: "Daily routine",
|
||||||
|
assigneeAgentId: agentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(res.body.error).toContain("tasks:assign");
|
||||||
|
expect(mockRoutineService.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires tasks:assign permission to retarget a routine assignee", async () => {
|
||||||
|
const app = createApp({
|
||||||
|
type: "board",
|
||||||
|
userId: "board-user",
|
||||||
|
source: "session",
|
||||||
|
isInstanceAdmin: false,
|
||||||
|
companyIds: [companyId],
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.patch(`/api/routines/${routineId}`)
|
||||||
|
.send({
|
||||||
|
assigneeAgentId: otherAgentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(res.body.error).toContain("tasks:assign");
|
||||||
|
expect(mockRoutineService.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires tasks:assign permission to reactivate a routine", async () => {
|
||||||
|
mockRoutineService.get.mockResolvedValue(pausedRoutine);
|
||||||
|
const app = createApp({
|
||||||
|
type: "board",
|
||||||
|
userId: "board-user",
|
||||||
|
source: "session",
|
||||||
|
isInstanceAdmin: false,
|
||||||
|
companyIds: [companyId],
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.patch(`/api/routines/${routineId}`)
|
||||||
|
.send({
|
||||||
|
status: "active",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(res.body.error).toContain("tasks:assign");
|
||||||
|
expect(mockRoutineService.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires tasks:assign permission to create a trigger", async () => {
|
||||||
|
const app = createApp({
|
||||||
|
type: "board",
|
||||||
|
userId: "board-user",
|
||||||
|
source: "session",
|
||||||
|
isInstanceAdmin: false,
|
||||||
|
companyIds: [companyId],
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/routines/${routineId}/triggers`)
|
||||||
|
.send({
|
||||||
|
kind: "schedule",
|
||||||
|
cronExpression: "0 10 * * *",
|
||||||
|
timezone: "UTC",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(res.body.error).toContain("tasks:assign");
|
||||||
|
expect(mockRoutineService.createTrigger).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires tasks:assign permission to update a trigger", async () => {
|
||||||
|
const app = createApp({
|
||||||
|
type: "board",
|
||||||
|
userId: "board-user",
|
||||||
|
source: "session",
|
||||||
|
isInstanceAdmin: false,
|
||||||
|
companyIds: [companyId],
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.patch(`/api/routine-triggers/${trigger.id}`)
|
||||||
|
.send({
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(res.body.error).toContain("tasks:assign");
|
||||||
|
expect(mockRoutineService.updateTrigger).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires tasks:assign permission to manually run a routine", async () => {
|
||||||
|
const app = createApp({
|
||||||
|
type: "board",
|
||||||
|
userId: "board-user",
|
||||||
|
source: "session",
|
||||||
|
isInstanceAdmin: false,
|
||||||
|
companyIds: [companyId],
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/routines/${routineId}/run`)
|
||||||
|
.send({});
|
||||||
|
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(res.body.error).toContain("tasks:assign");
|
||||||
|
expect(mockRoutineService.runRoutine).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows routine creation when the board user has tasks:assign", async () => {
|
||||||
|
mockAccessService.canUser.mockResolvedValue(true);
|
||||||
|
const app = createApp({
|
||||||
|
type: "board",
|
||||||
|
userId: "board-user",
|
||||||
|
source: "session",
|
||||||
|
isInstanceAdmin: false,
|
||||||
|
companyIds: [companyId],
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/companies/${companyId}/routines`)
|
||||||
|
.send({
|
||||||
|
projectId,
|
||||||
|
title: "Daily routine",
|
||||||
|
assigneeAgentId: agentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(mockRoutineService.create).toHaveBeenCalledWith(companyId, expect.objectContaining({
|
||||||
|
projectId,
|
||||||
|
title: "Daily routine",
|
||||||
|
assigneeAgentId: agentId,
|
||||||
|
}), {
|
||||||
|
agentId: null,
|
||||||
|
userId: "board-user",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
488
server/src/__tests__/routines-service.test.ts
Normal file
488
server/src/__tests__/routines-service.test.ts
Normal file
@@ -0,0 +1,488 @@
|
|||||||
|
import { createHmac, randomUUID } from "node:crypto";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import net from "node:net";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
activityLog,
|
||||||
|
agents,
|
||||||
|
applyPendingMigrations,
|
||||||
|
companies,
|
||||||
|
companySecrets,
|
||||||
|
companySecretVersions,
|
||||||
|
createDb,
|
||||||
|
ensurePostgresDatabase,
|
||||||
|
heartbeatRuns,
|
||||||
|
issues,
|
||||||
|
projects,
|
||||||
|
routineRuns,
|
||||||
|
routines,
|
||||||
|
routineTriggers,
|
||||||
|
} from "@paperclipai/db";
|
||||||
|
import { issueService } from "../services/issues.ts";
|
||||||
|
import { routineService } from "../services/routines.ts";
|
||||||
|
|
||||||
|
type EmbeddedPostgresInstance = {
|
||||||
|
initialise(): Promise<void>;
|
||||||
|
start(): Promise<void>;
|
||||||
|
stop(): Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EmbeddedPostgresCtor = new (opts: {
|
||||||
|
databaseDir: string;
|
||||||
|
user: string;
|
||||||
|
password: string;
|
||||||
|
port: number;
|
||||||
|
persistent: boolean;
|
||||||
|
initdbFlags?: string[];
|
||||||
|
onLog?: (message: unknown) => void;
|
||||||
|
onError?: (message: unknown) => void;
|
||||||
|
}) => EmbeddedPostgresInstance;
|
||||||
|
|
||||||
|
async function getEmbeddedPostgresCtor(): Promise<EmbeddedPostgresCtor> {
|
||||||
|
const mod = await import("embedded-postgres");
|
||||||
|
return mod.default as EmbeddedPostgresCtor;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAvailablePort(): Promise<number> {
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const server = net.createServer();
|
||||||
|
server.unref();
|
||||||
|
server.on("error", reject);
|
||||||
|
server.listen(0, "127.0.0.1", () => {
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === "string") {
|
||||||
|
server.close(() => reject(new Error("Failed to allocate test port")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { port } = address;
|
||||||
|
server.close((error) => {
|
||||||
|
if (error) reject(error);
|
||||||
|
else resolve(port);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startTempDatabase() {
|
||||||
|
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-routines-service-"));
|
||||||
|
const port = await getAvailablePort();
|
||||||
|
const EmbeddedPostgres = await getEmbeddedPostgresCtor();
|
||||||
|
const instance = new EmbeddedPostgres({
|
||||||
|
databaseDir: dataDir,
|
||||||
|
user: "paperclip",
|
||||||
|
password: "paperclip",
|
||||||
|
port,
|
||||||
|
persistent: true,
|
||||||
|
initdbFlags: ["--encoding=UTF8", "--locale=C"],
|
||||||
|
onLog: () => {},
|
||||||
|
onError: () => {},
|
||||||
|
});
|
||||||
|
await instance.initialise();
|
||||||
|
await instance.start();
|
||||||
|
|
||||||
|
const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`;
|
||||||
|
await ensurePostgresDatabase(adminConnectionString, "paperclip");
|
||||||
|
const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`;
|
||||||
|
await applyPendingMigrations(connectionString);
|
||||||
|
return { connectionString, dataDir, instance };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("routine service live-execution coalescing", () => {
|
||||||
|
let db!: ReturnType<typeof createDb>;
|
||||||
|
let instance: EmbeddedPostgresInstance | null = null;
|
||||||
|
let dataDir = "";
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const started = await startTempDatabase();
|
||||||
|
db = createDb(started.connectionString);
|
||||||
|
instance = started.instance;
|
||||||
|
dataDir = started.dataDir;
|
||||||
|
}, 20_000);
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await db.delete(activityLog);
|
||||||
|
await db.delete(routineRuns);
|
||||||
|
await db.delete(routineTriggers);
|
||||||
|
await db.delete(routines);
|
||||||
|
await db.delete(companySecretVersions);
|
||||||
|
await db.delete(companySecrets);
|
||||||
|
await db.delete(heartbeatRuns);
|
||||||
|
await db.delete(issues);
|
||||||
|
await db.delete(projects);
|
||||||
|
await db.delete(agents);
|
||||||
|
await db.delete(companies);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await instance?.stop();
|
||||||
|
if (dataDir) {
|
||||||
|
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function seedFixture(opts?: {
|
||||||
|
wakeup?: (
|
||||||
|
agentId: string,
|
||||||
|
wakeupOpts: {
|
||||||
|
source?: string;
|
||||||
|
triggerDetail?: string;
|
||||||
|
reason?: string | null;
|
||||||
|
payload?: Record<string, unknown> | null;
|
||||||
|
requestedByActorType?: "user" | "agent" | "system";
|
||||||
|
requestedByActorId?: string | null;
|
||||||
|
contextSnapshot?: Record<string, unknown>;
|
||||||
|
},
|
||||||
|
) => Promise<unknown>;
|
||||||
|
}) {
|
||||||
|
const companyId = randomUUID();
|
||||||
|
const agentId = randomUUID();
|
||||||
|
const projectId = randomUUID();
|
||||||
|
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
||||||
|
const wakeups: Array<{
|
||||||
|
agentId: string;
|
||||||
|
opts: {
|
||||||
|
source?: string;
|
||||||
|
triggerDetail?: string;
|
||||||
|
reason?: string | null;
|
||||||
|
payload?: Record<string, unknown> | null;
|
||||||
|
requestedByActorType?: "user" | "agent" | "system";
|
||||||
|
requestedByActorId?: string | null;
|
||||||
|
contextSnapshot?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
await db.insert(companies).values({
|
||||||
|
id: companyId,
|
||||||
|
name: "Paperclip",
|
||||||
|
issuePrefix,
|
||||||
|
requireBoardApprovalForNewAgents: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.insert(agents).values({
|
||||||
|
id: agentId,
|
||||||
|
companyId,
|
||||||
|
name: "CodexCoder",
|
||||||
|
role: "engineer",
|
||||||
|
status: "active",
|
||||||
|
adapterType: "codex_local",
|
||||||
|
adapterConfig: {},
|
||||||
|
runtimeConfig: {},
|
||||||
|
permissions: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.insert(projects).values({
|
||||||
|
id: projectId,
|
||||||
|
companyId,
|
||||||
|
name: "Routines",
|
||||||
|
status: "in_progress",
|
||||||
|
});
|
||||||
|
|
||||||
|
const svc = routineService(db, {
|
||||||
|
heartbeat: {
|
||||||
|
wakeup: async (wakeupAgentId, wakeupOpts) => {
|
||||||
|
wakeups.push({ agentId: wakeupAgentId, opts: wakeupOpts });
|
||||||
|
if (opts?.wakeup) return opts.wakeup(wakeupAgentId, wakeupOpts);
|
||||||
|
const issueId =
|
||||||
|
(typeof wakeupOpts.payload?.issueId === "string" && wakeupOpts.payload.issueId) ||
|
||||||
|
(typeof wakeupOpts.contextSnapshot?.issueId === "string" && wakeupOpts.contextSnapshot.issueId) ||
|
||||||
|
null;
|
||||||
|
if (!issueId) return null;
|
||||||
|
const queuedRunId = randomUUID();
|
||||||
|
await db.insert(heartbeatRuns).values({
|
||||||
|
id: queuedRunId,
|
||||||
|
companyId,
|
||||||
|
agentId: wakeupAgentId,
|
||||||
|
invocationSource: wakeupOpts.source ?? "assignment",
|
||||||
|
triggerDetail: wakeupOpts.triggerDetail ?? null,
|
||||||
|
status: "queued",
|
||||||
|
contextSnapshot: { ...(wakeupOpts.contextSnapshot ?? {}), issueId },
|
||||||
|
});
|
||||||
|
await db
|
||||||
|
.update(issues)
|
||||||
|
.set({
|
||||||
|
executionRunId: queuedRunId,
|
||||||
|
executionLockedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(issues.id, issueId));
|
||||||
|
return { id: queuedRunId };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const issueSvc = issueService(db);
|
||||||
|
const routine = await svc.create(
|
||||||
|
companyId,
|
||||||
|
{
|
||||||
|
projectId,
|
||||||
|
goalId: null,
|
||||||
|
parentIssueId: null,
|
||||||
|
title: "ascii frog",
|
||||||
|
description: "Run the frog routine",
|
||||||
|
assigneeAgentId: agentId,
|
||||||
|
priority: "medium",
|
||||||
|
status: "active",
|
||||||
|
concurrencyPolicy: "coalesce_if_active",
|
||||||
|
catchUpPolicy: "skip_missed",
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
return { companyId, agentId, issueSvc, projectId, routine, svc, wakeups };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("creates a fresh execution issue when the previous routine issue is open but idle", async () => {
|
||||||
|
const { companyId, issueSvc, routine, svc } = await seedFixture();
|
||||||
|
const previousRunId = randomUUID();
|
||||||
|
const previousIssue = await issueSvc.create(companyId, {
|
||||||
|
projectId: routine.projectId,
|
||||||
|
title: routine.title,
|
||||||
|
description: routine.description,
|
||||||
|
status: "todo",
|
||||||
|
priority: routine.priority,
|
||||||
|
assigneeAgentId: routine.assigneeAgentId,
|
||||||
|
originKind: "routine_execution",
|
||||||
|
originId: routine.id,
|
||||||
|
originRunId: previousRunId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.insert(routineRuns).values({
|
||||||
|
id: previousRunId,
|
||||||
|
companyId,
|
||||||
|
routineId: routine.id,
|
||||||
|
triggerId: null,
|
||||||
|
source: "manual",
|
||||||
|
status: "issue_created",
|
||||||
|
triggeredAt: new Date("2026-03-20T12:00:00.000Z"),
|
||||||
|
linkedIssueId: previousIssue.id,
|
||||||
|
completedAt: new Date("2026-03-20T12:00:00.000Z"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const detailBefore = await svc.getDetail(routine.id);
|
||||||
|
expect(detailBefore?.activeIssue).toBeNull();
|
||||||
|
|
||||||
|
const run = await svc.runRoutine(routine.id, { source: "manual" });
|
||||||
|
expect(run.status).toBe("issue_created");
|
||||||
|
expect(run.linkedIssueId).not.toBe(previousIssue.id);
|
||||||
|
|
||||||
|
const routineIssues = await db
|
||||||
|
.select({
|
||||||
|
id: issues.id,
|
||||||
|
originRunId: issues.originRunId,
|
||||||
|
})
|
||||||
|
.from(issues)
|
||||||
|
.where(eq(issues.originId, routine.id));
|
||||||
|
|
||||||
|
expect(routineIssues).toHaveLength(2);
|
||||||
|
expect(routineIssues.map((issue) => issue.id)).toContain(previousIssue.id);
|
||||||
|
expect(routineIssues.map((issue) => issue.id)).toContain(run.linkedIssueId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wakes the assignee when a routine creates a fresh execution issue", async () => {
|
||||||
|
const { agentId, routine, svc, wakeups } = await seedFixture();
|
||||||
|
|
||||||
|
const run = await svc.runRoutine(routine.id, { source: "manual" });
|
||||||
|
|
||||||
|
expect(run.status).toBe("issue_created");
|
||||||
|
expect(run.linkedIssueId).toBeTruthy();
|
||||||
|
expect(wakeups).toEqual([
|
||||||
|
{
|
||||||
|
agentId,
|
||||||
|
opts: {
|
||||||
|
source: "assignment",
|
||||||
|
triggerDetail: "system",
|
||||||
|
reason: "issue_assigned",
|
||||||
|
payload: { issueId: run.linkedIssueId, mutation: "create" },
|
||||||
|
requestedByActorType: undefined,
|
||||||
|
requestedByActorId: null,
|
||||||
|
contextSnapshot: { issueId: run.linkedIssueId, source: "routine.dispatch" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("waits for the assignee wakeup to be queued before returning the routine run", async () => {
|
||||||
|
let wakeupResolved = false;
|
||||||
|
const { routine, svc } = await seedFixture({
|
||||||
|
wakeup: async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
wakeupResolved = true;
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const run = await svc.runRoutine(routine.id, { source: "manual" });
|
||||||
|
|
||||||
|
expect(run.status).toBe("issue_created");
|
||||||
|
expect(wakeupResolved).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("coalesces only when the existing routine issue has a live execution run", async () => {
|
||||||
|
const { agentId, companyId, issueSvc, routine, svc } = await seedFixture();
|
||||||
|
const previousRunId = randomUUID();
|
||||||
|
const liveHeartbeatRunId = randomUUID();
|
||||||
|
const previousIssue = await issueSvc.create(companyId, {
|
||||||
|
projectId: routine.projectId,
|
||||||
|
title: routine.title,
|
||||||
|
description: routine.description,
|
||||||
|
status: "in_progress",
|
||||||
|
priority: routine.priority,
|
||||||
|
assigneeAgentId: routine.assigneeAgentId,
|
||||||
|
originKind: "routine_execution",
|
||||||
|
originId: routine.id,
|
||||||
|
originRunId: previousRunId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.insert(routineRuns).values({
|
||||||
|
id: previousRunId,
|
||||||
|
companyId,
|
||||||
|
routineId: routine.id,
|
||||||
|
triggerId: null,
|
||||||
|
source: "manual",
|
||||||
|
status: "issue_created",
|
||||||
|
triggeredAt: new Date("2026-03-20T12:00:00.000Z"),
|
||||||
|
linkedIssueId: previousIssue.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.insert(heartbeatRuns).values({
|
||||||
|
id: liveHeartbeatRunId,
|
||||||
|
companyId,
|
||||||
|
agentId,
|
||||||
|
invocationSource: "assignment",
|
||||||
|
triggerDetail: "system",
|
||||||
|
status: "running",
|
||||||
|
contextSnapshot: { issueId: previousIssue.id },
|
||||||
|
startedAt: new Date("2026-03-20T12:01:00.000Z"),
|
||||||
|
});
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(issues)
|
||||||
|
.set({
|
||||||
|
checkoutRunId: liveHeartbeatRunId,
|
||||||
|
executionRunId: liveHeartbeatRunId,
|
||||||
|
executionLockedAt: new Date("2026-03-20T12:01:00.000Z"),
|
||||||
|
})
|
||||||
|
.where(eq(issues.id, previousIssue.id));
|
||||||
|
|
||||||
|
const detailBefore = await svc.getDetail(routine.id);
|
||||||
|
expect(detailBefore?.activeIssue?.id).toBe(previousIssue.id);
|
||||||
|
|
||||||
|
const run = await svc.runRoutine(routine.id, { source: "manual" });
|
||||||
|
expect(run.status).toBe("coalesced");
|
||||||
|
expect(run.linkedIssueId).toBe(previousIssue.id);
|
||||||
|
expect(run.coalescedIntoRunId).toBe(previousRunId);
|
||||||
|
|
||||||
|
const routineIssues = await db
|
||||||
|
.select({ id: issues.id })
|
||||||
|
.from(issues)
|
||||||
|
.where(eq(issues.originId, routine.id));
|
||||||
|
|
||||||
|
expect(routineIssues).toHaveLength(1);
|
||||||
|
expect(routineIssues[0]?.id).toBe(previousIssue.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serializes concurrent dispatches until the first execution issue is linked to a queued run", async () => {
|
||||||
|
const { routine, svc } = await seedFixture({
|
||||||
|
wakeup: async (wakeupAgentId, wakeupOpts) => {
|
||||||
|
const issueId =
|
||||||
|
(typeof wakeupOpts.payload?.issueId === "string" && wakeupOpts.payload.issueId) ||
|
||||||
|
(typeof wakeupOpts.contextSnapshot?.issueId === "string" && wakeupOpts.contextSnapshot.issueId) ||
|
||||||
|
null;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||||
|
if (!issueId) return null;
|
||||||
|
const queuedRunId = randomUUID();
|
||||||
|
await db.insert(heartbeatRuns).values({
|
||||||
|
id: queuedRunId,
|
||||||
|
companyId: routine.companyId,
|
||||||
|
agentId: wakeupAgentId,
|
||||||
|
invocationSource: wakeupOpts.source ?? "assignment",
|
||||||
|
triggerDetail: wakeupOpts.triggerDetail ?? null,
|
||||||
|
status: "queued",
|
||||||
|
contextSnapshot: { ...(wakeupOpts.contextSnapshot ?? {}), issueId },
|
||||||
|
});
|
||||||
|
await db
|
||||||
|
.update(issues)
|
||||||
|
.set({
|
||||||
|
executionRunId: queuedRunId,
|
||||||
|
executionLockedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(issues.id, issueId));
|
||||||
|
return { id: queuedRunId };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [first, second] = await Promise.all([
|
||||||
|
svc.runRoutine(routine.id, { source: "manual" }),
|
||||||
|
svc.runRoutine(routine.id, { source: "manual" }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect([first.status, second.status].sort()).toEqual(["coalesced", "issue_created"]);
|
||||||
|
expect(first.linkedIssueId).toBeTruthy();
|
||||||
|
expect(second.linkedIssueId).toBeTruthy();
|
||||||
|
expect(first.linkedIssueId).toBe(second.linkedIssueId);
|
||||||
|
|
||||||
|
const routineIssues = await db
|
||||||
|
.select({ id: issues.id })
|
||||||
|
.from(issues)
|
||||||
|
.where(eq(issues.originId, routine.id));
|
||||||
|
|
||||||
|
expect(routineIssues).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails the run and cleans up the execution issue when wakeup queueing fails", async () => {
|
||||||
|
const { routine, svc } = await seedFixture({
|
||||||
|
wakeup: async () => {
|
||||||
|
throw new Error("queue unavailable");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const run = await svc.runRoutine(routine.id, { source: "manual" });
|
||||||
|
|
||||||
|
expect(run.status).toBe("failed");
|
||||||
|
expect(run.failureReason).toContain("queue unavailable");
|
||||||
|
expect(run.linkedIssueId).toBeNull();
|
||||||
|
|
||||||
|
const routineIssues = await db
|
||||||
|
.select({ id: issues.id })
|
||||||
|
.from(issues)
|
||||||
|
.where(eq(issues.originId, routine.id));
|
||||||
|
|
||||||
|
expect(routineIssues).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts standard second-precision webhook timestamps for HMAC triggers", async () => {
|
||||||
|
const { routine, svc } = await seedFixture();
|
||||||
|
const { trigger, secretMaterial } = await svc.createTrigger(
|
||||||
|
routine.id,
|
||||||
|
{
|
||||||
|
kind: "webhook",
|
||||||
|
signingMode: "hmac_sha256",
|
||||||
|
replayWindowSec: 300,
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(trigger.publicId).toBeTruthy();
|
||||||
|
expect(secretMaterial?.webhookSecret).toBeTruthy();
|
||||||
|
|
||||||
|
const payload = { ok: true };
|
||||||
|
const rawBody = Buffer.from(JSON.stringify(payload));
|
||||||
|
const timestampSeconds = String(Math.floor(Date.now() / 1000));
|
||||||
|
const signature = `sha256=${createHmac("sha256", secretMaterial!.webhookSecret)
|
||||||
|
.update(`${timestampSeconds}.`)
|
||||||
|
.update(rawBody)
|
||||||
|
.digest("hex")}`;
|
||||||
|
|
||||||
|
const run = await svc.firePublicTrigger(trigger.publicId!, {
|
||||||
|
signatureHeader: signature,
|
||||||
|
timestampHeader: timestampSeconds,
|
||||||
|
rawBody,
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(run.source).toBe("webhook");
|
||||||
|
expect(run.status).toBe("issue_created");
|
||||||
|
expect(run.linkedIssueId).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,6 +15,7 @@ import { companySkillRoutes } from "./routes/company-skills.js";
|
|||||||
import { agentRoutes } from "./routes/agents.js";
|
import { agentRoutes } from "./routes/agents.js";
|
||||||
import { projectRoutes } from "./routes/projects.js";
|
import { projectRoutes } from "./routes/projects.js";
|
||||||
import { issueRoutes } from "./routes/issues.js";
|
import { issueRoutes } from "./routes/issues.js";
|
||||||
|
import { routineRoutes } from "./routes/routines.js";
|
||||||
import { executionWorkspaceRoutes } from "./routes/execution-workspaces.js";
|
import { executionWorkspaceRoutes } from "./routes/execution-workspaces.js";
|
||||||
import { goalRoutes } from "./routes/goals.js";
|
import { goalRoutes } from "./routes/goals.js";
|
||||||
import { approvalRoutes } from "./routes/approvals.js";
|
import { approvalRoutes } from "./routes/approvals.js";
|
||||||
@@ -142,6 +143,7 @@ export async function createApp(
|
|||||||
api.use(assetRoutes(db, opts.storageService));
|
api.use(assetRoutes(db, opts.storageService));
|
||||||
api.use(projectRoutes(db));
|
api.use(projectRoutes(db));
|
||||||
api.use(issueRoutes(db, opts.storageService));
|
api.use(issueRoutes(db, opts.storageService));
|
||||||
|
api.use(routineRoutes(db));
|
||||||
api.use(executionWorkspaceRoutes(db));
|
api.use(executionWorkspaceRoutes(db));
|
||||||
api.use(goalRoutes(db));
|
api.use(goalRoutes(db));
|
||||||
api.use(approvalRoutes(db));
|
api.use(approvalRoutes(db));
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import { createApp } from "./app.js";
|
|||||||
import { loadConfig } from "./config.js";
|
import { loadConfig } from "./config.js";
|
||||||
import { logger } from "./middleware/logger.js";
|
import { logger } from "./middleware/logger.js";
|
||||||
import { setupLiveEventsWebSocketServer } from "./realtime/live-events-ws.js";
|
import { setupLiveEventsWebSocketServer } from "./realtime/live-events-ws.js";
|
||||||
import { heartbeatService, reconcilePersistedRuntimeServicesOnStartup } from "./services/index.js";
|
import { heartbeatService, reconcilePersistedRuntimeServicesOnStartup, routineService } from "./services/index.js";
|
||||||
import { createStorageServiceFromConfig } from "./storage/index.js";
|
import { createStorageServiceFromConfig } from "./storage/index.js";
|
||||||
import { printStartupBanner } from "./startup-banner.js";
|
import { printStartupBanner } from "./startup-banner.js";
|
||||||
import { getBoardClaimWarningUrl, initializeBoardClaimChallenge } from "./board-claim.js";
|
import { getBoardClaimWarningUrl, initializeBoardClaimChallenge } from "./board-claim.js";
|
||||||
@@ -526,6 +526,7 @@ export async function startServer(): Promise<StartedServer> {
|
|||||||
|
|
||||||
if (config.heartbeatSchedulerEnabled) {
|
if (config.heartbeatSchedulerEnabled) {
|
||||||
const heartbeat = heartbeatService(db as any);
|
const heartbeat = heartbeatService(db as any);
|
||||||
|
const routines = routineService(db as any);
|
||||||
|
|
||||||
// Reap orphaned running runs at startup while in-memory execution state is empty,
|
// Reap orphaned running runs at startup while in-memory execution state is empty,
|
||||||
// then resume any persisted queued runs that were waiting on the previous process.
|
// then resume any persisted queued runs that were waiting on the previous process.
|
||||||
@@ -547,6 +548,17 @@ export async function startServer(): Promise<StartedServer> {
|
|||||||
logger.error({ err }, "heartbeat timer tick failed");
|
logger.error({ err }, "heartbeat timer tick failed");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void routines
|
||||||
|
.tickScheduledTriggers(new Date())
|
||||||
|
.then((result) => {
|
||||||
|
if (result.triggered > 0) {
|
||||||
|
logger.info({ ...result }, "routine scheduler tick enqueued runs");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
logger.error({ err }, "routine scheduler tick failed");
|
||||||
|
});
|
||||||
|
|
||||||
// Periodically reap orphaned runs (5-min staleness threshold) and make sure
|
// Periodically reap orphaned runs (5-min staleness threshold) and make sure
|
||||||
// persisted queued work is still being driven forward.
|
// persisted queued work is still being driven forward.
|
||||||
void heartbeat
|
void heartbeat
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export { companySkillRoutes } from "./company-skills.js";
|
|||||||
export { agentRoutes } from "./agents.js";
|
export { agentRoutes } from "./agents.js";
|
||||||
export { projectRoutes } from "./projects.js";
|
export { projectRoutes } from "./projects.js";
|
||||||
export { issueRoutes } from "./issues.js";
|
export { issueRoutes } from "./issues.js";
|
||||||
|
export { routineRoutes } from "./routines.js";
|
||||||
export { goalRoutes } from "./goals.js";
|
export { goalRoutes } from "./goals.js";
|
||||||
export { approvalRoutes } from "./approvals.js";
|
export { approvalRoutes } from "./approvals.js";
|
||||||
export { secretRoutes } from "./secrets.js";
|
export { secretRoutes } from "./secrets.js";
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
documentService,
|
documentService,
|
||||||
logActivity,
|
logActivity,
|
||||||
projectService,
|
projectService,
|
||||||
|
routineService,
|
||||||
workProductService,
|
workProductService,
|
||||||
} from "../services/index.js";
|
} from "../services/index.js";
|
||||||
import { logger } from "../middleware/logger.js";
|
import { logger } from "../middleware/logger.js";
|
||||||
@@ -34,6 +35,7 @@ import { forbidden, HttpError, unauthorized } from "../errors.js";
|
|||||||
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||||
import { shouldWakeAssigneeOnCheckout } from "./issues-checkout-wakeup.js";
|
import { shouldWakeAssigneeOnCheckout } from "./issues-checkout-wakeup.js";
|
||||||
import { isAllowedContentType, MAX_ATTACHMENT_BYTES } from "../attachment-types.js";
|
import { isAllowedContentType, MAX_ATTACHMENT_BYTES } from "../attachment-types.js";
|
||||||
|
import { queueIssueAssignmentWakeup } from "../services/issue-assignment-wakeup.js";
|
||||||
|
|
||||||
const MAX_ISSUE_COMMENT_LIMIT = 500;
|
const MAX_ISSUE_COMMENT_LIMIT = 500;
|
||||||
|
|
||||||
@@ -49,6 +51,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
|||||||
const executionWorkspacesSvc = executionWorkspaceService(db);
|
const executionWorkspacesSvc = executionWorkspaceService(db);
|
||||||
const workProductsSvc = workProductService(db);
|
const workProductsSvc = workProductService(db);
|
||||||
const documentsSvc = documentService(db);
|
const documentsSvc = documentService(db);
|
||||||
|
const routinesSvc = routineService(db);
|
||||||
const upload = multer({
|
const upload = multer({
|
||||||
storage: multer.memoryStorage(),
|
storage: multer.memoryStorage(),
|
||||||
limits: { fileSize: MAX_ATTACHMENT_BYTES, files: 1 },
|
limits: { fileSize: MAX_ATTACHMENT_BYTES, files: 1 },
|
||||||
@@ -236,6 +239,10 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
|||||||
projectId: req.query.projectId as string | undefined,
|
projectId: req.query.projectId as string | undefined,
|
||||||
parentId: req.query.parentId as string | undefined,
|
parentId: req.query.parentId as string | undefined,
|
||||||
labelId: req.query.labelId as string | undefined,
|
labelId: req.query.labelId as string | undefined,
|
||||||
|
originKind: req.query.originKind as string | undefined,
|
||||||
|
originId: req.query.originId as string | undefined,
|
||||||
|
includeRoutineExecutions:
|
||||||
|
req.query.includeRoutineExecutions === "true" || req.query.includeRoutineExecutions === "1",
|
||||||
q: req.query.q as string | undefined,
|
q: req.query.q as string | undefined,
|
||||||
});
|
});
|
||||||
res.json(result);
|
res.json(result);
|
||||||
@@ -775,19 +782,15 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
|||||||
details: { title: issue.title, identifier: issue.identifier },
|
details: { title: issue.title, identifier: issue.identifier },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (issue.assigneeAgentId && issue.status !== "backlog") {
|
void queueIssueAssignmentWakeup({
|
||||||
void heartbeat
|
heartbeat,
|
||||||
.wakeup(issue.assigneeAgentId, {
|
issue,
|
||||||
source: "assignment",
|
|
||||||
triggerDetail: "system",
|
|
||||||
reason: "issue_assigned",
|
reason: "issue_assigned",
|
||||||
payload: { issueId: issue.id, mutation: "create" },
|
mutation: "create",
|
||||||
|
contextSource: "issue.create",
|
||||||
requestedByActorType: actor.actorType,
|
requestedByActorType: actor.actorType,
|
||||||
requestedByActorId: actor.actorId,
|
requestedByActorId: actor.actorId,
|
||||||
contextSnapshot: { issueId: issue.id, source: "issue.create" },
|
});
|
||||||
})
|
|
||||||
.catch((err) => logger.warn({ err, issueId: issue.id }, "failed to wake assignee on issue create"));
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(201).json(issue);
|
res.status(201).json(issue);
|
||||||
});
|
});
|
||||||
@@ -856,6 +859,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
|||||||
res.status(404).json({ error: "Issue not found" });
|
res.status(404).json({ error: "Issue not found" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
await routinesSvc.syncRunStatusForIssue(issue.id);
|
||||||
|
|
||||||
if (actor.runId) {
|
if (actor.runId) {
|
||||||
await heartbeat.reportRunActivity(actor.runId).catch((err) =>
|
await heartbeat.reportRunActivity(actor.runId).catch((err) =>
|
||||||
|
|||||||
299
server/src/routes/routines.ts
Normal file
299
server/src/routes/routines.ts
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
import { Router, type Request } from "express";
|
||||||
|
import type { Db } from "@paperclipai/db";
|
||||||
|
import {
|
||||||
|
createRoutineSchema,
|
||||||
|
createRoutineTriggerSchema,
|
||||||
|
rotateRoutineTriggerSecretSchema,
|
||||||
|
runRoutineSchema,
|
||||||
|
updateRoutineSchema,
|
||||||
|
updateRoutineTriggerSchema,
|
||||||
|
} from "@paperclipai/shared";
|
||||||
|
import { validate } from "../middleware/validate.js";
|
||||||
|
import { accessService, logActivity, routineService } from "../services/index.js";
|
||||||
|
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||||
|
import { forbidden, unauthorized } from "../errors.js";
|
||||||
|
|
||||||
|
export function routineRoutes(db: Db) {
|
||||||
|
const router = Router();
|
||||||
|
const svc = routineService(db);
|
||||||
|
const access = accessService(db);
|
||||||
|
|
||||||
|
async function assertBoardCanAssignTasks(req: Request, companyId: string) {
|
||||||
|
assertCompanyAccess(req, companyId);
|
||||||
|
if (req.actor.type !== "board") return;
|
||||||
|
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return;
|
||||||
|
const allowed = await access.canUser(companyId, req.actor.userId, "tasks:assign");
|
||||||
|
if (!allowed) {
|
||||||
|
throw forbidden("Missing permission: tasks:assign");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertCanManageCompanyRoutine(req: Request, companyId: string, assigneeAgentId?: string | null) {
|
||||||
|
assertCompanyAccess(req, companyId);
|
||||||
|
if (req.actor.type === "board") return;
|
||||||
|
if (req.actor.type !== "agent" || !req.actor.agentId) throw unauthorized();
|
||||||
|
if (assigneeAgentId && assigneeAgentId !== req.actor.agentId) {
|
||||||
|
throw forbidden("Agents can only manage routines assigned to themselves");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertCanManageExistingRoutine(req: Request, routineId: string) {
|
||||||
|
const routine = await svc.get(routineId);
|
||||||
|
if (!routine) return null;
|
||||||
|
assertCompanyAccess(req, routine.companyId);
|
||||||
|
if (req.actor.type === "board") return routine;
|
||||||
|
if (req.actor.type !== "agent" || !req.actor.agentId) throw unauthorized();
|
||||||
|
if (routine.assigneeAgentId !== req.actor.agentId) {
|
||||||
|
throw forbidden("Agents can only manage routines assigned to themselves");
|
||||||
|
}
|
||||||
|
return routine;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get("/companies/:companyId/routines", async (req, res) => {
|
||||||
|
const companyId = req.params.companyId as string;
|
||||||
|
assertCompanyAccess(req, companyId);
|
||||||
|
const result = await svc.list(companyId);
|
||||||
|
res.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/companies/:companyId/routines", validate(createRoutineSchema), async (req, res) => {
|
||||||
|
const companyId = req.params.companyId as string;
|
||||||
|
await assertBoardCanAssignTasks(req, companyId);
|
||||||
|
assertCanManageCompanyRoutine(req, companyId, req.body.assigneeAgentId);
|
||||||
|
const created = await svc.create(companyId, req.body, {
|
||||||
|
agentId: req.actor.type === "agent" ? req.actor.agentId : null,
|
||||||
|
userId: req.actor.type === "board" ? req.actor.userId ?? "board" : null,
|
||||||
|
});
|
||||||
|
const actor = getActorInfo(req);
|
||||||
|
await logActivity(db, {
|
||||||
|
companyId,
|
||||||
|
actorType: actor.actorType,
|
||||||
|
actorId: actor.actorId,
|
||||||
|
agentId: actor.agentId,
|
||||||
|
runId: actor.runId,
|
||||||
|
action: "routine.created",
|
||||||
|
entityType: "routine",
|
||||||
|
entityId: created.id,
|
||||||
|
details: { title: created.title, assigneeAgentId: created.assigneeAgentId },
|
||||||
|
});
|
||||||
|
res.status(201).json(created);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/routines/:id", async (req, res) => {
|
||||||
|
const detail = await svc.getDetail(req.params.id as string);
|
||||||
|
if (!detail) {
|
||||||
|
res.status(404).json({ error: "Routine not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
assertCompanyAccess(req, detail.companyId);
|
||||||
|
res.json(detail);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.patch("/routines/:id", validate(updateRoutineSchema), async (req, res) => {
|
||||||
|
const routine = await assertCanManageExistingRoutine(req, req.params.id as string);
|
||||||
|
if (!routine) {
|
||||||
|
res.status(404).json({ error: "Routine not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const assigneeWillChange =
|
||||||
|
req.body.assigneeAgentId !== undefined &&
|
||||||
|
req.body.assigneeAgentId !== routine.assigneeAgentId;
|
||||||
|
if (assigneeWillChange) {
|
||||||
|
await assertBoardCanAssignTasks(req, routine.companyId);
|
||||||
|
}
|
||||||
|
const statusWillActivate =
|
||||||
|
req.body.status !== undefined &&
|
||||||
|
req.body.status === "active" &&
|
||||||
|
routine.status !== "active";
|
||||||
|
if (statusWillActivate) {
|
||||||
|
await assertBoardCanAssignTasks(req, routine.companyId);
|
||||||
|
}
|
||||||
|
if (req.actor.type === "agent" && req.body.assigneeAgentId && req.body.assigneeAgentId !== req.actor.agentId) {
|
||||||
|
throw forbidden("Agents can only assign routines to themselves");
|
||||||
|
}
|
||||||
|
const updated = await svc.update(routine.id, req.body, {
|
||||||
|
agentId: req.actor.type === "agent" ? req.actor.agentId : null,
|
||||||
|
userId: req.actor.type === "board" ? req.actor.userId ?? "board" : null,
|
||||||
|
});
|
||||||
|
const actor = getActorInfo(req);
|
||||||
|
await logActivity(db, {
|
||||||
|
companyId: routine.companyId,
|
||||||
|
actorType: actor.actorType,
|
||||||
|
actorId: actor.actorId,
|
||||||
|
agentId: actor.agentId,
|
||||||
|
runId: actor.runId,
|
||||||
|
action: "routine.updated",
|
||||||
|
entityType: "routine",
|
||||||
|
entityId: routine.id,
|
||||||
|
details: { title: updated?.title ?? routine.title },
|
||||||
|
});
|
||||||
|
res.json(updated);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/routines/:id/runs", async (req, res) => {
|
||||||
|
const routine = await svc.get(req.params.id as string);
|
||||||
|
if (!routine) {
|
||||||
|
res.status(404).json({ error: "Routine not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
assertCompanyAccess(req, routine.companyId);
|
||||||
|
const limit = Number(req.query.limit ?? 50);
|
||||||
|
const result = await svc.listRuns(routine.id, Number.isFinite(limit) ? limit : 50);
|
||||||
|
res.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/routines/:id/triggers", validate(createRoutineTriggerSchema), async (req, res) => {
|
||||||
|
const routine = await assertCanManageExistingRoutine(req, req.params.id as string);
|
||||||
|
if (!routine) {
|
||||||
|
res.status(404).json({ error: "Routine not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await assertBoardCanAssignTasks(req, routine.companyId);
|
||||||
|
const created = await svc.createTrigger(routine.id, req.body, {
|
||||||
|
agentId: req.actor.type === "agent" ? req.actor.agentId : null,
|
||||||
|
userId: req.actor.type === "board" ? req.actor.userId ?? "board" : null,
|
||||||
|
});
|
||||||
|
const actor = getActorInfo(req);
|
||||||
|
await logActivity(db, {
|
||||||
|
companyId: routine.companyId,
|
||||||
|
actorType: actor.actorType,
|
||||||
|
actorId: actor.actorId,
|
||||||
|
agentId: actor.agentId,
|
||||||
|
runId: actor.runId,
|
||||||
|
action: "routine.trigger_created",
|
||||||
|
entityType: "routine_trigger",
|
||||||
|
entityId: created.trigger.id,
|
||||||
|
details: { routineId: routine.id, kind: created.trigger.kind },
|
||||||
|
});
|
||||||
|
res.status(201).json(created);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.patch("/routine-triggers/:id", validate(updateRoutineTriggerSchema), async (req, res) => {
|
||||||
|
const trigger = await svc.getTrigger(req.params.id as string);
|
||||||
|
if (!trigger) {
|
||||||
|
res.status(404).json({ error: "Routine trigger not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const routine = await assertCanManageExistingRoutine(req, trigger.routineId);
|
||||||
|
if (!routine) {
|
||||||
|
res.status(404).json({ error: "Routine not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await assertBoardCanAssignTasks(req, routine.companyId);
|
||||||
|
const updated = await svc.updateTrigger(trigger.id, req.body, {
|
||||||
|
agentId: req.actor.type === "agent" ? req.actor.agentId : null,
|
||||||
|
userId: req.actor.type === "board" ? req.actor.userId ?? "board" : null,
|
||||||
|
});
|
||||||
|
const actor = getActorInfo(req);
|
||||||
|
await logActivity(db, {
|
||||||
|
companyId: routine.companyId,
|
||||||
|
actorType: actor.actorType,
|
||||||
|
actorId: actor.actorId,
|
||||||
|
agentId: actor.agentId,
|
||||||
|
runId: actor.runId,
|
||||||
|
action: "routine.trigger_updated",
|
||||||
|
entityType: "routine_trigger",
|
||||||
|
entityId: trigger.id,
|
||||||
|
details: { routineId: routine.id, kind: updated?.kind ?? trigger.kind },
|
||||||
|
});
|
||||||
|
res.json(updated);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete("/routine-triggers/:id", async (req, res) => {
|
||||||
|
const trigger = await svc.getTrigger(req.params.id as string);
|
||||||
|
if (!trigger) {
|
||||||
|
res.status(404).json({ error: "Routine trigger not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const routine = await assertCanManageExistingRoutine(req, trigger.routineId);
|
||||||
|
if (!routine) {
|
||||||
|
res.status(404).json({ error: "Routine not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await svc.deleteTrigger(trigger.id);
|
||||||
|
const actor = getActorInfo(req);
|
||||||
|
await logActivity(db, {
|
||||||
|
companyId: routine.companyId,
|
||||||
|
actorType: actor.actorType,
|
||||||
|
actorId: actor.actorId,
|
||||||
|
agentId: actor.agentId,
|
||||||
|
runId: actor.runId,
|
||||||
|
action: "routine.trigger_deleted",
|
||||||
|
entityType: "routine_trigger",
|
||||||
|
entityId: trigger.id,
|
||||||
|
details: { routineId: routine.id, kind: trigger.kind },
|
||||||
|
});
|
||||||
|
res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/routine-triggers/:id/rotate-secret",
|
||||||
|
validate(rotateRoutineTriggerSecretSchema),
|
||||||
|
async (req, res) => {
|
||||||
|
const trigger = await svc.getTrigger(req.params.id as string);
|
||||||
|
if (!trigger) {
|
||||||
|
res.status(404).json({ error: "Routine trigger not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const routine = await assertCanManageExistingRoutine(req, trigger.routineId);
|
||||||
|
if (!routine) {
|
||||||
|
res.status(404).json({ error: "Routine not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rotated = await svc.rotateTriggerSecret(trigger.id, {
|
||||||
|
agentId: req.actor.type === "agent" ? req.actor.agentId : null,
|
||||||
|
userId: req.actor.type === "board" ? req.actor.userId ?? "board" : null,
|
||||||
|
});
|
||||||
|
const actor = getActorInfo(req);
|
||||||
|
await logActivity(db, {
|
||||||
|
companyId: routine.companyId,
|
||||||
|
actorType: actor.actorType,
|
||||||
|
actorId: actor.actorId,
|
||||||
|
agentId: actor.agentId,
|
||||||
|
runId: actor.runId,
|
||||||
|
action: "routine.trigger_secret_rotated",
|
||||||
|
entityType: "routine_trigger",
|
||||||
|
entityId: trigger.id,
|
||||||
|
details: { routineId: routine.id },
|
||||||
|
});
|
||||||
|
res.json(rotated);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post("/routines/:id/run", validate(runRoutineSchema), async (req, res) => {
|
||||||
|
const routine = await assertCanManageExistingRoutine(req, req.params.id as string);
|
||||||
|
if (!routine) {
|
||||||
|
res.status(404).json({ error: "Routine not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await assertBoardCanAssignTasks(req, routine.companyId);
|
||||||
|
const run = await svc.runRoutine(routine.id, req.body);
|
||||||
|
const actor = getActorInfo(req);
|
||||||
|
await logActivity(db, {
|
||||||
|
companyId: routine.companyId,
|
||||||
|
actorType: actor.actorType,
|
||||||
|
actorId: actor.actorId,
|
||||||
|
agentId: actor.agentId,
|
||||||
|
runId: actor.runId,
|
||||||
|
action: "routine.run_triggered",
|
||||||
|
entityType: "routine_run",
|
||||||
|
entityId: run.id,
|
||||||
|
details: { routineId: routine.id, source: run.source, status: run.status },
|
||||||
|
});
|
||||||
|
res.status(202).json(run);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/routine-triggers/public/:publicId/fire", async (req, res) => {
|
||||||
|
const result = await svc.firePublicTrigger(req.params.publicId as string, {
|
||||||
|
authorizationHeader: req.header("authorization"),
|
||||||
|
signatureHeader: req.header("x-paperclip-signature"),
|
||||||
|
timestampHeader: req.header("x-paperclip-timestamp"),
|
||||||
|
idempotencyKey: req.header("idempotency-key"),
|
||||||
|
rawBody: (req as { rawBody?: Buffer }).rawBody ?? null,
|
||||||
|
payload: typeof req.body === "object" && req.body !== null ? req.body as Record<string, unknown> : null,
|
||||||
|
});
|
||||||
|
res.status(202).json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
@@ -3123,15 +3123,6 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let created = await agents.create(targetCompany.id, patch);
|
let created = await agents.create(targetCompany.id, patch);
|
||||||
try {
|
|
||||||
const materialized = await instructions.materializeManagedBundle(created, bundleFiles, {
|
|
||||||
clearLegacyPromptTemplate: true,
|
|
||||||
replaceExisting: true,
|
|
||||||
});
|
|
||||||
created = await agents.update(created.id, { adapterConfig: materialized.adapterConfig }) ?? created;
|
|
||||||
} catch (err) {
|
|
||||||
warnings.push(`Failed to materialize instructions bundle for ${manifestAgent.slug}: ${err instanceof Error ? err.message : String(err)}`);
|
|
||||||
}
|
|
||||||
await access.ensureMembership(targetCompany.id, "agent", created.id, "member", "active");
|
await access.ensureMembership(targetCompany.id, "agent", created.id, "member", "active");
|
||||||
await access.setPrincipalPermission(
|
await access.setPrincipalPermission(
|
||||||
targetCompany.id,
|
targetCompany.id,
|
||||||
@@ -3141,6 +3132,15 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
|||||||
true,
|
true,
|
||||||
actorUserId ?? null,
|
actorUserId ?? null,
|
||||||
);
|
);
|
||||||
|
try {
|
||||||
|
const materialized = await instructions.materializeManagedBundle(created, bundleFiles, {
|
||||||
|
clearLegacyPromptTemplate: true,
|
||||||
|
replaceExisting: true,
|
||||||
|
});
|
||||||
|
created = await agents.update(created.id, { adapterConfig: materialized.adapterConfig }) ?? created;
|
||||||
|
} catch (err) {
|
||||||
|
warnings.push(`Failed to materialize instructions bundle for ${manifestAgent.slug}: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
}
|
||||||
importedSlugToAgentId.set(planAgent.slug, created.id);
|
importedSlugToAgentId.set(planAgent.slug, created.id);
|
||||||
existingSlugToAgentId.set(normalizeAgentUrlKey(created.name) ?? created.id, created.id);
|
existingSlugToAgentId.set(normalizeAgentUrlKey(created.name) ?? created.id, created.id);
|
||||||
resultAgents.push({
|
resultAgents.push({
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export { activityService, type ActivityFilters } from "./activity.js";
|
|||||||
export { approvalService } from "./approvals.js";
|
export { approvalService } from "./approvals.js";
|
||||||
export { budgetService } from "./budgets.js";
|
export { budgetService } from "./budgets.js";
|
||||||
export { secretService } from "./secrets.js";
|
export { secretService } from "./secrets.js";
|
||||||
|
export { routineService } from "./routines.js";
|
||||||
export { costService } from "./costs.js";
|
export { costService } from "./costs.js";
|
||||||
export { financeService } from "./finance.js";
|
export { financeService } from "./finance.js";
|
||||||
export { heartbeatService } from "./heartbeat.js";
|
export { heartbeatService } from "./heartbeat.js";
|
||||||
|
|||||||
48
server/src/services/issue-assignment-wakeup.ts
Normal file
48
server/src/services/issue-assignment-wakeup.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { logger } from "../middleware/logger.js";
|
||||||
|
|
||||||
|
type WakeupTriggerDetail = "manual" | "ping" | "callback" | "system";
|
||||||
|
type WakeupSource = "timer" | "assignment" | "on_demand" | "automation";
|
||||||
|
|
||||||
|
export interface IssueAssignmentWakeupDeps {
|
||||||
|
wakeup: (
|
||||||
|
agentId: string,
|
||||||
|
opts: {
|
||||||
|
source?: WakeupSource;
|
||||||
|
triggerDetail?: WakeupTriggerDetail;
|
||||||
|
reason?: string | null;
|
||||||
|
payload?: Record<string, unknown> | null;
|
||||||
|
requestedByActorType?: "user" | "agent" | "system";
|
||||||
|
requestedByActorId?: string | null;
|
||||||
|
contextSnapshot?: Record<string, unknown>;
|
||||||
|
},
|
||||||
|
) => Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function queueIssueAssignmentWakeup(input: {
|
||||||
|
heartbeat: IssueAssignmentWakeupDeps;
|
||||||
|
issue: { id: string; assigneeAgentId: string | null; status: string };
|
||||||
|
reason: string;
|
||||||
|
mutation: string;
|
||||||
|
contextSource: string;
|
||||||
|
requestedByActorType?: "user" | "agent" | "system";
|
||||||
|
requestedByActorId?: string | null;
|
||||||
|
rethrowOnError?: boolean;
|
||||||
|
}) {
|
||||||
|
if (!input.issue.assigneeAgentId || input.issue.status === "backlog") return;
|
||||||
|
|
||||||
|
return input.heartbeat
|
||||||
|
.wakeup(input.issue.assigneeAgentId, {
|
||||||
|
source: "assignment",
|
||||||
|
triggerDetail: "system",
|
||||||
|
reason: input.reason,
|
||||||
|
payload: { issueId: input.issue.id, mutation: input.mutation },
|
||||||
|
requestedByActorType: input.requestedByActorType,
|
||||||
|
requestedByActorId: input.requestedByActorId ?? null,
|
||||||
|
contextSnapshot: { issueId: input.issue.id, source: input.contextSource },
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
logger.warn({ err, issueId: input.issue.id }, "failed to wake assignee on issue assignment");
|
||||||
|
if (input.rethrowOnError) throw err;
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { and, asc, desc, eq, inArray, isNull, or, sql } from "drizzle-orm";
|
import { and, asc, desc, eq, inArray, isNull, ne, or, sql } from "drizzle-orm";
|
||||||
import type { Db } from "@paperclipai/db";
|
import type { Db } from "@paperclipai/db";
|
||||||
import {
|
import {
|
||||||
agents,
|
agents,
|
||||||
@@ -68,6 +68,9 @@ export interface IssueFilters {
|
|||||||
projectId?: string;
|
projectId?: string;
|
||||||
parentId?: string;
|
parentId?: string;
|
||||||
labelId?: string;
|
labelId?: string;
|
||||||
|
originKind?: string;
|
||||||
|
originId?: string;
|
||||||
|
includeRoutineExecutions?: boolean;
|
||||||
q?: string;
|
q?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -516,6 +519,8 @@ export function issueService(db: Db) {
|
|||||||
}
|
}
|
||||||
if (filters?.projectId) conditions.push(eq(issues.projectId, filters.projectId));
|
if (filters?.projectId) conditions.push(eq(issues.projectId, filters.projectId));
|
||||||
if (filters?.parentId) conditions.push(eq(issues.parentId, filters.parentId));
|
if (filters?.parentId) conditions.push(eq(issues.parentId, filters.parentId));
|
||||||
|
if (filters?.originKind) conditions.push(eq(issues.originKind, filters.originKind));
|
||||||
|
if (filters?.originId) conditions.push(eq(issues.originId, filters.originId));
|
||||||
if (filters?.labelId) {
|
if (filters?.labelId) {
|
||||||
const labeledIssueIds = await db
|
const labeledIssueIds = await db
|
||||||
.select({ issueId: issueLabels.issueId })
|
.select({ issueId: issueLabels.issueId })
|
||||||
@@ -534,6 +539,9 @@ export function issueService(db: Db) {
|
|||||||
)!,
|
)!,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (!filters?.includeRoutineExecutions && !filters?.originKind && !filters?.originId) {
|
||||||
|
conditions.push(ne(issues.originKind, "routine_execution"));
|
||||||
|
}
|
||||||
conditions.push(isNull(issues.hiddenAt));
|
conditions.push(isNull(issues.hiddenAt));
|
||||||
|
|
||||||
const priorityOrder = sql`CASE ${issues.priority} WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END`;
|
const priorityOrder = sql`CASE ${issues.priority} WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END`;
|
||||||
@@ -615,6 +623,7 @@ export function issueService(db: Db) {
|
|||||||
eq(issues.companyId, companyId),
|
eq(issues.companyId, companyId),
|
||||||
isNull(issues.hiddenAt),
|
isNull(issues.hiddenAt),
|
||||||
unreadForUserCondition(companyId, userId),
|
unreadForUserCondition(companyId, userId),
|
||||||
|
ne(issues.originKind, "routine_execution"),
|
||||||
];
|
];
|
||||||
if (status) {
|
if (status) {
|
||||||
const statuses = status.split(",").map((s) => s.trim()).filter(Boolean);
|
const statuses = status.split(",").map((s) => s.trim()).filter(Boolean);
|
||||||
@@ -753,6 +762,7 @@ export function issueService(db: Db) {
|
|||||||
|
|
||||||
const values = {
|
const values = {
|
||||||
...issueData,
|
...issueData,
|
||||||
|
originKind: issueData.originKind ?? "manual",
|
||||||
goalId: resolveIssueGoalId({
|
goalId: resolveIssueGoalId({
|
||||||
projectId: issueData.projectId,
|
projectId: issueData.projectId,
|
||||||
goalId: issueData.goalId,
|
goalId: issueData.goalId,
|
||||||
|
|||||||
1268
server/src/services/routines.ts
Normal file
1268
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,
|
getById,
|
||||||
getByName,
|
getByName,
|
||||||
|
resolveSecretValue,
|
||||||
|
|
||||||
create: async (
|
create: async (
|
||||||
companyId: string,
|
companyId: string,
|
||||||
|
|||||||
@@ -80,6 +80,12 @@ curl -sS -X POST "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/skills/
|
|||||||
}'
|
}'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
You can also use source strings such as:
|
||||||
|
|
||||||
|
- `google-labs-code/stitch-skills/design-md`
|
||||||
|
- `vercel-labs/agent-browser/agent-browser`
|
||||||
|
- `npx skills add https://github.com/vercel-labs/agent-browser --skill agent-browser`
|
||||||
|
|
||||||
If the task is to discover skills from the company project workspaces first:
|
If the task is to discover skills from the company project workspaces first:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import { Projects } from "./pages/Projects";
|
|||||||
import { ProjectDetail } from "./pages/ProjectDetail";
|
import { ProjectDetail } from "./pages/ProjectDetail";
|
||||||
import { Issues } from "./pages/Issues";
|
import { Issues } from "./pages/Issues";
|
||||||
import { IssueDetail } from "./pages/IssueDetail";
|
import { IssueDetail } from "./pages/IssueDetail";
|
||||||
|
import { Routines } from "./pages/Routines";
|
||||||
|
import { RoutineDetail } from "./pages/RoutineDetail";
|
||||||
import { ExecutionWorkspaceDetail } from "./pages/ExecutionWorkspaceDetail";
|
import { ExecutionWorkspaceDetail } from "./pages/ExecutionWorkspaceDetail";
|
||||||
import { Goals } from "./pages/Goals";
|
import { Goals } from "./pages/Goals";
|
||||||
import { GoalDetail } from "./pages/GoalDetail";
|
import { GoalDetail } from "./pages/GoalDetail";
|
||||||
@@ -150,6 +152,8 @@ function boardRoutes() {
|
|||||||
<Route path="issues/done" element={<Navigate to="/issues" replace />} />
|
<Route path="issues/done" element={<Navigate to="/issues" replace />} />
|
||||||
<Route path="issues/recent" element={<Navigate to="/issues" replace />} />
|
<Route path="issues/recent" element={<Navigate to="/issues" replace />} />
|
||||||
<Route path="issues/:issueId" element={<IssueDetail />} />
|
<Route path="issues/:issueId" element={<IssueDetail />} />
|
||||||
|
<Route path="routines" element={<Routines />} />
|
||||||
|
<Route path="routines/:routineId" element={<RoutineDetail />} />
|
||||||
<Route path="execution-workspaces/:workspaceId" element={<ExecutionWorkspaceDetail />} />
|
<Route path="execution-workspaces/:workspaceId" element={<ExecutionWorkspaceDetail />} />
|
||||||
<Route path="goals" element={<Goals />} />
|
<Route path="goals" element={<Goals />} />
|
||||||
<Route path="goals/:goalId" element={<GoalDetail />} />
|
<Route path="goals/:goalId" element={<GoalDetail />} />
|
||||||
@@ -315,6 +319,8 @@ export function App() {
|
|||||||
<Route path="companies" element={<UnprefixedBoardRedirect />} />
|
<Route path="companies" element={<UnprefixedBoardRedirect />} />
|
||||||
<Route path="issues" element={<UnprefixedBoardRedirect />} />
|
<Route path="issues" element={<UnprefixedBoardRedirect />} />
|
||||||
<Route path="issues/:issueId" element={<UnprefixedBoardRedirect />} />
|
<Route path="issues/:issueId" element={<UnprefixedBoardRedirect />} />
|
||||||
|
<Route path="routines" element={<UnprefixedBoardRedirect />} />
|
||||||
|
<Route path="routines/:routineId" element={<UnprefixedBoardRedirect />} />
|
||||||
<Route path="skills/*" element={<UnprefixedBoardRedirect />} />
|
<Route path="skills/*" element={<UnprefixedBoardRedirect />} />
|
||||||
<Route path="settings" element={<LegacySettingsRedirect />} />
|
<Route path="settings" element={<LegacySettingsRedirect />} />
|
||||||
<Route path="settings/*" element={<LegacySettingsRedirect />} />
|
<Route path="settings/*" element={<LegacySettingsRedirect />} />
|
||||||
|
|||||||
@@ -22,7 +22,14 @@ export interface IssueForRun {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const activityApi = {
|
export const activityApi = {
|
||||||
list: (companyId: string) => api.get<ActivityEvent[]>(`/companies/${companyId}/activity`),
|
list: (companyId: string, filters?: { entityType?: string; entityId?: string; agentId?: string }) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filters?.entityType) params.set("entityType", filters.entityType);
|
||||||
|
if (filters?.entityId) params.set("entityId", filters.entityId);
|
||||||
|
if (filters?.agentId) params.set("agentId", filters.agentId);
|
||||||
|
const qs = params.toString();
|
||||||
|
return api.get<ActivityEvent[]>(`/companies/${companyId}/activity${qs ? `?${qs}` : ""}`);
|
||||||
|
},
|
||||||
forIssue: (issueId: string) => api.get<ActivityEvent[]>(`/issues/${issueId}/activity`),
|
forIssue: (issueId: string) => api.get<ActivityEvent[]>(`/issues/${issueId}/activity`),
|
||||||
runsForIssue: (issueId: string) => api.get<RunForIssue[]>(`/issues/${issueId}/runs`),
|
runsForIssue: (issueId: string) => api.get<RunForIssue[]>(`/issues/${issueId}/runs`),
|
||||||
issuesForRun: (runId: string) => api.get<IssueForRun[]>(`/heartbeat-runs/${runId}/issues`),
|
issuesForRun: (runId: string) => api.get<IssueForRun[]>(`/heartbeat-runs/${runId}/issues`),
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import type {
|
import type {
|
||||||
Agent,
|
Agent,
|
||||||
|
AgentDetail,
|
||||||
AgentInstructionsBundle,
|
AgentInstructionsBundle,
|
||||||
AgentInstructionsFileDetail,
|
AgentInstructionsFileDetail,
|
||||||
AgentSkillSnapshot,
|
AgentSkillSnapshot,
|
||||||
AgentDetail,
|
|
||||||
AdapterEnvironmentTestResult,
|
AdapterEnvironmentTestResult,
|
||||||
AgentKeyCreated,
|
AgentKeyCreated,
|
||||||
AgentRuntimeState,
|
AgentRuntimeState,
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|||||||
errorBody,
|
errorBody,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (res.status === 204) return undefined as T;
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export { companiesApi } from "./companies";
|
|||||||
export { agentsApi } from "./agents";
|
export { agentsApi } from "./agents";
|
||||||
export { projectsApi } from "./projects";
|
export { projectsApi } from "./projects";
|
||||||
export { issuesApi } from "./issues";
|
export { issuesApi } from "./issues";
|
||||||
|
export { routinesApi } from "./routines";
|
||||||
export { goalsApi } from "./goals";
|
export { goalsApi } from "./goals";
|
||||||
export { approvalsApi } from "./approvals";
|
export { approvalsApi } from "./approvals";
|
||||||
export { costsApi } from "./costs";
|
export { costsApi } from "./costs";
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ export const issuesApi = {
|
|||||||
touchedByUserId?: string;
|
touchedByUserId?: string;
|
||||||
unreadForUserId?: string;
|
unreadForUserId?: string;
|
||||||
labelId?: string;
|
labelId?: string;
|
||||||
|
originKind?: string;
|
||||||
|
originId?: string;
|
||||||
|
includeRoutineExecutions?: boolean;
|
||||||
q?: string;
|
q?: string;
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
@@ -33,6 +36,9 @@ export const issuesApi = {
|
|||||||
if (filters?.touchedByUserId) params.set("touchedByUserId", filters.touchedByUserId);
|
if (filters?.touchedByUserId) params.set("touchedByUserId", filters.touchedByUserId);
|
||||||
if (filters?.unreadForUserId) params.set("unreadForUserId", filters.unreadForUserId);
|
if (filters?.unreadForUserId) params.set("unreadForUserId", filters.unreadForUserId);
|
||||||
if (filters?.labelId) params.set("labelId", filters.labelId);
|
if (filters?.labelId) params.set("labelId", filters.labelId);
|
||||||
|
if (filters?.originKind) params.set("originKind", filters.originKind);
|
||||||
|
if (filters?.originId) params.set("originId", filters.originId);
|
||||||
|
if (filters?.includeRoutineExecutions) params.set("includeRoutineExecutions", "true");
|
||||||
if (filters?.q) params.set("q", filters.q);
|
if (filters?.q) params.set("q", filters.q);
|
||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
return api.get<Issue[]>(`/companies/${companyId}/issues${qs ? `?${qs}` : ""}`);
|
return api.get<Issue[]>(`/companies/${companyId}/issues${qs ? `?${qs}` : ""}`);
|
||||||
|
|||||||
58
ui/src/api/routines.ts
Normal file
58
ui/src/api/routines.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import type {
|
||||||
|
ActivityEvent,
|
||||||
|
Routine,
|
||||||
|
RoutineDetail,
|
||||||
|
RoutineListItem,
|
||||||
|
RoutineRun,
|
||||||
|
RoutineRunSummary,
|
||||||
|
RoutineTrigger,
|
||||||
|
RoutineTriggerSecretMaterial,
|
||||||
|
} from "@paperclipai/shared";
|
||||||
|
import { activityApi } from "./activity";
|
||||||
|
import { api } from "./client";
|
||||||
|
|
||||||
|
export interface RoutineTriggerResponse {
|
||||||
|
trigger: RoutineTrigger;
|
||||||
|
secretMaterial: RoutineTriggerSecretMaterial | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RotateRoutineTriggerResponse {
|
||||||
|
trigger: RoutineTrigger;
|
||||||
|
secretMaterial: RoutineTriggerSecretMaterial;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const routinesApi = {
|
||||||
|
list: (companyId: string) => api.get<RoutineListItem[]>(`/companies/${companyId}/routines`),
|
||||||
|
create: (companyId: string, data: Record<string, unknown>) =>
|
||||||
|
api.post<Routine>(`/companies/${companyId}/routines`, data),
|
||||||
|
get: (id: string) => api.get<RoutineDetail>(`/routines/${id}`),
|
||||||
|
update: (id: string, data: Record<string, unknown>) => api.patch<Routine>(`/routines/${id}`, data),
|
||||||
|
listRuns: (id: string, limit: number = 50) => api.get<RoutineRunSummary[]>(`/routines/${id}/runs?limit=${limit}`),
|
||||||
|
createTrigger: (id: string, data: Record<string, unknown>) =>
|
||||||
|
api.post<RoutineTriggerResponse>(`/routines/${id}/triggers`, data),
|
||||||
|
updateTrigger: (id: string, data: Record<string, unknown>) =>
|
||||||
|
api.patch<RoutineTrigger>(`/routine-triggers/${id}`, data),
|
||||||
|
deleteTrigger: (id: string) => api.delete<void>(`/routine-triggers/${id}`),
|
||||||
|
rotateTriggerSecret: (id: string) =>
|
||||||
|
api.post<RotateRoutineTriggerResponse>(`/routine-triggers/${id}/rotate-secret`, {}),
|
||||||
|
run: (id: string, data?: Record<string, unknown>) =>
|
||||||
|
api.post<RoutineRun>(`/routines/${id}/run`, data ?? {}),
|
||||||
|
activity: async (
|
||||||
|
companyId: string,
|
||||||
|
routineId: string,
|
||||||
|
related?: { triggerIds?: string[]; runIds?: string[] },
|
||||||
|
) => {
|
||||||
|
const requests = [
|
||||||
|
activityApi.list(companyId, { entityType: "routine", entityId: routineId }),
|
||||||
|
...(related?.triggerIds ?? []).map((triggerId) =>
|
||||||
|
activityApi.list(companyId, { entityType: "routine_trigger", entityId: triggerId })),
|
||||||
|
...(related?.runIds ?? []).map((runId) =>
|
||||||
|
activityApi.list(companyId, { entityType: "routine_run", entityId: runId })),
|
||||||
|
];
|
||||||
|
const events = (await Promise.all(requests)).flat();
|
||||||
|
const deduped = new Map(events.map((event) => [event.id, event]));
|
||||||
|
return [...deduped.values()].sort(
|
||||||
|
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
51
ui/src/components/AgentActionButtons.tsx
Normal file
51
ui/src/components/AgentActionButtons.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { Pause, Play } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export function RunButton({
|
||||||
|
onClick,
|
||||||
|
disabled,
|
||||||
|
label = "Run now",
|
||||||
|
size = "sm",
|
||||||
|
}: {
|
||||||
|
onClick: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
label?: string;
|
||||||
|
size?: "sm" | "default";
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Button variant="outline" size={size} onClick={onClick} disabled={disabled}>
|
||||||
|
<Play className="h-3.5 w-3.5 sm:mr-1" />
|
||||||
|
<span className="hidden sm:inline">{label}</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PauseResumeButton({
|
||||||
|
isPaused,
|
||||||
|
onPause,
|
||||||
|
onResume,
|
||||||
|
disabled,
|
||||||
|
size = "sm",
|
||||||
|
}: {
|
||||||
|
isPaused: boolean;
|
||||||
|
onPause: () => void;
|
||||||
|
onResume: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
size?: "sm" | "default";
|
||||||
|
}) {
|
||||||
|
if (isPaused) {
|
||||||
|
return (
|
||||||
|
<Button variant="outline" size={size} onClick={onResume} disabled={disabled}>
|
||||||
|
<Play className="h-3.5 w-3.5 sm:mr-1" />
|
||||||
|
<span className="hidden sm:inline">Resume</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button variant="outline" size={size} onClick={onPause} disabled={disabled}>
|
||||||
|
<Pause className="h-3.5 w-3.5 sm:mr-1" />
|
||||||
|
<span className="hidden sm:inline">Pause</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
344
ui/src/components/ScheduleEditor.tsx
Normal file
344
ui/src/components/ScheduleEditor.tsx
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||||
|
|
||||||
|
type SchedulePreset = "every_minute" | "every_hour" | "every_day" | "weekdays" | "weekly" | "monthly" | "custom";
|
||||||
|
|
||||||
|
const PRESETS: { value: SchedulePreset; label: string }[] = [
|
||||||
|
{ value: "every_minute", label: "Every minute" },
|
||||||
|
{ value: "every_hour", label: "Every hour" },
|
||||||
|
{ value: "every_day", label: "Every day" },
|
||||||
|
{ value: "weekdays", label: "Weekdays" },
|
||||||
|
{ value: "weekly", label: "Weekly" },
|
||||||
|
{ value: "monthly", label: "Monthly" },
|
||||||
|
{ value: "custom", label: "Custom (cron)" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const HOURS = Array.from({ length: 24 }, (_, i) => ({
|
||||||
|
value: String(i),
|
||||||
|
label: i === 0 ? "12 AM" : i < 12 ? `${i} AM` : i === 12 ? "12 PM" : `${i - 12} PM`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const MINUTES = Array.from({ length: 12 }, (_, i) => ({
|
||||||
|
value: String(i * 5),
|
||||||
|
label: String(i * 5).padStart(2, "0"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const DAYS_OF_WEEK = [
|
||||||
|
{ value: "1", label: "Mon" },
|
||||||
|
{ value: "2", label: "Tue" },
|
||||||
|
{ value: "3", label: "Wed" },
|
||||||
|
{ value: "4", label: "Thu" },
|
||||||
|
{ value: "5", label: "Fri" },
|
||||||
|
{ value: "6", label: "Sat" },
|
||||||
|
{ value: "0", label: "Sun" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DAYS_OF_MONTH = Array.from({ length: 31 }, (_, i) => ({
|
||||||
|
value: String(i + 1),
|
||||||
|
label: String(i + 1),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function parseCronToPreset(cron: string): {
|
||||||
|
preset: SchedulePreset;
|
||||||
|
hour: string;
|
||||||
|
minute: string;
|
||||||
|
dayOfWeek: string;
|
||||||
|
dayOfMonth: string;
|
||||||
|
} {
|
||||||
|
const defaults = { hour: "10", minute: "0", dayOfWeek: "1", dayOfMonth: "1" };
|
||||||
|
|
||||||
|
if (!cron || !cron.trim()) {
|
||||||
|
return { preset: "every_day", ...defaults };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = cron.trim().split(/\s+/);
|
||||||
|
if (parts.length !== 5) {
|
||||||
|
return { preset: "custom", ...defaults };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [min, hr, dom, , dow] = parts;
|
||||||
|
|
||||||
|
// Every minute: "* * * * *"
|
||||||
|
if (min === "*" && hr === "*" && dom === "*" && dow === "*") {
|
||||||
|
return { preset: "every_minute", ...defaults };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every hour: "0 * * * *"
|
||||||
|
if (hr === "*" && dom === "*" && dow === "*") {
|
||||||
|
return { preset: "every_hour", ...defaults, minute: min === "*" ? "0" : min };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every day: "M H * * *"
|
||||||
|
if (dom === "*" && dow === "*" && hr !== "*") {
|
||||||
|
return { preset: "every_day", ...defaults, hour: hr, minute: min === "*" ? "0" : min };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weekdays: "M H * * 1-5"
|
||||||
|
if (dom === "*" && dow === "1-5" && hr !== "*") {
|
||||||
|
return { preset: "weekdays", ...defaults, hour: hr, minute: min === "*" ? "0" : min };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weekly: "M H * * D" (single day)
|
||||||
|
if (dom === "*" && /^\d$/.test(dow) && hr !== "*") {
|
||||||
|
return { preset: "weekly", ...defaults, hour: hr, minute: min === "*" ? "0" : min, dayOfWeek: dow };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Monthly: "M H D * *"
|
||||||
|
if (/^\d{1,2}$/.test(dom) && dow === "*" && hr !== "*") {
|
||||||
|
return { preset: "monthly", ...defaults, hour: hr, minute: min === "*" ? "0" : min, dayOfMonth: dom };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { preset: "custom", ...defaults };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCron(preset: SchedulePreset, hour: string, minute: string, dayOfWeek: string, dayOfMonth: string): string {
|
||||||
|
switch (preset) {
|
||||||
|
case "every_minute":
|
||||||
|
return "* * * * *";
|
||||||
|
case "every_hour":
|
||||||
|
return `${minute} * * * *`;
|
||||||
|
case "every_day":
|
||||||
|
return `${minute} ${hour} * * *`;
|
||||||
|
case "weekdays":
|
||||||
|
return `${minute} ${hour} * * 1-5`;
|
||||||
|
case "weekly":
|
||||||
|
return `${minute} ${hour} * * ${dayOfWeek}`;
|
||||||
|
case "monthly":
|
||||||
|
return `${minute} ${hour} ${dayOfMonth} * *`;
|
||||||
|
case "custom":
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeSchedule(cron: string): string {
|
||||||
|
const { preset, hour, minute, dayOfWeek, dayOfMonth } = parseCronToPreset(cron);
|
||||||
|
const hourLabel = HOURS.find((h) => h.value === hour)?.label ?? `${hour}`;
|
||||||
|
const timeStr = `${hourLabel.replace(/ (AM|PM)$/, "")}:${minute.padStart(2, "0")} ${hourLabel.match(/(AM|PM)$/)?.[0] ?? ""}`;
|
||||||
|
|
||||||
|
switch (preset) {
|
||||||
|
case "every_minute":
|
||||||
|
return "Every minute";
|
||||||
|
case "every_hour":
|
||||||
|
return `Every hour at :${minute.padStart(2, "0")}`;
|
||||||
|
case "every_day":
|
||||||
|
return `Every day at ${timeStr}`;
|
||||||
|
case "weekdays":
|
||||||
|
return `Weekdays at ${timeStr}`;
|
||||||
|
case "weekly": {
|
||||||
|
const day = DAYS_OF_WEEK.find((d) => d.value === dayOfWeek)?.label ?? dayOfWeek;
|
||||||
|
return `Every ${day} at ${timeStr}`;
|
||||||
|
}
|
||||||
|
case "monthly":
|
||||||
|
return `Monthly on the ${dayOfMonth}${ordinalSuffix(Number(dayOfMonth))} at ${timeStr}`;
|
||||||
|
case "custom":
|
||||||
|
return cron || "No schedule set";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ordinalSuffix(n: number): string {
|
||||||
|
const s = ["th", "st", "nd", "rd"];
|
||||||
|
const v = n % 100;
|
||||||
|
return s[(v - 20) % 10] || s[v] || s[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export { describeSchedule };
|
||||||
|
|
||||||
|
export function ScheduleEditor({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (cron: string) => void;
|
||||||
|
}) {
|
||||||
|
const parsed = useMemo(() => parseCronToPreset(value), [value]);
|
||||||
|
const [preset, setPreset] = useState<SchedulePreset>(parsed.preset);
|
||||||
|
const [hour, setHour] = useState(parsed.hour);
|
||||||
|
const [minute, setMinute] = useState(parsed.minute);
|
||||||
|
const [dayOfWeek, setDayOfWeek] = useState(parsed.dayOfWeek);
|
||||||
|
const [dayOfMonth, setDayOfMonth] = useState(parsed.dayOfMonth);
|
||||||
|
const [customCron, setCustomCron] = useState(preset === "custom" ? value : "");
|
||||||
|
|
||||||
|
// Sync from external value changes
|
||||||
|
useEffect(() => {
|
||||||
|
const p = parseCronToPreset(value);
|
||||||
|
setPreset(p.preset);
|
||||||
|
setHour(p.hour);
|
||||||
|
setMinute(p.minute);
|
||||||
|
setDayOfWeek(p.dayOfWeek);
|
||||||
|
setDayOfMonth(p.dayOfMonth);
|
||||||
|
if (p.preset === "custom") setCustomCron(value);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
const emitChange = useCallback(
|
||||||
|
(p: SchedulePreset, h: string, m: string, dow: string, dom: string, custom: string) => {
|
||||||
|
if (p === "custom") {
|
||||||
|
onChange(custom);
|
||||||
|
} else {
|
||||||
|
onChange(buildCron(p, h, m, dow, dom));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePresetChange = (newPreset: SchedulePreset) => {
|
||||||
|
setPreset(newPreset);
|
||||||
|
if (newPreset === "custom") {
|
||||||
|
setCustomCron(value);
|
||||||
|
} else {
|
||||||
|
emitChange(newPreset, hour, minute, dayOfWeek, dayOfMonth, customCron);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Select value={preset} onValueChange={(v) => handlePresetChange(v as SchedulePreset)}>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="Choose frequency..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{PRESETS.map((p) => (
|
||||||
|
<SelectItem key={p.value} value={p.value}>
|
||||||
|
{p.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{preset === "custom" ? (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Input
|
||||||
|
value={customCron}
|
||||||
|
onChange={(e) => {
|
||||||
|
setCustomCron(e.target.value);
|
||||||
|
emitChange("custom", hour, minute, dayOfWeek, dayOfMonth, e.target.value);
|
||||||
|
}}
|
||||||
|
placeholder="0 10 * * *"
|
||||||
|
className="font-mono text-sm"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Five fields: minute hour day-of-month month day-of-week
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{preset !== "every_minute" && preset !== "every_hour" && (
|
||||||
|
<>
|
||||||
|
<span className="text-sm text-muted-foreground">at</span>
|
||||||
|
<Select
|
||||||
|
value={hour}
|
||||||
|
onValueChange={(h) => {
|
||||||
|
setHour(h);
|
||||||
|
emitChange(preset, h, minute, dayOfWeek, dayOfMonth, customCron);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[120px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{HOURS.map((h) => (
|
||||||
|
<SelectItem key={h.value} value={h.value}>
|
||||||
|
{h.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<span className="text-sm text-muted-foreground">:</span>
|
||||||
|
<Select
|
||||||
|
value={minute}
|
||||||
|
onValueChange={(m) => {
|
||||||
|
setMinute(m);
|
||||||
|
emitChange(preset, hour, m, dayOfWeek, dayOfMonth, customCron);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[80px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{MINUTES.map((m) => (
|
||||||
|
<SelectItem key={m.value} value={m.value}>
|
||||||
|
{m.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{preset === "every_hour" && (
|
||||||
|
<>
|
||||||
|
<span className="text-sm text-muted-foreground">at minute</span>
|
||||||
|
<Select
|
||||||
|
value={minute}
|
||||||
|
onValueChange={(m) => {
|
||||||
|
setMinute(m);
|
||||||
|
emitChange(preset, hour, m, dayOfWeek, dayOfMonth, customCron);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[80px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{MINUTES.map((m) => (
|
||||||
|
<SelectItem key={m.value} value={m.value}>
|
||||||
|
:{m.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{preset === "weekly" && (
|
||||||
|
<>
|
||||||
|
<span className="text-sm text-muted-foreground">on</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{DAYS_OF_WEEK.map((d) => (
|
||||||
|
<Button
|
||||||
|
key={d.value}
|
||||||
|
type="button"
|
||||||
|
variant={dayOfWeek === d.value ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2 text-xs"
|
||||||
|
onClick={() => {
|
||||||
|
setDayOfWeek(d.value);
|
||||||
|
emitChange(preset, hour, minute, d.value, dayOfMonth, customCron);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{d.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{preset === "monthly" && (
|
||||||
|
<>
|
||||||
|
<span className="text-sm text-muted-foreground">on day</span>
|
||||||
|
<Select
|
||||||
|
value={dayOfMonth}
|
||||||
|
onValueChange={(dom) => {
|
||||||
|
setDayOfMonth(dom);
|
||||||
|
emitChange(preset, hour, minute, dayOfWeek, dom, customCron);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[80px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{DAYS_OF_MONTH.map((d) => (
|
||||||
|
<SelectItem key={d.value} value={d.value}>
|
||||||
|
{d.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
SquarePen,
|
SquarePen,
|
||||||
Network,
|
Network,
|
||||||
Boxes,
|
Boxes,
|
||||||
|
Repeat,
|
||||||
Settings,
|
Settings,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
@@ -98,6 +99,7 @@ export function Sidebar() {
|
|||||||
|
|
||||||
<SidebarSection label="Work">
|
<SidebarSection label="Work">
|
||||||
<SidebarNavItem to="/issues" label="Issues" icon={CircleDot} />
|
<SidebarNavItem to="/issues" label="Issues" icon={CircleDot} />
|
||||||
|
<SidebarNavItem to="/routines" label="Routines" icon={Repeat} textBadge="Beta" textBadgeTone="amber" />
|
||||||
<SidebarNavItem to="/goals" label="Goals" icon={Target} />
|
<SidebarNavItem to="/goals" label="Goals" icon={Target} />
|
||||||
</SidebarSection>
|
</SidebarSection>
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ interface SidebarNavItemProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
badge?: number;
|
badge?: number;
|
||||||
badgeTone?: "default" | "danger";
|
badgeTone?: "default" | "danger";
|
||||||
|
textBadge?: string;
|
||||||
|
textBadgeTone?: "default" | "amber";
|
||||||
alert?: boolean;
|
alert?: boolean;
|
||||||
liveCount?: number;
|
liveCount?: number;
|
||||||
}
|
}
|
||||||
@@ -23,6 +25,8 @@ export function SidebarNavItem({
|
|||||||
className,
|
className,
|
||||||
badge,
|
badge,
|
||||||
badgeTone = "default",
|
badgeTone = "default",
|
||||||
|
textBadge,
|
||||||
|
textBadgeTone = "default",
|
||||||
alert = false,
|
alert = false,
|
||||||
liveCount,
|
liveCount,
|
||||||
}: SidebarNavItemProps) {
|
}: SidebarNavItemProps) {
|
||||||
@@ -50,6 +54,18 @@ export function SidebarNavItem({
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex-1 truncate">{label}</span>
|
<span className="flex-1 truncate">{label}</span>
|
||||||
|
{textBadge && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"ml-auto rounded-full px-1.5 py-0.5 text-[10px] font-medium leading-none",
|
||||||
|
textBadgeTone === "amber"
|
||||||
|
? "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400"
|
||||||
|
: "bg-muted text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{textBadge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{liveCount != null && liveCount > 0 && (
|
{liveCount != null && liveCount > 0 && (
|
||||||
<span className="ml-auto flex items-center gap-1.5">
|
<span className="ml-auto flex items-center gap-1.5">
|
||||||
<span className="relative flex h-2 w-2">
|
<span className="relative flex h-2 w-2">
|
||||||
|
|||||||
@@ -422,6 +422,11 @@ function invalidateActivityQueries(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (entityType === "routine" || entityType === "routine_trigger" || entityType === "routine_run") {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["routines"] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (entityType === "company") {
|
if (entityType === "company") {
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
|
queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const BOARD_ROUTE_ROOTS = new Set([
|
|||||||
"agents",
|
"agents",
|
||||||
"projects",
|
"projects",
|
||||||
"issues",
|
"issues",
|
||||||
|
"routines",
|
||||||
"goals",
|
"goals",
|
||||||
"approvals",
|
"approvals",
|
||||||
"costs",
|
"costs",
|
||||||
|
|||||||
@@ -48,6 +48,12 @@ export const queryKeys = {
|
|||||||
activeRun: (issueId: string) => ["issues", "active-run", issueId] as const,
|
activeRun: (issueId: string) => ["issues", "active-run", issueId] as const,
|
||||||
workProducts: (issueId: string) => ["issues", "work-products", issueId] as const,
|
workProducts: (issueId: string) => ["issues", "work-products", issueId] as const,
|
||||||
},
|
},
|
||||||
|
routines: {
|
||||||
|
list: (companyId: string) => ["routines", companyId] as const,
|
||||||
|
detail: (id: string) => ["routines", "detail", id] as const,
|
||||||
|
runs: (id: string) => ["routines", "runs", id] as const,
|
||||||
|
activity: (companyId: string, id: string) => ["routines", "activity", companyId, id] as const,
|
||||||
|
},
|
||||||
executionWorkspaces: {
|
executionWorkspaces: {
|
||||||
list: (companyId: string, filters?: Record<string, string | boolean | undefined>) =>
|
list: (companyId: string, filters?: Record<string, string | boolean | undefined>) =>
|
||||||
["execution-workspaces", companyId, filters ?? {}] as const,
|
["execution-workspaces", companyId, filters ?? {}] as const,
|
||||||
|
|||||||
71
ui/src/lib/routine-trigger-patch.test.ts
Normal file
71
ui/src/lib/routine-trigger-patch.test.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { RoutineTrigger } from "@paperclipai/shared";
|
||||||
|
import { buildRoutineTriggerPatch } from "./routine-trigger-patch";
|
||||||
|
|
||||||
|
function makeScheduleTrigger(overrides: Partial<RoutineTrigger> = {}): RoutineTrigger {
|
||||||
|
return {
|
||||||
|
id: "trigger-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
routineId: "routine-1",
|
||||||
|
kind: "schedule",
|
||||||
|
label: "Daily",
|
||||||
|
enabled: true,
|
||||||
|
cronExpression: "0 10 * * *",
|
||||||
|
timezone: "UTC",
|
||||||
|
nextRunAt: null,
|
||||||
|
lastFiredAt: null,
|
||||||
|
publicId: null,
|
||||||
|
secretId: null,
|
||||||
|
signingMode: null,
|
||||||
|
replayWindowSec: null,
|
||||||
|
lastRotatedAt: null,
|
||||||
|
lastResult: null,
|
||||||
|
createdByAgentId: null,
|
||||||
|
createdByUserId: null,
|
||||||
|
updatedByAgentId: null,
|
||||||
|
updatedByUserId: null,
|
||||||
|
createdAt: new Date("2026-03-20T00:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-03-20T00:00:00.000Z"),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("buildRoutineTriggerPatch", () => {
|
||||||
|
it("preserves an existing schedule trigger timezone when saving edits", () => {
|
||||||
|
const patch = buildRoutineTriggerPatch(
|
||||||
|
makeScheduleTrigger({ timezone: "UTC" }),
|
||||||
|
{
|
||||||
|
label: "Daily label edit",
|
||||||
|
cronExpression: "0 10 * * *",
|
||||||
|
signingMode: "bearer",
|
||||||
|
replayWindowSec: "300",
|
||||||
|
},
|
||||||
|
"America/Chicago",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(patch).toEqual({
|
||||||
|
label: "Daily label edit",
|
||||||
|
cronExpression: "0 10 * * *",
|
||||||
|
timezone: "UTC",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the local timezone when a schedule trigger has none", () => {
|
||||||
|
const patch = buildRoutineTriggerPatch(
|
||||||
|
makeScheduleTrigger({ timezone: null }),
|
||||||
|
{
|
||||||
|
label: "",
|
||||||
|
cronExpression: "15 9 * * 1-5",
|
||||||
|
signingMode: "bearer",
|
||||||
|
replayWindowSec: "300",
|
||||||
|
},
|
||||||
|
"America/Chicago",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(patch).toEqual({
|
||||||
|
label: null,
|
||||||
|
cronExpression: "15 9 * * 1-5",
|
||||||
|
timezone: "America/Chicago",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
30
ui/src/lib/routine-trigger-patch.ts
Normal file
30
ui/src/lib/routine-trigger-patch.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import type { RoutineTrigger } from "@paperclipai/shared";
|
||||||
|
|
||||||
|
export type RoutineTriggerEditorDraft = {
|
||||||
|
label: string;
|
||||||
|
cronExpression: string;
|
||||||
|
signingMode: string;
|
||||||
|
replayWindowSec: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildRoutineTriggerPatch(
|
||||||
|
trigger: RoutineTrigger,
|
||||||
|
draft: RoutineTriggerEditorDraft,
|
||||||
|
fallbackTimezone: string,
|
||||||
|
) {
|
||||||
|
const patch: Record<string, unknown> = {
|
||||||
|
label: draft.label.trim() || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (trigger.kind === "schedule") {
|
||||||
|
patch.cronExpression = draft.cronExpression.trim();
|
||||||
|
patch.timezone = trigger.timezone ?? fallbackTimezone;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trigger.kind === "webhook") {
|
||||||
|
patch.signingMode = draft.signingMode;
|
||||||
|
patch.replayWindowSec = Number(draft.replayWindowSec || "300");
|
||||||
|
}
|
||||||
|
|
||||||
|
return patch;
|
||||||
|
}
|
||||||
@@ -34,6 +34,7 @@ import { CopyText } from "../components/CopyText";
|
|||||||
import { EntityRow } from "../components/EntityRow";
|
import { EntityRow } from "../components/EntityRow";
|
||||||
import { Identity } from "../components/Identity";
|
import { Identity } from "../components/Identity";
|
||||||
import { PageSkeleton } from "../components/PageSkeleton";
|
import { PageSkeleton } from "../components/PageSkeleton";
|
||||||
|
import { RunButton, PauseResumeButton } from "../components/AgentActionButtons";
|
||||||
import { BudgetPolicyCard } from "../components/BudgetPolicyCard";
|
import { BudgetPolicyCard } from "../components/BudgetPolicyCard";
|
||||||
import { PackageFileTree, buildFileTree } from "../components/PackageFileTree";
|
import { PackageFileTree, buildFileTree } from "../components/PackageFileTree";
|
||||||
import { ScrollToBottom } from "../components/ScrollToBottom";
|
import { ScrollToBottom } from "../components/ScrollToBottom";
|
||||||
@@ -50,8 +51,6 @@ import {
|
|||||||
} from "@/components/ui/popover";
|
} from "@/components/ui/popover";
|
||||||
import {
|
import {
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Play,
|
|
||||||
Pause,
|
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
XCircle,
|
XCircle,
|
||||||
Clock,
|
Clock,
|
||||||
@@ -833,36 +832,17 @@ export function AgentDetail() {
|
|||||||
<Plus className="h-3.5 w-3.5 sm:mr-1" />
|
<Plus className="h-3.5 w-3.5 sm:mr-1" />
|
||||||
<span className="hidden sm:inline">Assign Task</span>
|
<span className="hidden sm:inline">Assign Task</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<RunButton
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => agentAction.mutate("invoke")}
|
onClick={() => agentAction.mutate("invoke")}
|
||||||
disabled={agentAction.isPending || isPendingApproval}
|
disabled={agentAction.isPending || isPendingApproval}
|
||||||
>
|
label="Run Heartbeat"
|
||||||
<Play className="h-3.5 w-3.5 sm:mr-1" />
|
/>
|
||||||
<span className="hidden sm:inline">Run Heartbeat</span>
|
<PauseResumeButton
|
||||||
</Button>
|
isPaused={agent.status === "paused"}
|
||||||
{agent.status === "paused" ? (
|
onPause={() => agentAction.mutate("pause")}
|
||||||
<Button
|
onResume={() => agentAction.mutate("resume")}
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => agentAction.mutate("resume")}
|
|
||||||
disabled={agentAction.isPending || isPendingApproval}
|
disabled={agentAction.isPending || isPendingApproval}
|
||||||
>
|
/>
|
||||||
<Play className="h-3.5 w-3.5 sm:mr-1" />
|
|
||||||
<span className="hidden sm:inline">Resume</span>
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => agentAction.mutate("pause")}
|
|
||||||
disabled={agentAction.isPending || isPendingApproval}
|
|
||||||
>
|
|
||||||
<Pause className="h-3.5 w-3.5 sm:mr-1" />
|
|
||||||
<span className="hidden sm:inline">Pause</span>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<span className="hidden sm:inline"><StatusBadge status={agent.status} /></span>
|
<span className="hidden sm:inline"><StatusBadge status={agent.status} /></span>
|
||||||
{mobileLiveRun && (
|
{mobileLiveRun && (
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import {
|
|||||||
MessageSquare,
|
MessageSquare,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Paperclip,
|
Paperclip,
|
||||||
|
Repeat,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -726,6 +727,16 @@ export function IssueDetail() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{issue.originKind === "routine_execution" && issue.originId && (
|
||||||
|
<Link
|
||||||
|
to={`/routines/${issue.originId}`}
|
||||||
|
className="inline-flex items-center gap-1 rounded-full bg-violet-500/10 border border-violet-500/30 px-2 py-0.5 text-[10px] font-medium text-violet-600 dark:text-violet-400 shrink-0 hover:bg-violet-500/20 transition-colors"
|
||||||
|
>
|
||||||
|
<Repeat className="h-3 w-3" />
|
||||||
|
Routine
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
{issue.projectId ? (
|
{issue.projectId ? (
|
||||||
<Link
|
<Link
|
||||||
to={`/projects/${issue.projectId}`}
|
to={`/projects/${issue.projectId}`}
|
||||||
|
|||||||
1020
ui/src/pages/RoutineDetail.tsx
Normal file
1020
ui/src/pages/RoutineDetail.tsx
Normal file
File diff suppressed because it is too large
Load Diff
660
ui/src/pages/Routines.tsx
Normal file
660
ui/src/pages/Routines.tsx
Normal file
@@ -0,0 +1,660 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useNavigate } from "@/lib/router";
|
||||||
|
import { ChevronDown, ChevronRight, MoreHorizontal, Play, Plus, Repeat } from "lucide-react";
|
||||||
|
import { routinesApi } from "../api/routines";
|
||||||
|
import { agentsApi } from "../api/agents";
|
||||||
|
import { projectsApi } from "../api/projects";
|
||||||
|
import { useCompany } from "../context/CompanyContext";
|
||||||
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
|
import { useToast } from "../context/ToastContext";
|
||||||
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
|
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
|
||||||
|
import { EmptyState } from "../components/EmptyState";
|
||||||
|
import { PageSkeleton } from "../components/PageSkeleton";
|
||||||
|
import { AgentIcon } from "../components/AgentIconPicker";
|
||||||
|
import { InlineEntitySelector, type InlineEntityOption } from "../components/InlineEntitySelector";
|
||||||
|
import { MarkdownEditor, type MarkdownEditorRef } from "../components/MarkdownEditor";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||||
|
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
|
||||||
|
const concurrencyPolicies = ["coalesce_if_active", "always_enqueue", "skip_if_active"];
|
||||||
|
const catchUpPolicies = ["skip_missed", "enqueue_missed_with_cap"];
|
||||||
|
const concurrencyPolicyDescriptions: Record<string, string> = {
|
||||||
|
coalesce_if_active: "If a run is already active, keep just one follow-up run queued.",
|
||||||
|
always_enqueue: "Queue every trigger occurrence, even if the routine is already running.",
|
||||||
|
skip_if_active: "Drop new trigger occurrences while a run is still active.",
|
||||||
|
};
|
||||||
|
const catchUpPolicyDescriptions: Record<string, string> = {
|
||||||
|
skip_missed: "Ignore windows that were missed while the scheduler or routine was paused.",
|
||||||
|
enqueue_missed_with_cap: "Catch up missed schedule windows in capped batches after recovery.",
|
||||||
|
};
|
||||||
|
|
||||||
|
function autoResizeTextarea(element: HTMLTextAreaElement | null) {
|
||||||
|
if (!element) return;
|
||||||
|
element.style.height = "auto";
|
||||||
|
element.style.height = `${element.scrollHeight}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLastRunTimestamp(value: Date | string | null | undefined) {
|
||||||
|
if (!value) return "Never";
|
||||||
|
return new Date(value).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextRoutineStatus(currentStatus: string, enabled: boolean) {
|
||||||
|
if (currentStatus === "archived" && enabled) return "active";
|
||||||
|
return enabled ? "active" : "paused";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Routines() {
|
||||||
|
const { selectedCompanyId } = useCompany();
|
||||||
|
const { setBreadcrumbs } = useBreadcrumbs();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { pushToast } = useToast();
|
||||||
|
const descriptionEditorRef = useRef<MarkdownEditorRef>(null);
|
||||||
|
const titleInputRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
|
const assigneeSelectorRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
const projectSelectorRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
const [runningRoutineId, setRunningRoutineId] = useState<string | null>(null);
|
||||||
|
const [statusMutationRoutineId, setStatusMutationRoutineId] = useState<string | null>(null);
|
||||||
|
const [composerOpen, setComposerOpen] = useState(false);
|
||||||
|
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||||
|
const [draft, setDraft] = useState({
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
projectId: "",
|
||||||
|
assigneeAgentId: "",
|
||||||
|
priority: "medium",
|
||||||
|
concurrencyPolicy: "coalesce_if_active",
|
||||||
|
catchUpPolicy: "skip_missed",
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setBreadcrumbs([{ label: "Routines" }]);
|
||||||
|
}, [setBreadcrumbs]);
|
||||||
|
|
||||||
|
const { data: routines, isLoading, error } = useQuery({
|
||||||
|
queryKey: queryKeys.routines.list(selectedCompanyId!),
|
||||||
|
queryFn: () => routinesApi.list(selectedCompanyId!),
|
||||||
|
enabled: !!selectedCompanyId,
|
||||||
|
});
|
||||||
|
const { data: agents } = useQuery({
|
||||||
|
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||||
|
queryFn: () => agentsApi.list(selectedCompanyId!),
|
||||||
|
enabled: !!selectedCompanyId,
|
||||||
|
});
|
||||||
|
const { data: projects } = useQuery({
|
||||||
|
queryKey: queryKeys.projects.list(selectedCompanyId!),
|
||||||
|
queryFn: () => projectsApi.list(selectedCompanyId!),
|
||||||
|
enabled: !!selectedCompanyId,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
autoResizeTextarea(titleInputRef.current);
|
||||||
|
}, [draft.title, composerOpen]);
|
||||||
|
|
||||||
|
const createRoutine = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
routinesApi.create(selectedCompanyId!, {
|
||||||
|
...draft,
|
||||||
|
description: draft.description.trim() || null,
|
||||||
|
}),
|
||||||
|
onSuccess: async (routine) => {
|
||||||
|
setDraft({
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
projectId: "",
|
||||||
|
assigneeAgentId: "",
|
||||||
|
priority: "medium",
|
||||||
|
concurrencyPolicy: "coalesce_if_active",
|
||||||
|
catchUpPolicy: "skip_missed",
|
||||||
|
});
|
||||||
|
setComposerOpen(false);
|
||||||
|
setAdvancedOpen(false);
|
||||||
|
await queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) });
|
||||||
|
pushToast({
|
||||||
|
title: "Routine created",
|
||||||
|
body: "Add the first trigger to turn it into a live workflow.",
|
||||||
|
tone: "success",
|
||||||
|
});
|
||||||
|
navigate(`/routines/${routine.id}?tab=triggers`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateRoutineStatus = useMutation({
|
||||||
|
mutationFn: ({ id, status }: { id: string; status: string }) => routinesApi.update(id, { status }),
|
||||||
|
onMutate: ({ id }) => {
|
||||||
|
setStatusMutationRoutineId(id);
|
||||||
|
},
|
||||||
|
onSuccess: async (_, variables) => {
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(variables.id) }),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
setStatusMutationRoutineId(null);
|
||||||
|
},
|
||||||
|
onError: (mutationError) => {
|
||||||
|
pushToast({
|
||||||
|
title: "Failed to update routine",
|
||||||
|
body: mutationError instanceof Error ? mutationError.message : "Paperclip could not update the routine.",
|
||||||
|
tone: "error",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const runRoutine = useMutation({
|
||||||
|
mutationFn: (id: string) => routinesApi.run(id),
|
||||||
|
onMutate: (id) => {
|
||||||
|
setRunningRoutineId(id);
|
||||||
|
},
|
||||||
|
onSuccess: async (_, id) => {
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(id) }),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
setRunningRoutineId(null);
|
||||||
|
},
|
||||||
|
onError: (mutationError) => {
|
||||||
|
pushToast({
|
||||||
|
title: "Routine run failed",
|
||||||
|
body: mutationError instanceof Error ? mutationError.message : "Paperclip could not start the routine run.",
|
||||||
|
tone: "error",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const recentAssigneeIds = useMemo(() => getRecentAssigneeIds(), [composerOpen]);
|
||||||
|
const assigneeOptions = useMemo<InlineEntityOption[]>(
|
||||||
|
() =>
|
||||||
|
sortAgentsByRecency(
|
||||||
|
(agents ?? []).filter((agent) => agent.status !== "terminated"),
|
||||||
|
recentAssigneeIds,
|
||||||
|
).map((agent) => ({
|
||||||
|
id: agent.id,
|
||||||
|
label: agent.name,
|
||||||
|
searchText: `${agent.name} ${agent.role} ${agent.title ?? ""}`,
|
||||||
|
})),
|
||||||
|
[agents, recentAssigneeIds],
|
||||||
|
);
|
||||||
|
const projectOptions = useMemo<InlineEntityOption[]>(
|
||||||
|
() =>
|
||||||
|
(projects ?? []).map((project) => ({
|
||||||
|
id: project.id,
|
||||||
|
label: project.name,
|
||||||
|
searchText: project.description ?? "",
|
||||||
|
})),
|
||||||
|
[projects],
|
||||||
|
);
|
||||||
|
const agentById = useMemo(
|
||||||
|
() => new Map((agents ?? []).map((agent) => [agent.id, agent])),
|
||||||
|
[agents],
|
||||||
|
);
|
||||||
|
const projectById = useMemo(
|
||||||
|
() => new Map((projects ?? []).map((project) => [project.id, project])),
|
||||||
|
[projects],
|
||||||
|
);
|
||||||
|
const currentAssignee = draft.assigneeAgentId ? agentById.get(draft.assigneeAgentId) ?? null : null;
|
||||||
|
const currentProject = draft.projectId ? projectById.get(draft.projectId) ?? null : null;
|
||||||
|
|
||||||
|
if (!selectedCompanyId) {
|
||||||
|
return <EmptyState icon={Repeat} message="Select a company to view routines." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <PageSkeleton variant="issues-list" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
|
||||||
|
Routines
|
||||||
|
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-800 dark:bg-amber-900/30 dark:text-amber-400">Beta</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Recurring work definitions that materialize into auditable execution issues.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => setComposerOpen(true)}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Create routine
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={composerOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!createRoutine.isPending) {
|
||||||
|
setComposerOpen(open);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent showCloseButton={false} className="max-w-3xl gap-0 overflow-hidden p-0">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border/60 px-5 py-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium uppercase tracking-[0.2em] text-muted-foreground">New routine</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Define the recurring work first. Trigger setup comes next on the detail page.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setComposerOpen(false);
|
||||||
|
setAdvancedOpen(false);
|
||||||
|
}}
|
||||||
|
disabled={createRoutine.isPending}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-5 pt-5 pb-3">
|
||||||
|
<textarea
|
||||||
|
ref={titleInputRef}
|
||||||
|
className="w-full resize-none overflow-hidden bg-transparent text-xl font-semibold outline-none placeholder:text-muted-foreground/50"
|
||||||
|
placeholder="Routine title"
|
||||||
|
rows={1}
|
||||||
|
value={draft.title}
|
||||||
|
onChange={(event) => {
|
||||||
|
setDraft((current) => ({ ...current, title: event.target.value }));
|
||||||
|
autoResizeTextarea(event.target);
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter" && !event.metaKey && !event.ctrlKey && !event.nativeEvent.isComposing) {
|
||||||
|
event.preventDefault();
|
||||||
|
descriptionEditorRef.current?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === "Tab" && !event.shiftKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (draft.assigneeAgentId) {
|
||||||
|
if (draft.projectId) {
|
||||||
|
descriptionEditorRef.current?.focus();
|
||||||
|
} else {
|
||||||
|
projectSelectorRef.current?.focus();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
assigneeSelectorRef.current?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-5 pb-3">
|
||||||
|
<div className="overflow-x-auto overscroll-x-contain">
|
||||||
|
<div className="inline-flex min-w-full flex-wrap items-center gap-2 text-sm text-muted-foreground sm:min-w-max sm:flex-nowrap">
|
||||||
|
<span>For</span>
|
||||||
|
<InlineEntitySelector
|
||||||
|
ref={assigneeSelectorRef}
|
||||||
|
value={draft.assigneeAgentId}
|
||||||
|
options={assigneeOptions}
|
||||||
|
placeholder="Assignee"
|
||||||
|
noneLabel="No assignee"
|
||||||
|
searchPlaceholder="Search assignees..."
|
||||||
|
emptyMessage="No assignees found."
|
||||||
|
onChange={(assigneeAgentId) => {
|
||||||
|
if (assigneeAgentId) trackRecentAssignee(assigneeAgentId);
|
||||||
|
setDraft((current) => ({ ...current, assigneeAgentId }));
|
||||||
|
}}
|
||||||
|
onConfirm={() => {
|
||||||
|
if (draft.projectId) {
|
||||||
|
descriptionEditorRef.current?.focus();
|
||||||
|
} else {
|
||||||
|
projectSelectorRef.current?.focus();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
renderTriggerValue={(option) =>
|
||||||
|
option ? (
|
||||||
|
currentAssignee ? (
|
||||||
|
<>
|
||||||
|
<AgentIcon icon={currentAssignee.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="truncate">{option.label}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="truncate">{option.label}</span>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">Assignee</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
renderOption={(option) => {
|
||||||
|
if (!option.id) return <span className="truncate">{option.label}</span>;
|
||||||
|
const assignee = agentById.get(option.id);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{assignee ? <AgentIcon icon={assignee.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : null}
|
||||||
|
<span className="truncate">{option.label}</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>in</span>
|
||||||
|
<InlineEntitySelector
|
||||||
|
ref={projectSelectorRef}
|
||||||
|
value={draft.projectId}
|
||||||
|
options={projectOptions}
|
||||||
|
placeholder="Project"
|
||||||
|
noneLabel="No project"
|
||||||
|
searchPlaceholder="Search projects..."
|
||||||
|
emptyMessage="No projects found."
|
||||||
|
onChange={(projectId) => setDraft((current) => ({ ...current, projectId }))}
|
||||||
|
onConfirm={() => descriptionEditorRef.current?.focus()}
|
||||||
|
renderTriggerValue={(option) =>
|
||||||
|
option && currentProject ? (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className="h-3.5 w-3.5 shrink-0 rounded-sm"
|
||||||
|
style={{ backgroundColor: currentProject.color ?? "#64748b" }}
|
||||||
|
/>
|
||||||
|
<span className="truncate">{option.label}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">Project</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
renderOption={(option) => {
|
||||||
|
if (!option.id) return <span className="truncate">{option.label}</span>;
|
||||||
|
const project = projectById.get(option.id);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className="h-3.5 w-3.5 shrink-0 rounded-sm"
|
||||||
|
style={{ backgroundColor: project?.color ?? "#64748b" }}
|
||||||
|
/>
|
||||||
|
<span className="truncate">{option.label}</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-border/60 px-5 py-4">
|
||||||
|
<MarkdownEditor
|
||||||
|
ref={descriptionEditorRef}
|
||||||
|
value={draft.description}
|
||||||
|
onChange={(description) => setDraft((current) => ({ ...current, description }))}
|
||||||
|
placeholder="Add instructions..."
|
||||||
|
bordered={false}
|
||||||
|
contentClassName="min-h-[160px] text-sm text-muted-foreground"
|
||||||
|
onSubmit={() => {
|
||||||
|
if (!createRoutine.isPending && draft.title.trim() && draft.projectId && draft.assigneeAgentId) {
|
||||||
|
createRoutine.mutate();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-border/60 px-5 py-3">
|
||||||
|
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
||||||
|
<CollapsibleTrigger className="flex w-full items-center justify-between text-left">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Advanced delivery settings</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Keep policy controls secondary to the work definition.</p>
|
||||||
|
</div>
|
||||||
|
{advancedOpen ? <ChevronDown className="h-4 w-4 text-muted-foreground" /> : <ChevronRight className="h-4 w-4 text-muted-foreground" />}
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent className="pt-3">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">Concurrency</p>
|
||||||
|
<Select
|
||||||
|
value={draft.concurrencyPolicy}
|
||||||
|
onValueChange={(concurrencyPolicy) => setDraft((current) => ({ ...current, concurrencyPolicy }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{concurrencyPolicies.map((value) => (
|
||||||
|
<SelectItem key={value} value={value}>{value.replaceAll("_", " ")}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-muted-foreground">{concurrencyPolicyDescriptions[draft.concurrencyPolicy]}</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">Catch-up</p>
|
||||||
|
<Select
|
||||||
|
value={draft.catchUpPolicy}
|
||||||
|
onValueChange={(catchUpPolicy) => setDraft((current) => ({ ...current, catchUpPolicy }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{catchUpPolicies.map((value) => (
|
||||||
|
<SelectItem key={value} value={value}>{value.replaceAll("_", " ")}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-muted-foreground">{catchUpPolicyDescriptions[draft.catchUpPolicy]}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 border-t border-border/60 px-5 py-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
After creation, Paperclip takes you straight to trigger setup for schedules, webhooks, or internal runs.
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2 sm:items-end">
|
||||||
|
<Button
|
||||||
|
onClick={() => createRoutine.mutate()}
|
||||||
|
disabled={
|
||||||
|
createRoutine.isPending ||
|
||||||
|
!draft.title.trim() ||
|
||||||
|
!draft.projectId ||
|
||||||
|
!draft.assigneeAgentId
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
{createRoutine.isPending ? "Creating..." : "Create routine"}
|
||||||
|
</Button>
|
||||||
|
{createRoutine.isError ? (
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
{createRoutine.error instanceof Error ? createRoutine.error.message : "Failed to create routine"}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6 text-sm text-destructive">
|
||||||
|
{error instanceof Error ? error.message : "Failed to load routines"}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{(routines ?? []).length === 0 ? (
|
||||||
|
<div className="py-12">
|
||||||
|
<EmptyState
|
||||||
|
icon={Repeat}
|
||||||
|
message="No routines yet. Use Create routine to define the first recurring workflow."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-xs text-muted-foreground border-b border-border">
|
||||||
|
<th className="px-3 py-2 font-medium">Name</th>
|
||||||
|
<th className="px-3 py-2 font-medium">Project</th>
|
||||||
|
<th className="px-3 py-2 font-medium">Agent</th>
|
||||||
|
<th className="px-3 py-2 font-medium">Last run</th>
|
||||||
|
<th className="px-3 py-2 font-medium">Enabled</th>
|
||||||
|
<th className="w-12 px-3 py-2" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(routines ?? []).map((routine) => {
|
||||||
|
const enabled = routine.status === "active";
|
||||||
|
const isArchived = routine.status === "archived";
|
||||||
|
const isStatusPending = statusMutationRoutineId === routine.id;
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={routine.id}
|
||||||
|
className="align-middle border-b border-border transition-colors hover:bg-accent/50 last:border-b-0 cursor-pointer"
|
||||||
|
onClick={() => navigate(`/routines/${routine.id}`)}
|
||||||
|
>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
<div className="min-w-[180px]">
|
||||||
|
<span className="font-medium">
|
||||||
|
{routine.title}
|
||||||
|
</span>
|
||||||
|
{(isArchived || routine.status === "paused") && (
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{isArchived ? "archived" : "paused"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
{routine.projectId ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<span
|
||||||
|
className="shrink-0 h-3 w-3 rounded-sm"
|
||||||
|
style={{ backgroundColor: projectById.get(routine.projectId)?.color ?? "#6366f1" }}
|
||||||
|
/>
|
||||||
|
<span className="truncate">{projectById.get(routine.projectId)?.name ?? "Unknown"}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
{routine.assigneeAgentId ? (() => {
|
||||||
|
const agent = agentById.get(routine.assigneeAgentId);
|
||||||
|
return agent ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<AgentIcon icon={agent.icon} className="h-4 w-4 shrink-0" />
|
||||||
|
<span className="truncate">{agent.name}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">Unknown</span>
|
||||||
|
);
|
||||||
|
})() : (
|
||||||
|
<span className="text-xs text-muted-foreground">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 text-muted-foreground">
|
||||||
|
<div>{formatLastRunTimestamp(routine.lastRun?.triggeredAt)}</div>
|
||||||
|
{routine.lastRun ? (
|
||||||
|
<div className="mt-1 text-xs">{routine.lastRun.status.replaceAll("_", " ")}</div>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={enabled}
|
||||||
|
aria-label={enabled ? `Disable ${routine.title}` : `Enable ${routine.title}`}
|
||||||
|
disabled={isStatusPending || isArchived}
|
||||||
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||||
|
enabled ? "bg-foreground" : "bg-muted"
|
||||||
|
} ${isStatusPending || isArchived ? "cursor-not-allowed opacity-50" : ""}`}
|
||||||
|
onClick={() =>
|
||||||
|
updateRoutineStatus.mutate({
|
||||||
|
id: routine.id,
|
||||||
|
status: nextRoutineStatus(routine.status, !enabled),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-5 w-5 rounded-full bg-background shadow-sm transition-transform ${
|
||||||
|
enabled ? "translate-x-5" : "translate-x-0.5"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{isArchived ? "Archived" : enabled ? "On" : "Off"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 text-right" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon-sm" aria-label={`More actions for ${routine.title}`}>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => navigate(`/routines/${routine.id}`)}>
|
||||||
|
Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
disabled={runningRoutineId === routine.id || isArchived}
|
||||||
|
onClick={() => runRoutine.mutate(routine.id)}
|
||||||
|
>
|
||||||
|
{runningRoutineId === routine.id ? "Running..." : "Run now"}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() =>
|
||||||
|
updateRoutineStatus.mutate({
|
||||||
|
id: routine.id,
|
||||||
|
status: enabled ? "paused" : "active",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={isStatusPending || isArchived}
|
||||||
|
>
|
||||||
|
{enabled ? "Pause" : "Enable"}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() =>
|
||||||
|
updateRoutineStatus.mutate({
|
||||||
|
id: routine.id,
|
||||||
|
status: routine.status === "archived" ? "active" : "archived",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={isStatusPending}
|
||||||
|
>
|
||||||
|
{routine.status === "archived" ? "Restore" : "Archive"}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user