feat(routines): add workspace-aware routine runs

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-04-02 11:38:57 -05:00
parent 36376968af
commit 909e8cd4c8
38 changed files with 15468 additions and 250 deletions

View file

@ -166,6 +166,9 @@ 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_VARIABLE_TYPES = ["text", "textarea", "number", "boolean", "select"] as const;
export type RoutineVariableType = (typeof ROUTINE_VARIABLE_TYPES)[number];
export const ROUTINE_RUN_STATUSES = [
"received",
"coalesced",

View file

@ -21,6 +21,7 @@ export {
ROUTINE_CATCH_UP_POLICIES,
ROUTINE_TRIGGER_KINDS,
ROUTINE_TRIGGER_SIGNING_MODES,
ROUTINE_VARIABLE_TYPES,
ROUTINE_RUN_STATUSES,
ROUTINE_RUN_SOURCES,
PAUSE_REASONS,
@ -88,6 +89,7 @@ export {
type RoutineCatchUpPolicy,
type RoutineTriggerKind,
type RoutineTriggerSigningMode,
type RoutineVariableType,
type RoutineRunStatus,
type RoutineRunSource,
type PauseReason,
@ -255,6 +257,8 @@ export type {
FinanceSummary,
FinanceByBiller,
FinanceByKind,
AgentWakeupResponse,
AgentWakeupSkipped,
HeartbeatRun,
HeartbeatRunEvent,
AgentRuntimeState,
@ -304,6 +308,8 @@ export type {
CompanySecret,
SecretProviderDescriptor,
Routine,
RoutineVariable,
RoutineVariableDefaultValue,
RoutineTrigger,
RoutineRun,
RoutineTriggerSecretMaterial,
@ -473,6 +479,7 @@ export {
updateRoutineSchema,
createRoutineTriggerSchema,
updateRoutineTriggerSchema,
routineVariableSchema,
runRoutineSchema,
rotateRoutineTriggerSecretSchema,
type CreateSecret,
@ -597,6 +604,14 @@ export {
type ParsedProjectMention,
} from "./project-mentions.js";
export {
extractRoutineVariableNames,
interpolateRoutineTemplate,
isValidRoutineVariableName,
stringifyRoutineVariableValue,
syncRoutineVariablesWithTemplate,
} from "./routine-variables.js";
export {
paperclipConfigSchema,
configMetaSchema,

View file

@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import {
extractRoutineVariableNames,
interpolateRoutineTemplate,
syncRoutineVariablesWithTemplate,
} from "./routine-variables.js";
describe("routine variable helpers", () => {
it("extracts placeholder names in first-appearance order", () => {
expect(
extractRoutineVariableNames("Review {{repo}} and {{priority}} for {{repo}}"),
).toEqual(["repo", "priority"]);
});
it("preserves existing metadata when syncing variables from a template", () => {
expect(
syncRoutineVariablesWithTemplate("Review {{repo}} and {{priority}}", [
{ name: "repo", label: "Repository", type: "text", defaultValue: "paperclip", required: true, options: [] },
]),
).toEqual([
{ name: "repo", label: "Repository", type: "text", defaultValue: "paperclip", required: true, options: [] },
{ name: "priority", label: null, type: "text", defaultValue: null, required: true, options: [] },
]);
});
it("interpolates provided variable values into the routine template", () => {
expect(
interpolateRoutineTemplate("Review {{repo}} for {{priority}}", {
repo: "paperclip",
priority: "high",
}),
).toBe("Review paperclip for high");
});
});

View file

@ -0,0 +1,62 @@
import type { RoutineVariable } from "./types/routine.js";
const ROUTINE_VARIABLE_MATCHER = /\{\{\s*([A-Za-z][A-Za-z0-9_]*)\s*\}\}/g;
export function isValidRoutineVariableName(name: string): boolean {
return /^[A-Za-z][A-Za-z0-9_]*$/.test(name);
}
export function extractRoutineVariableNames(template: string | null | undefined): string[] {
if (!template) return [];
const found = new Set<string>();
for (const match of template.matchAll(ROUTINE_VARIABLE_MATCHER)) {
const name = match[1];
if (name && !found.has(name)) {
found.add(name);
}
}
return [...found];
}
function defaultRoutineVariable(name: string): RoutineVariable {
return {
name,
label: null,
type: "text",
defaultValue: null,
required: true,
options: [],
};
}
export function syncRoutineVariablesWithTemplate(
template: string | null | undefined,
existing: RoutineVariable[] | null | undefined,
): RoutineVariable[] {
const names = extractRoutineVariableNames(template);
const existingByName = new Map((existing ?? []).map((variable) => [variable.name, variable]));
return names.map((name) => existingByName.get(name) ?? defaultRoutineVariable(name));
}
export function stringifyRoutineVariableValue(value: unknown): string {
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
if (value == null) return "";
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
export function interpolateRoutineTemplate(
template: string | null | undefined,
values: Record<string, unknown> | null | undefined,
): string | null {
if (template == null) return null;
if (!values || Object.keys(values).length === 0) return template;
return template.replace(ROUTINE_VARIABLE_MATCHER, (match, rawName: string) => {
if (!(rawName in values)) return match;
return stringifyRoutineVariableValue(values[rawName]);
});
}

View file

@ -57,6 +57,8 @@ export interface CompanyPortabilityProjectManifestEntry {
metadata: Record<string, unknown> | null;
}
import type { RoutineVariable } from "./routine.js";
export interface CompanyPortabilityProjectWorkspaceManifestEntry {
key: string;
name: string;
@ -84,6 +86,7 @@ export interface CompanyPortabilityIssueRoutineTriggerManifestEntry {
export interface CompanyPortabilityIssueRoutineManifestEntry {
concurrencyPolicy: string | null;
catchUpPolicy: string | null;
variables?: RoutineVariable[] | null;
triggers: CompanyPortabilityIssueRoutineTriggerManifestEntry[];
}

View file

@ -42,6 +42,18 @@ export interface HeartbeatRun {
updatedAt: Date;
}
export interface AgentWakeupSkipped {
status: "skipped";
reason: string;
message: string | null;
issueId: string | null;
executionRunId: string | null;
executionAgentId: string | null;
executionAgentName: string | null;
}
export type AgentWakeupResponse = HeartbeatRun | AgentWakeupSkipped;
export interface HeartbeatRunEvent {
id: number;
companyId: string;

View file

@ -130,6 +130,8 @@ export type {
} from "./secrets.js";
export type {
Routine,
RoutineVariable,
RoutineVariableDefaultValue,
RoutineTrigger,
RoutineRun,
RoutineTriggerSecretMaterial,
@ -141,6 +143,8 @@ export type {
export type { CostEvent, CostSummary, CostByAgent, CostByProviderModel, CostByBiller, CostByAgentModel, CostWindowSpendRow, CostByProject } from "./cost.js";
export type { FinanceEvent, FinanceSummary, FinanceByBiller, FinanceByKind } from "./finance.js";
export type {
AgentWakeupResponse,
AgentWakeupSkipped,
HeartbeatRun,
HeartbeatRunEvent,
AgentRuntimeState,

View file

@ -1,4 +1,4 @@
import type { IssueOriginKind } from "../constants.js";
import type { IssueOriginKind, RoutineVariableType } from "../constants.js";
export interface RoutineProjectSummary {
id: string;
@ -25,6 +25,17 @@ export interface RoutineIssueSummary {
updatedAt: Date;
}
export type RoutineVariableDefaultValue = string | number | boolean | null;
export interface RoutineVariable {
name: string;
label: string | null;
type: RoutineVariableType;
defaultValue: RoutineVariableDefaultValue;
required: boolean;
options: string[];
}
export interface Routine {
id: string;
companyId: string;
@ -38,6 +49,7 @@ export interface Routine {
status: string;
concurrencyPolicy: string;
catchUpPolicy: string;
variables: RoutineVariable[];
createdByAgentId: string | null;
createdByUserId: string | null;
updatedByAgentId: string | null;

View file

@ -1,4 +1,5 @@
import { z } from "zod";
import { routineVariableSchema } from "./routine.js";
export const portabilityIncludeSchema = z
.object({
@ -123,6 +124,7 @@ export const portabilityIssueRoutineTriggerManifestEntrySchema = z.object({
export const portabilityIssueRoutineManifestEntrySchema = z.object({
concurrencyPolicy: z.string().nullable(),
catchUpPolicy: z.string().nullable(),
variables: z.array(routineVariableSchema).nullable().optional(),
triggers: z.array(portabilityIssueRoutineTriggerManifestEntrySchema).default([]),
});

View file

@ -214,6 +214,7 @@ export {
updateRoutineSchema,
createRoutineTriggerSchema,
updateRoutineTriggerSchema,
routineVariableSchema,
runRoutineSchema,
rotateRoutineTriggerSecretSchema,
type CreateRoutine,

View file

@ -5,7 +5,44 @@ import {
ROUTINE_CONCURRENCY_POLICIES,
ROUTINE_STATUSES,
ROUTINE_TRIGGER_SIGNING_MODES,
ROUTINE_VARIABLE_TYPES,
} from "../constants.js";
import { issueExecutionWorkspaceSettingsSchema } from "./issue.js";
const routineVariableValueSchema = z.union([z.string(), z.number().finite(), z.boolean()]);
export const routineVariableSchema = z.object({
name: z.string().trim().regex(/^[A-Za-z][A-Za-z0-9_]*$/),
label: z.string().trim().max(120).optional().nullable(),
type: z.enum(ROUTINE_VARIABLE_TYPES).optional().default("text"),
defaultValue: routineVariableValueSchema.optional().nullable(),
required: z.boolean().optional().default(true),
options: z.array(z.string().trim().min(1).max(120)).max(50).optional().default([]),
}).superRefine((value, ctx) => {
if (value.type === "select" && value.options.length === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["options"],
message: "Select variables require at least one option",
});
}
if (value.type !== "select" && value.options.length > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["options"],
message: "Only select variables can define options",
});
}
if (value.type === "select" && value.defaultValue != null) {
if (typeof value.defaultValue !== "string" || !value.options.includes(value.defaultValue)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["defaultValue"],
message: "Select variable defaults must match one of the allowed options",
});
}
}
});
export const createRoutineSchema = z.object({
projectId: z.string().uuid(),
@ -18,6 +55,7 @@ export const createRoutineSchema = z.object({
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"),
variables: z.array(routineVariableSchema).optional().default([]),
});
export type CreateRoutine = z.infer<typeof createRoutineSchema>;
@ -62,8 +100,19 @@ 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(),
variables: z.record(routineVariableValueSchema).optional().nullable(),
idempotencyKey: z.string().trim().max(255).optional().nullable(),
source: z.enum(["manual", "api"]).optional().default("manual"),
executionWorkspaceId: z.string().uuid().optional().nullable(),
executionWorkspacePreference: z.enum([
"inherit",
"shared_workspace",
"isolated_workspace",
"operator_branch",
"reuse_existing",
"agent_default",
]).optional().nullable(),
executionWorkspaceSettings: issueExecutionWorkspaceSettingsSchema.optional().nullable(),
});
export type RunRoutine = z.infer<typeof runRoutineSchema>;