mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-11 05:31:46 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Scheduled routines can prompt agents with variables that are filled in at dispatch time. > - Existing routine variable handling supported plain text-like values, but date inputs need a structured contract so routines can pass consistent date values. > - Operators also need date variables to be easy to configure and override from the routine UI. > - This pull request adds a date variable type across shared validation, server dispatch, and UI editing/run dialogs. > - The benefit is that routine authors can define date inputs once and agents receive validated ISO-style date values when routines run. ## Linked Issues or Issue Description Refs #219 Feature request: - Problem/motivation: Scheduled routines need first-class, typed date variables so operators can configure dates without relying on free-form text conventions. - Proposed solution: Add an `x-date` routine variable type with shared parsing/validation, server dispatch support, and UI date-picker controls in routine variable editors and run dialogs. - Alternatives considered: Continue treating dates as plain text, but that leaves validation and formatting to individual operators and agents. - Roadmap alignment: This is a focused improvement to the completed Scheduled Routines milestone and does not duplicate an active roadmap item. Related PR search: - Searched existing PRs/issues for `routine date picker`, `date variables`, and `scheduled routine date variable`; no direct duplicate PR was found. ## What Changed - Added the shared `x-date` routine variable contract, parsing, defaults, and validation coverage. - Extended routine dispatch to validate and pass date variable values. - Added date input controls to the routine variable editor and routine run variables dialog. - Added focused tests for shared validation, server dispatch, and the UI date controls. ## Verification - `git diff --check public/master...HEAD` - `pnpm run preflight:workspace-links && pnpm exec vitest run packages/shared/src/routine-variables.test.ts packages/shared/src/validators/routine.test.ts server/src/__tests__/routines-service.test.ts ui/src/components/RoutineRunVariablesDialog.test.tsx ui/src/components/RoutineVariablesEditor.test.tsx` - 5 test files passed - 68 tests passed ## Risks Low to medium risk. This adds a new routine variable type across shared/server/UI paths, so the main risk is compatibility with existing routine variable payloads. The change keeps existing variable types intact and adds targeted validation tests for the new date behavior. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex coding agent based on GPT-5, with terminal, git, GitHub CLI, and local test execution capabilities. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
144 lines
4.6 KiB
TypeScript
144 lines
4.6 KiB
TypeScript
import type { RoutineVariable } from "./types/routine.js";
|
|
|
|
// Tolerate markdown-escaped underscores (`\_`) inside placeholders. WYSIWYG markdown
|
|
// editors (e.g. MDXEditor) serialize `_` between word chars as `\_` to prevent
|
|
// reparse-as-emphasis, so a user-typed `{{pr_url}}` is stored as `{{pr\_url}}`.
|
|
const ROUTINE_VARIABLE_MATCHER = /\{\{\s*([A-Za-z](?:\\_|[A-Za-z0-9_])*)\s*\}\}/g;
|
|
|
|
function unescapeRoutineVariableName(raw: string): string {
|
|
return raw.replace(/\\_/g, "_");
|
|
}
|
|
|
|
type RoutineTemplateInput = string | null | undefined | Array<string | null | undefined>;
|
|
|
|
/**
|
|
* Built-in variable names that are automatically available in routine templates
|
|
* without needing to be defined in the routine's variables list.
|
|
*/
|
|
export const BUILTIN_ROUTINE_VARIABLE_NAMES = new Set(["date", "timestamp"]);
|
|
|
|
export function isBuiltinRoutineVariable(name: string): boolean {
|
|
return BUILTIN_ROUTINE_VARIABLE_NAMES.has(name);
|
|
}
|
|
|
|
const HUMAN_TIMESTAMP_FORMATTER = new Intl.DateTimeFormat("en-US", {
|
|
year: "numeric",
|
|
month: "long",
|
|
day: "numeric",
|
|
hour: "numeric",
|
|
minute: "2-digit",
|
|
hour12: true,
|
|
timeZone: "UTC",
|
|
timeZoneName: "short",
|
|
});
|
|
|
|
/**
|
|
* Returns current values for all built-in routine variables.
|
|
* `date` expands to the current date in YYYY-MM-DD format (UTC).
|
|
* `timestamp` expands to a human-readable date and time (e.g. "April 28, 2026 at 12:17 PM UTC").
|
|
*/
|
|
export function getBuiltinRoutineVariableValues(): Record<string, string> {
|
|
const now = new Date();
|
|
return {
|
|
date: now.toISOString().slice(0, 10),
|
|
timestamp: HUMAN_TIMESTAMP_FORMATTER.format(now),
|
|
};
|
|
}
|
|
|
|
export function isValidRoutineVariableName(name: string): boolean {
|
|
return /^[A-Za-z][A-Za-z0-9_]*$/.test(name);
|
|
}
|
|
|
|
export function isRoutineDateVariableName(name: string): boolean {
|
|
return isValidRoutineVariableName(name) && name.length > "Date".length && name.endsWith("Date");
|
|
}
|
|
|
|
export function isValidRoutineDateString(value: string): boolean {
|
|
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
|
if (!match) return false;
|
|
|
|
const year = Number(match[1]);
|
|
const month = Number(match[2]);
|
|
const day = Number(match[3]);
|
|
if (month < 1 || month > 12) return false;
|
|
|
|
const leapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
|
|
const daysInMonth = [
|
|
31,
|
|
leapYear ? 29 : 28,
|
|
31,
|
|
30,
|
|
31,
|
|
30,
|
|
31,
|
|
31,
|
|
30,
|
|
31,
|
|
30,
|
|
31,
|
|
][month - 1]!;
|
|
return day >= 1 && day <= daysInMonth;
|
|
}
|
|
|
|
function normalizeRoutineTemplateInput(input: RoutineTemplateInput): string[] {
|
|
const templates = Array.isArray(input) ? input : [input];
|
|
return templates.filter((template): template is string => typeof template === "string" && template.length > 0);
|
|
}
|
|
|
|
export function extractRoutineVariableNames(template: RoutineTemplateInput): string[] {
|
|
const found = new Set<string>();
|
|
for (const source of normalizeRoutineTemplateInput(template)) {
|
|
for (const match of source.matchAll(ROUTINE_VARIABLE_MATCHER)) {
|
|
const name = match[1] ? unescapeRoutineVariableName(match[1]) : "";
|
|
if (name && !found.has(name)) {
|
|
found.add(name);
|
|
}
|
|
}
|
|
}
|
|
return [...found];
|
|
}
|
|
|
|
function defaultRoutineVariable(name: string): RoutineVariable {
|
|
return {
|
|
name,
|
|
label: null,
|
|
type: isRoutineDateVariableName(name) ? "date" : "text",
|
|
defaultValue: null,
|
|
required: true,
|
|
options: [],
|
|
};
|
|
}
|
|
|
|
export function syncRoutineVariablesWithTemplate(
|
|
template: RoutineTemplateInput,
|
|
existing: RoutineVariable[] | null | undefined,
|
|
): RoutineVariable[] {
|
|
const names = extractRoutineVariableNames(template).filter((name) => !isBuiltinRoutineVariable(name));
|
|
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) => {
|
|
const name = unescapeRoutineVariableName(rawName);
|
|
if (!(name in values)) return match;
|
|
return stringifyRoutineVariableValue(values[name]);
|
|
});
|
|
}
|