- Add task `source` provenance field tracking how tasks enter the system (e.g. `"manual"`, `"heartbeat"`, `"executor"`, `"triage"`, `"cron"`, `"routine"`) - Wire `source` into shared create-task tools, executor agent, triage agent, cron runner, and routine runner - Add `source` to heartbeat task creation in `agent-heartbeat.ts` and mission execution loop in `mission-execution-loop.ts` - Wire `source` field into dashboard routes (`register-planning-subtask-routes.ts`, `register-task-workflow-routes.ts`) and GitHub integration (`register-git-github.ts`) - Add `source` to the API layer and task store handlers in the dashboard - Expose `source` in the `fn` CLI task command and `task-plan` extension tool - Add integration tests for provenance across engine (agent-tools, agent-heartbeat, cron-runner, triage, pr-comment-handler) and dashboard routes - Update existing tests to assert the correct source values - Bulk-revert stale changeset files that were superseded by the v0.9.1 release commit Commits merged: - test(FN-2923): complete Step 14 — align tests with task source provenance - test(FN-2923): complete Steps 12-13 — update and add provenance tests - feat(FN-2923): complete Step 10 — wire CLI task provenance - feat(FN-2923): complete Step 9 — wire dashboard UI and API source fields - feat(FN-2923): complete Step 8 — wire dashboard route provenance - feat(FN-2923): complete Step 7 — wire remaining engine provenance - feat(FN-2923): complete Step 6 — add routine create-task provenance - feat(FN-2923): complete Step 5 — add cron create-task provenance - feat(FN-2923): complete Step 4 — add triage subtask source - feat(FN-2923): complete Step 3 — add executor create-task source - feat(FN-2923): complete Step 2 — add heartbeat task provenance - feat(FN-2923): complete Step 1 — add provenance to shared create-task tools - feat(FN-2945): merge fusion/fn-2945 - chore(release): v0.9.1 Files changed: .changeset/active-agents-no-stuck-connecting.md | 13 --- .changeset/active-agents-panel-hoist-heartbeat.md | 13 --- .changeset/fix-agent-heartbeat-terminal-links.md | 5 -- .changeset/fix-experimental-defaults.md | 5 -- .changeset/improve-git-manager-diff-pane.md | 5 -- .changeset/insights-two-pane-layout.md | 5 -- .changeset/show-planning-tasks-immediately.md | 5 -- CHANGELOG.md | 92 ++++++++++++++++++++++ package.json | 2 +- packages/cli-alias/CHANGELOG.md | 15 ++++ packages/cli-alias/package.json | 2 +- packages/cli/CHANGELOG.md | 12 +++ packages/cli/package.json | 2 +- packages/cli/src/__tests__/task-plan.test.ts | 1 + packages/cli/src/commands/__tests__/task.test.ts | 15 +++- packages/cli/src/commands/task.ts | 15 +++- packages/cli/src/extension.ts | 9 +++ packages/core/CHANGELOG.md | 7 ++ packages/core/package.json | 2 +- packages/dashboard/CHANGELOG.md | 14 ++++ .../app/__tests__/agent-css-classes.test.ts | 3 +- packages/dashboard/app/__tests__/api.test.ts | 13 +++ packages/dashboard/app/api/legacy.ts | 2 + packages/dashboard/app/components/AgentsView.css | 22 +++--- .../app/components/ModelOnboardingModal.tsx | 5 +- packages/dashboard/app/components/TodoView.tsx | 2 + .../__tests__/ModelOnboardingModal.test.tsx | 4 +- .../app/components/__tests__/QuickChatFAB.test.tsx | 14 +--- .../app/components/__tests__/TodoView.test.tsx | 4 +- .../__tests__/agents-view-mobile.test.tsx | 5 +- .../components/__tests__/onboarding-flow.test.tsx | 2 +- .../app/hooks/__tests__/useTaskHandlers.test.ts | 4 +- packages/dashboard/app/hooks/useTaskHandlers.ts | 4 +- packages/dashboard/package.json | 2 +- packages/dashboard/src/__tests__/routes.test.ts | 14 ++++ packages/dashboard/src/routes.ts | 4 + .../dashboard/src/routes/register-git-github.ts | 12 +++ .../src/routes/register-planning-subtask-routes.ts | 3 + .../src/routes/register-task-workflow-routes.ts | 7 ++ packages/desktop/CHANGELOG.md | 7 ++ packages/desktop/package.json | 2 +- packages/engine/CHANGELOG.md | 11 +++ packages/engine/package.json | 2 +- .../engine/src/__tests__/agent-heartbeat.test.ts | 10 +++ .../src/__tests__/agent-tools-delegation.test.ts | 2 + packages/engine/src/__tests__/agent-tools.test.ts | 37 +++++++++ packages/engine/src/__tests__/cron-runner.test.ts | 4 + .../src/__tests__/pr-comment-handler.test.ts | 8 ++ packages/engine/src/__tests__/triage.test.ts | 2 + packages/engine/src/agent-heartbeat.ts | 13 ++- packages/engine/src/agent-tools.ts | 13 ++- packages/engine/src/cron-runner.ts | 7 +- packages/engine/src/executor.ts | 2 +- packages/engine/src/mission-execution-loop.ts | 8 ++ packages/engine/src/pr-comment-handler.ts | 5 ++ packages/engine/src/project-engine.ts | 8 ++ packages/engine/src/routine-runner.ts | 4 + packages/engine/src/triage.ts | 4 + packages/mobile/CHANGELOG.md | 7 ++ packages/mobile/package.json | 2 +- packages/pi-claude-cli/CHANGELOG.md | 7 ++ packages/pi-claude-cli/package.json | 2 +- packages/plugin-sdk/CHANGELOG.md | 10 +++ packages/plugin-sdk/package.json | 2 +- .../examples/fusion-plugin-auto-label/CHANGELOG.md | 8 ++ .../examples/fusion-plugin-auto-label/package.json | 2 +- .../examples/fusion-plugin-ci-status/CHANGELOG.md | 8 ++ .../examples/fusion-plugin-ci-status/package.json | 2 +- .../fusion-plugin-notification/CHANGELOG.md | 8 ++ .../fusion-plugin-notification/package.json | 2 +- .../fusion-plugin-settings-demo/CHANGELOG.md | 8 ++ .../fusion-plugin-settings-demo/package.json | 2 +- plugins/fusion-plugin-hermes-runtime/CHANGELOG.md | 8 ++ plugins/fusion-plugin-hermes-runtime/package.json | 2 +- .../fusion-plugin-openclaw-runtime/CHANGELOG.md | 8 ++ .../fusion-plugin-openclaw-runtime/package.json | 2 +- .../fusion-plugin-paperclip-runtime/CHANGELOG.md | 8 ++ .../fusion-plugin-paperclip-runtime/package.json | 2 +- 78 files changed, 499 insertions(+), 115 deletions(-) Fusion-Task-Id: FN-2923
551 lines
18 KiB
TypeScript
551 lines
18 KiB
TypeScript
/**
|
|
* RoutineRunner — orchestrates routine execution via the heartbeat system.
|
|
*
|
|
* - Validates routine state before execution (enabled, has assigned agent)
|
|
* - Enforces concurrency policies (parallel/skip/queue/replace)
|
|
* - Handles catch-up for missed runs
|
|
* - Triggers heartbeat execution for routines
|
|
*/
|
|
|
|
import { CronExpressionParser } from "cron-parser";
|
|
import { exec } from "node:child_process";
|
|
import { promisify } from "node:util";
|
|
import type {
|
|
RoutineStore,
|
|
Routine,
|
|
RoutineExecutionResult,
|
|
AutomationRunResult,
|
|
AutomationStep,
|
|
AutomationStepResult,
|
|
Column,
|
|
TaskCreateInput,
|
|
TaskStore,
|
|
} from "@fusion/core";
|
|
import type { HeartbeatMonitor } from "./agent-heartbeat.js";
|
|
import type { AiPromptExecutor } from "./cron-runner.js";
|
|
import { createLogger } from "./logger.js";
|
|
import { defaultShell } from "./shell-utils.js";
|
|
|
|
const log = createLogger("routine-runner");
|
|
const execAsync = promisify(exec);
|
|
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
|
|
const MAX_BUFFER = 1024 * 1024;
|
|
const MAX_OUTPUT_LENGTH = 10 * 1024;
|
|
|
|
/** Options for RoutineRunner constructor */
|
|
export interface RoutineRunnerOptions {
|
|
/** RoutineStore for querying and updating routines */
|
|
routineStore: RoutineStore;
|
|
/** HeartbeatMonitor for triggering agent execution */
|
|
heartbeatMonitor: HeartbeatMonitor;
|
|
/** Project root directory */
|
|
rootDir: string;
|
|
/** Optional task store used when routines execute schedule-style actions. */
|
|
taskStore?: TaskStore;
|
|
/** Optional AI prompt executor for ai-prompt action steps. */
|
|
aiPromptExecutor?: AiPromptExecutor;
|
|
}
|
|
|
|
/**
|
|
* Maximum number of catch-up executions to prevent runaway loops.
|
|
*/
|
|
const MAX_CATCH_UP_INTERVALS = 10;
|
|
|
|
/**
|
|
* RoutineRunner orchestrates routine execution via the heartbeat system.
|
|
*
|
|
* Key behaviors:
|
|
* - Enforces concurrency policies before starting executions
|
|
* - Handles catch-up for missed runs based on catch-up policy
|
|
* - Triggers heartbeats with routine context in the trigger detail
|
|
*/
|
|
export class RoutineRunner {
|
|
private options: RoutineRunnerOptions;
|
|
/** Tracks currently-running executions by routine ID */
|
|
private inFlightExecutions: Map<string, Promise<RoutineExecutionResult>> = new Map();
|
|
|
|
constructor(options: RoutineRunnerOptions) {
|
|
this.options = options;
|
|
}
|
|
|
|
/**
|
|
* Execute a routine by ID with a given trigger type.
|
|
*
|
|
* @param routineId - ID of the routine to execute
|
|
* @param triggerType - What triggered this execution: "cron", "webhook", or "api"
|
|
* @param context - Additional context passed to the heartbeat execution
|
|
* @returns The execution result
|
|
* @throws Error if routine not found or disabled
|
|
*/
|
|
async executeRoutine(
|
|
routineId: string,
|
|
triggerType: "cron" | "webhook" | "api",
|
|
context?: Record<string, unknown>,
|
|
): Promise<RoutineExecutionResult> {
|
|
// 1. Load routine
|
|
let routine: Routine;
|
|
try {
|
|
routine = await this.options.routineStore.getRoutine(routineId);
|
|
} catch {
|
|
throw new Error(`Routine '${routineId}' not found`);
|
|
}
|
|
|
|
// 2. Validate routine state
|
|
if (!routine.enabled) {
|
|
throw new Error(`Routine '${routineId}' is disabled`);
|
|
}
|
|
|
|
if (!this.hasRoutineAction(routine) && !routine.agentId) {
|
|
throw new Error(`Routine '${routineId}' has no assigned agent`);
|
|
}
|
|
|
|
// 3. Enforce concurrency policy
|
|
const concurrency = routine.executionPolicy ?? "queue";
|
|
|
|
if (concurrency === "reject" && this.inFlightExecutions.has(routineId)) {
|
|
log.log(`Routine ${routineId} rejected — already running`);
|
|
// Return a failed result without creating an execution record
|
|
return {
|
|
routineId,
|
|
success: false,
|
|
output: "Routine rejected — already running",
|
|
error: "Routine rejected — already running",
|
|
startedAt: new Date().toISOString(),
|
|
completedAt: new Date().toISOString(),
|
|
};
|
|
}
|
|
|
|
// If queue, wait for existing execution
|
|
if (concurrency === "queue" && this.inFlightExecutions.has(routineId)) {
|
|
log.log(`Routine ${routineId} queued — waiting for existing execution`);
|
|
const existingResult = await this.inFlightExecutions.get(routineId);
|
|
if (existingResult) {
|
|
await existingResult;
|
|
}
|
|
}
|
|
|
|
// 4. Record execution start
|
|
const startedAt = new Date().toISOString();
|
|
|
|
// Set in-flight BEFORE starting execution to prevent race conditions
|
|
const executionPromise = this.runExecution(routine, triggerType, context, startedAt);
|
|
this.inFlightExecutions.set(routineId, executionPromise);
|
|
|
|
try {
|
|
await this.options.routineStore.startRoutineExecution(routineId, {
|
|
triggeredAt: startedAt,
|
|
invocationSource: "routine",
|
|
});
|
|
|
|
const result = await executionPromise;
|
|
return result;
|
|
} finally {
|
|
this.inFlightExecutions.delete(routineId);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Internal execution logic for a routine.
|
|
*/
|
|
private async runExecution(
|
|
routine: Routine,
|
|
triggerType: string,
|
|
context: Record<string, unknown> | undefined,
|
|
startedAt: string,
|
|
): Promise<RoutineExecutionResult> {
|
|
const routineId = routine.id;
|
|
|
|
try {
|
|
const actionResult = this.hasRoutineAction(routine)
|
|
? await this.executeRoutineAction(routine, startedAt)
|
|
: await this.executeAgentRoutine(routine, triggerType, context);
|
|
|
|
await this.options.routineStore.completeRoutineExecution(routineId, {
|
|
completedAt: actionResult.completedAt,
|
|
success: actionResult.success,
|
|
resultJson: actionResult.success ? { output: actionResult.output } : undefined,
|
|
output: actionResult.output,
|
|
error: actionResult.error,
|
|
triggerType: triggerType as RoutineExecutionResult["triggerType"],
|
|
stepResults: actionResult.stepResults,
|
|
});
|
|
|
|
return {
|
|
routineId,
|
|
success: actionResult.success,
|
|
output: actionResult.output,
|
|
startedAt,
|
|
completedAt: actionResult.completedAt,
|
|
error: actionResult.error,
|
|
triggerType: triggerType as RoutineExecutionResult["triggerType"],
|
|
stepResults: actionResult.stepResults,
|
|
};
|
|
} catch (err) {
|
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
log.error(`Routine ${routineId} execution failed: ${errorMessage}`);
|
|
|
|
// Record failure
|
|
try {
|
|
await this.options.routineStore.completeRoutineExecution(routineId, {
|
|
completedAt: new Date().toISOString(),
|
|
success: false,
|
|
error: errorMessage,
|
|
});
|
|
} catch (persistError) {
|
|
log.error(`[${routineId}] Failed to persist error state: ${persistError}`);
|
|
}
|
|
|
|
return {
|
|
routineId,
|
|
success: false,
|
|
output: errorMessage,
|
|
startedAt,
|
|
completedAt: new Date().toISOString(),
|
|
error: errorMessage,
|
|
};
|
|
}
|
|
}
|
|
|
|
private hasRoutineAction(routine: Routine): boolean {
|
|
return Boolean((routine.steps && routine.steps.length > 0) || routine.command?.trim());
|
|
}
|
|
|
|
private async executeAgentRoutine(
|
|
routine: Routine,
|
|
triggerType: string,
|
|
context: Record<string, unknown> | undefined,
|
|
): Promise<AutomationRunResult> {
|
|
const run = await this.options.heartbeatMonitor.executeHeartbeat({
|
|
agentId: routine.agentId,
|
|
source: "routine",
|
|
triggerDetail: `routine:${routine.id}:${triggerType}`,
|
|
contextSnapshot: {
|
|
routineId: routine.id,
|
|
routineName: routine.name,
|
|
triggerType,
|
|
...context,
|
|
},
|
|
});
|
|
|
|
if (run.status === "failed" || run.status === "terminated") {
|
|
const error = run.stderrExcerpt || `Run ${run.status}`;
|
|
return {
|
|
success: false,
|
|
output: error,
|
|
error,
|
|
startedAt: new Date().toISOString(),
|
|
completedAt: new Date().toISOString(),
|
|
};
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
output: run.resultJson ? JSON.stringify(run.resultJson) : "Routine completed successfully",
|
|
startedAt: new Date().toISOString(),
|
|
completedAt: new Date().toISOString(),
|
|
};
|
|
}
|
|
|
|
private async executeRoutineAction(
|
|
routine: Routine,
|
|
startedAt: string,
|
|
): Promise<AutomationRunResult> {
|
|
if (routine.steps && routine.steps.length > 0) {
|
|
return this.executeSteps(routine, startedAt);
|
|
}
|
|
return this.executeCommand(routine.command ?? "", routine.timeoutMs, startedAt);
|
|
}
|
|
|
|
private async executeCommand(
|
|
command: string,
|
|
timeoutMs: number | undefined,
|
|
startedAt: string,
|
|
): Promise<AutomationRunResult> {
|
|
try {
|
|
const { stdout, stderr } = await execAsync(command, {
|
|
timeout: timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
maxBuffer: MAX_BUFFER,
|
|
shell: defaultShell,
|
|
});
|
|
|
|
return {
|
|
success: true,
|
|
output: truncateOutput(stdout, stderr),
|
|
startedAt,
|
|
completedAt: new Date().toISOString(),
|
|
};
|
|
} catch (err) {
|
|
const errObj = err as Record<string, unknown>;
|
|
const stdout = typeof errObj.stdout === "string" ? errObj.stdout : "";
|
|
const stderr = typeof errObj.stderr === "string" ? errObj.stderr : "";
|
|
const error = errObj.killed === true
|
|
? `Command timed out after ${(timeoutMs ?? DEFAULT_TIMEOUT_MS) / 1000}s`
|
|
: (err instanceof Error ? err.message : null) ?? String(err);
|
|
return {
|
|
success: false,
|
|
output: truncateOutput(stdout, stderr),
|
|
error,
|
|
startedAt,
|
|
completedAt: new Date().toISOString(),
|
|
};
|
|
}
|
|
}
|
|
|
|
private async executeSteps(routine: Routine, startedAt: string): Promise<AutomationRunResult> {
|
|
const steps = routine.steps ?? [];
|
|
const stepResults: AutomationStepResult[] = [];
|
|
let overallSuccess = true;
|
|
let stoppedEarly = false;
|
|
|
|
for (let i = 0; i < steps.length; i++) {
|
|
const step = steps[i];
|
|
const result = await this.executeStep(routine, step, i);
|
|
stepResults.push(result);
|
|
|
|
if (!result.success) {
|
|
overallSuccess = false;
|
|
if (!step.continueOnFailure) {
|
|
stoppedEarly = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
const outputParts: string[] = [];
|
|
for (const sr of stepResults) {
|
|
outputParts.push(`=== Step ${sr.stepIndex + 1}: ${sr.stepName} (${sr.success ? "success" : "FAILED"}) ===`);
|
|
if (sr.output) outputParts.push(sr.output);
|
|
if (sr.error) outputParts.push(`Error: ${sr.error}`);
|
|
}
|
|
const failedSteps = stepResults.filter((sr) => !sr.success);
|
|
|
|
return {
|
|
success: overallSuccess,
|
|
output: truncateOutput(outputParts.join("\n"), ""),
|
|
error: failedSteps.length > 0
|
|
? `${failedSteps.length} step(s) failed: ${failedSteps.map((s) => s.stepName).join(", ")}${stoppedEarly ? " (execution stopped)" : ""}`
|
|
: undefined,
|
|
startedAt,
|
|
completedAt: new Date().toISOString(),
|
|
stepResults,
|
|
};
|
|
}
|
|
|
|
private async executeStep(
|
|
routine: Routine,
|
|
step: AutomationStep,
|
|
stepIndex: number,
|
|
): Promise<AutomationStepResult> {
|
|
const startedAt = new Date().toISOString();
|
|
const timeoutMs = step.timeoutMs ?? routine.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
|
|
if (step.type === "command") {
|
|
const result = await this.executeCommand(step.command ?? "", timeoutMs, startedAt);
|
|
return {
|
|
stepId: step.id,
|
|
stepName: step.name,
|
|
stepIndex,
|
|
success: result.success,
|
|
output: result.output,
|
|
error: result.error,
|
|
startedAt,
|
|
completedAt: result.completedAt,
|
|
};
|
|
}
|
|
|
|
if (step.type === "ai-prompt") {
|
|
if (!step.prompt?.trim()) {
|
|
return { stepId: step.id, stepName: step.name, stepIndex, success: false, output: "", error: "AI prompt step has no prompt specified", startedAt, completedAt: new Date().toISOString() };
|
|
}
|
|
if (!this.options.aiPromptExecutor) {
|
|
return { stepId: step.id, stepName: step.name, stepIndex, success: false, output: "", error: "AI execution is not configured", startedAt, completedAt: new Date().toISOString() };
|
|
}
|
|
try {
|
|
const output = await Promise.race([
|
|
this.options.aiPromptExecutor(step.prompt, step.modelProvider, step.modelId),
|
|
new Promise<never>((_resolve, reject) => setTimeout(() => reject(new Error(`AI prompt step timed out after ${timeoutMs / 1000}s`)), timeoutMs)),
|
|
]);
|
|
return { stepId: step.id, stepName: step.name, stepIndex, success: true, output: truncateOutput(output, ""), startedAt, completedAt: new Date().toISOString() };
|
|
} catch (err) {
|
|
return { stepId: step.id, stepName: step.name, stepIndex, success: false, output: "", error: err instanceof Error ? err.message : String(err), startedAt, completedAt: new Date().toISOString() };
|
|
}
|
|
}
|
|
|
|
if (step.type === "create-task") {
|
|
if (!this.options.taskStore) {
|
|
return { stepId: step.id, stepName: step.name, stepIndex, success: false, output: "", error: "Task creation is not configured", startedAt, completedAt: new Date().toISOString() };
|
|
}
|
|
if (!step.taskDescription?.trim()) {
|
|
return { stepId: step.id, stepName: step.name, stepIndex, success: false, output: "", error: "Create-task step has no task description specified", startedAt, completedAt: new Date().toISOString() };
|
|
}
|
|
|
|
const taskInput: TaskCreateInput = {
|
|
title: step.taskTitle?.trim() || undefined,
|
|
description: step.taskDescription.trim(),
|
|
column: (step.taskColumn as Column) || "triage",
|
|
modelProvider: step.modelProvider?.trim() || undefined,
|
|
modelId: step.modelId?.trim() || undefined,
|
|
source: {
|
|
sourceType: "automation",
|
|
sourceMetadata: { routineId: routine.id, stepId: step.id },
|
|
},
|
|
};
|
|
try {
|
|
const task = await this.options.taskStore.createTask(taskInput);
|
|
return { stepId: step.id, stepName: step.name, stepIndex, success: true, output: `Created task ${task.id}: ${task.title || task.description.slice(0, 80)}`, startedAt, completedAt: new Date().toISOString() };
|
|
} catch (err) {
|
|
return { stepId: step.id, stepName: step.name, stepIndex, success: false, output: "", error: err instanceof Error ? err.message : String(err), startedAt, completedAt: new Date().toISOString() };
|
|
}
|
|
}
|
|
|
|
return {
|
|
stepId: step.id,
|
|
stepName: step.name,
|
|
stepIndex,
|
|
success: false,
|
|
output: "",
|
|
error: `Unknown step type: "${String((step as unknown as Record<string, unknown>).type)}"`,
|
|
startedAt,
|
|
completedAt: new Date().toISOString(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Handle catch-up for missed routine executions based on the catch-up policy.
|
|
*
|
|
* @param routine - The routine to check for catch-up
|
|
*/
|
|
async handleCatchUp(routine: Routine): Promise<void> {
|
|
const catchUpPolicy = routine.catchUpPolicy ?? "skip";
|
|
|
|
if (catchUpPolicy === "skip") {
|
|
return;
|
|
}
|
|
|
|
// "run_one" or "run" policy - need to catch up
|
|
if (!routine.lastRunAt) {
|
|
// Never run before — nothing to catch up
|
|
return;
|
|
}
|
|
|
|
// Calculate missed intervals
|
|
if (!routine.cronExpression) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const cronExpr = CronExpressionParser.parse(routine.cronExpression, {
|
|
currentDate: new Date(routine.lastRunAt ?? Date.now()),
|
|
});
|
|
const lastRun = new Date(routine.lastRunAt ?? Date.now());
|
|
const now = new Date();
|
|
|
|
const missedIntervals: Date[] = [];
|
|
|
|
// Get next interval after lastRun, then iterate
|
|
let intervalDate = new Date(cronExpr.next().toISOString() ?? Date.now());
|
|
|
|
while (intervalDate.getTime() <= now.getTime() && missedIntervals.length < MAX_CATCH_UP_INTERVALS) {
|
|
if (intervalDate.getTime() > lastRun.getTime()) {
|
|
missedIntervals.push(new Date(intervalDate));
|
|
}
|
|
const nextIso = cronExpr.next().toISOString();
|
|
if (!nextIso) break;
|
|
intervalDate = new Date(nextIso);
|
|
}
|
|
|
|
if (missedIntervals.length === 0) {
|
|
return;
|
|
}
|
|
|
|
log.log(`[${routine.id}] Running ${missedIntervals.length} catch-up executions`);
|
|
|
|
// Execute each missed interval
|
|
for (const missedInterval of missedIntervals) {
|
|
try {
|
|
await this.executeRoutine(routine.id, "cron", {
|
|
catchUp: true,
|
|
missedInterval: missedInterval.toISOString(),
|
|
});
|
|
} catch (err) {
|
|
log.error(`[${routine.id}] Catch-up execution failed: ${err}`);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
log.error(`[${routine.id}] Error calculating catch-up intervals: ${err}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Trigger a routine manually (via API).
|
|
*
|
|
* @param routineId - The ID of the routine to trigger
|
|
* @returns The execution result
|
|
* @throws Error if routine not found or disabled
|
|
*/
|
|
async triggerManual(routineId: string): Promise<RoutineExecutionResult> {
|
|
const routine = await this.options.routineStore.getRoutine(routineId);
|
|
|
|
if (!routine.enabled) {
|
|
throw new Error(`Routine '${routineId}' is disabled`);
|
|
}
|
|
|
|
return this.executeRoutine(routineId, "api");
|
|
}
|
|
|
|
/**
|
|
* Trigger a routine via webhook.
|
|
*
|
|
* @param routineId - The ID of the routine to trigger
|
|
* @param payload - The webhook payload
|
|
* @param _signature - The webhook signature (verified by RoutineScheduler)
|
|
* @returns The execution result
|
|
* @throws Error if routine not found, not a webhook trigger, or disabled
|
|
*/
|
|
async triggerWebhook(
|
|
routineId: string,
|
|
payload: Record<string, unknown>,
|
|
_signature?: string
|
|
): Promise<RoutineExecutionResult> {
|
|
const routine = await this.options.routineStore.getRoutine(routineId);
|
|
|
|
if (routine.trigger.type !== "webhook") {
|
|
throw new Error(
|
|
`Routine '${routineId}' does not have webhook trigger type`
|
|
);
|
|
}
|
|
|
|
if (!routine.enabled) {
|
|
throw new Error(`Routine '${routineId}' is disabled`);
|
|
}
|
|
|
|
return this.executeRoutine(routineId, "webhook", { webhookPayload: payload });
|
|
}
|
|
|
|
/**
|
|
* Get the number of currently-running executions.
|
|
*/
|
|
getInFlightCount(): number {
|
|
return this.inFlightExecutions.size;
|
|
}
|
|
|
|
/**
|
|
* Check if a routine is currently being executed.
|
|
*/
|
|
isRoutineRunning(routineId: string): boolean {
|
|
return this.inFlightExecutions.has(routineId);
|
|
}
|
|
}
|
|
|
|
function truncateOutput(stdout: string, stderr: string): string {
|
|
let output = stdout;
|
|
if (stderr) {
|
|
output += stdout ? "\n--- stderr ---\n" : "";
|
|
output += stderr;
|
|
}
|
|
if (output.length > MAX_OUTPUT_LENGTH) {
|
|
return `${output.slice(0, MAX_OUTPUT_LENGTH)}\n[output truncated]`;
|
|
}
|
|
return output;
|
|
}
|