Unified Backend Workflow Engine Design (Friday)
This design extends the current runtime in src/workflows/ and aligns with:
docs/friday-style-guide.mddocs/distributed-architecture.md(workflow engine + event model)- existing
skills,sessions,plugins, and SQLite migration patterns.
// src/workflows/friday-workflow-engine.constants.ts
export const FRIDAY_WORKFLOW_TRIGGER_TYPES = {
CRON: "cron",
WEBHOOK: "webhook",
EVENT: "event",
} as const;
export const FRIDAY_WORKFLOW_NODE_TYPES = {
TRIGGER: "trigger",
ACTION: "action",
CONDITION: "condition",
TRANSFORM: "transform",
APPROVAL: "approval",
} as const;
export const FRIDAY_WORKFLOW_ACTION_TYPES = {
SKILL: "skill",
AI_COMPLETION: "ai_completion",
HTTP_REQUEST: "http_request",
} as const;
export const FRIDAY_WORKFLOW_RUN_STATUSES = {
PENDING: "pending",
RUNNING: "running",
PAUSED: "paused",
COMPLETED: "completed",
FAILED: "failed",
CANCELLED: "cancelled",
} as const;
export const FRIDAY_WORKFLOW_NODE_STATUSES = {
PENDING: "pending",
RUNNING: "running",
COMPLETED: "completed",
FAILED: "failed",
WAITING_APPROVAL: "waiting_approval",
CANCELLED: "cancelled",
RETRYING: "retrying",
} as const;
export const FRIDAY_WORKFLOW_DEFAULT_NODE_TIMEOUT_MS = 300_000;
export const FRIDAY_WORKFLOW_DEFAULT_RUN_TIMEOUT_MS = 3_600_000;
export const FRIDAY_WORKFLOW_APPROVAL_DEFAULT_TIMEOUT_MS = 86_400_000;
export const FRIDAY_WORKFLOW_WEBHOOK_PATH_TOKEN_BYTES = 24;
export const FRIDAY_WORKFLOW_BUILTIN_SKILL_AI_COMPLETION = "friday.builtin.ai-completion";
export const FRIDAY_WORKFLOW_BUILTIN_SKILL_HTTP_REQUEST = "friday.builtin.http-request";// src/workflows/model/friday-workflow-engine.types.ts
export type FridayWorkflowStatus = "draft" | "published" | "archived";
export type FridayWorkflowTriggerType = "cron" | "webhook" | "event";
export type FridayWorkflowNodeType = "trigger" | "action" | "condition" | "transform" | "approval";
export type FridayWorkflowActionType = "skill" | "ai_completion" | "http_request";
export type FridayWorkflowRunStatus = "pending" | "running" | "paused" | "completed" | "failed" | "cancelled";
export type FridayWorkflowNodeStatus = "pending" | "running" | "completed" | "failed" | "waiting_approval" | "cancelled" | "retrying";
export interface FridayWorkflowVersionConfig {
runTimeoutMs?: number;
defaultNodeTimeoutMs?: number;
maxParallelism?: number;
failurePolicy: {
onFailure: "fail_fast" | "continue_on_error" | "fallback_step" | "compensate" | "pause_for_approval";
fallbackNodeId?: string;
};
}
export type FridayWorkflowNodeConfig =
| { triggerType: "cron"; cron: string; timezone: string }
| { triggerType: "webhook"; method: "POST"; secretRef?: string; dedupeKeyPath?: string }
| { triggerType: "event"; source: string; event: string; filterExpr?: string; pluginId?: string }
| { actionType: "skill"; skillId: string; inputMapping?: Record<string, unknown> }
| { actionType: "ai_completion"; prompt: string; model?: string; temperature?: number }
| { actionType: "http_request"; method: string; url: string; headers?: Record<string, string>; body?: unknown }
| { conditionType: "if" | "switch"; expression: string; cases?: Array<{ label: string; expression: string }> }
| { transformType: "map" | "template" | "merge"; mapping?: Record<string, unknown>; expression?: string; outputKey?: string }
| { approverUserId?: string; approverRole?: "owner" | "admin" | "operator"; timeoutMs?: number; onReject?: "fail" | "reject_branch" };
export interface FridayWorkflowNodeDefinition {
id: string;
type: FridayWorkflowNodeType;
name: string;
config: FridayWorkflowNodeConfig;
timeoutMs?: number;
}
export interface FridayWorkflowEdgeDefinition {
id: string;
sourceNodeId: string;
targetNodeId: string;
condition?: string;
branch?: "true" | "false" | "success" | "failure" | "approve" | "reject";
}// src/workflows/model/friday-workflow-editor.types.ts (React Flow compatible)
export interface FridayWorkflowEditorGraphV1 {
schemaVersion: "1.0";
reactFlowVersion: "11";
nodes: Array<{
id: string;
type: "workflow_node";
position: { x: number; y: number };
width?: number;
height?: number;
data: FridayWorkflowNodeDefinition;
}>;
edges: Array<{
id: string;
source: string;
target: string;
sourceHandle?: string;
targetHandle?: string;
data?: { condition?: string; branch?: string };
}>;
viewport?: { x: number; y: number; zoom: number };
}Compatibility note: keep reading existing queued as pending and blocked_offline as waiting_approval during transition.
-- src/state/sqlite/migrations/v009-workflow-engine-triggers-approvals.ts
ALTER TABLE workflow_versions ADD COLUMN editor_graph_json TEXT;
ALTER TABLE workflow_versions ADD COLUMN config_json TEXT NOT NULL DEFAULT '{}';
ALTER TABLE workflow_runs ADD COLUMN trigger_node_id TEXT;
ALTER TABLE workflow_runs ADD COLUMN timeout_ms INTEGER;
ALTER TABLE workflow_runs ADD COLUMN deadline_at TEXT;
ALTER TABLE workflow_runs ADD COLUMN paused_at TEXT;
ALTER TABLE workflow_runs ADD COLUMN resumed_at TEXT;
ALTER TABLE workflow_run_nodes ADD COLUMN node_type TEXT;
ALTER TABLE workflow_run_nodes ADD COLUMN timeout_ms INTEGER;
ALTER TABLE workflow_run_nodes ADD COLUMN approval_request_id TEXT;
CREATE TABLE IF NOT EXISTS workflow_trigger_registrations (
id TEXT PRIMARY KEY,
workflow_id TEXT NOT NULL REFERENCES workflows(id) ON DELETE CASCADE,
workflow_version_id TEXT NOT NULL REFERENCES workflow_versions(id) ON DELETE CASCADE,
trigger_node_id TEXT NOT NULL,
trigger_type TEXT NOT NULL CHECK (trigger_type IN ('cron','webhook','event')),
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0,1)),
cron_expression TEXT,
cron_timezone TEXT,
webhook_path_token TEXT,
webhook_secret_ref TEXT,
webhook_signature_header TEXT,
event_source TEXT,
event_name TEXT,
event_filter_expr TEXT,
plugin_id TEXT REFERENCES plugins(id) ON DELETE SET NULL,
dedupe_window_sec INTEGER NOT NULL DEFAULT 300,
last_fired_at TEXT,
next_fire_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(workflow_version_id, trigger_node_id),
UNIQUE(webhook_path_token)
);
CREATE INDEX IF NOT EXISTS idx_workflow_trigger_registrations_due
ON workflow_trigger_registrations(trigger_type, enabled, next_fire_at);
CREATE INDEX IF NOT EXISTS idx_workflow_trigger_registrations_event
ON workflow_trigger_registrations(event_source, event_name, enabled);
CREATE TABLE IF NOT EXISTS workflow_trigger_deliveries (
id TEXT PRIMARY KEY,
trigger_registration_id TEXT NOT NULL REFERENCES workflow_trigger_registrations(id) ON DELETE CASCADE,
dedupe_key TEXT NOT NULL,
run_id TEXT REFERENCES workflow_runs(id) ON DELETE SET NULL,
status TEXT NOT NULL CHECK (status IN ('accepted','duplicate','failed')),
error_code TEXT,
error_message TEXT,
delivered_at TEXT NOT NULL,
created_at TEXT NOT NULL,
UNIQUE(trigger_registration_id, dedupe_key)
);
CREATE TABLE IF NOT EXISTS workflow_run_checkpoints (
run_id TEXT PRIMARY KEY REFERENCES workflow_runs(id) ON DELETE CASCADE,
checkpoint_seq INTEGER NOT NULL,
run_status TEXT NOT NULL,
active_node_ids_json TEXT NOT NULL DEFAULT '[]',
completed_node_ids_json TEXT NOT NULL DEFAULT '[]',
failed_node_ids_json TEXT NOT NULL DEFAULT '[]',
waiting_approval_node_ids_json TEXT NOT NULL DEFAULT '[]',
context_json TEXT NOT NULL DEFAULT '{}',
last_node_id TEXT,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_workflow_run_checkpoints_status
ON workflow_run_checkpoints(run_status, updated_at DESC);
CREATE TABLE IF NOT EXISTS workflow_approval_requests (
id TEXT PRIMARY KEY,
workflow_id TEXT NOT NULL REFERENCES workflows(id) ON DELETE CASCADE,
workflow_version_id TEXT NOT NULL REFERENCES workflow_versions(id) ON DELETE CASCADE,
run_id TEXT NOT NULL REFERENCES workflow_runs(id) ON DELETE CASCADE,
run_node_attempt_id TEXT NOT NULL REFERENCES workflow_run_nodes(id) ON DELETE CASCADE,
node_id TEXT NOT NULL,
approver_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
approver_role TEXT,
status TEXT NOT NULL CHECK (status IN ('pending','approved','rejected','expired','cancelled')),
request_payload_json TEXT NOT NULL DEFAULT '{}',
timeout_at TEXT,
decided_at TEXT,
decided_by_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
decision_comment TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(run_node_attempt_id)
);
CREATE INDEX IF NOT EXISTS idx_workflow_approval_requests_approver
ON workflow_approval_requests(approver_user_id, status, timeout_at);
CREATE INDEX IF NOT EXISTS idx_workflow_approval_requests_run
ON workflow_approval_requests(run_id, status);export interface FridayWorkflowTriggerRepository {
upsertManyForVersion(input: FridayWorkflowTriggerRegistrationEntity[]): void;
listByWorkflow(workflowId: string): FridayWorkflowTriggerRegistrationEntity[];
listDueCron(nowIso: string, limit: number): FridayWorkflowTriggerRegistrationEntity[];
getByWebhookToken(pathToken: string): FridayWorkflowTriggerRegistrationEntity | null;
listByEvent(source: string, event: string): FridayWorkflowTriggerRegistrationEntity[];
markFired(id: string, firedAt: string, nextFireAt?: string): void;
setEnabled(id: string, enabled: boolean, nowIso: string): void;
deleteByWorkflowVersion(workflowVersionId: string): void;
}
export interface FridayWorkflowTriggerDeliveryRepository {
tryInsert(input: {
id: string;
triggerRegistrationId: string;
dedupeKey: string;
status: "accepted" | "duplicate" | "failed";
deliveredAt: string;
}): boolean;
markAccepted(triggerRegistrationId: string, dedupeKey: string, runId: string): void;
markFailed(triggerRegistrationId: string, dedupeKey: string, code: string, message: string): void;
}
export interface FridayWorkflowRunCheckpointRepository {
upsert(checkpoint: FridayWorkflowRunCheckpointEntity): void;
get(runId: string): FridayWorkflowRunCheckpointEntity | null;
delete(runId: string): void;
listRecoverableRuns(limit: number): FridayWorkflowRunCheckpointEntity[];
}
export interface FridayWorkflowApprovalRepository {
insert(request: FridayWorkflowApprovalRequestEntity): FridayWorkflowApprovalRequestEntity;
getById(id: string): FridayWorkflowApprovalRequestEntity | null;
getByRunNodeAttemptId(runNodeAttemptId: string): FridayWorkflowApprovalRequestEntity | null;
listPending(input: { approverUserId?: string; limit?: number; cursor?: string }): FridayWorkflowApprovalRequestEntity[];
resolvePending(input: {
id: string;
status: "approved" | "rejected";
decidedByUserId: string;
comment?: string;
nowIso: string;
}): FridayWorkflowApprovalRequestEntity | null;
expirePending(nowIso: string, limit: number): FridayWorkflowApprovalRequestEntity[];
}export interface FridayWorkflowExecutionEngineService {
startRun(input: FridayWorkflowStartRunInput): Promise<FridayWorkflowRunEntity>;
resumeRun(runId: string): Promise<FridayWorkflowRunEntity>;
cancelRun(runId: string, reason?: string): Promise<FridayWorkflowRunEntity>;
retryRun(runId: string, nodeIds?: string[]): Promise<FridayWorkflowRunEntity>;
recoverActiveRuns(limit?: number): Promise<number>;
sweepTimedOutRuns(nowIso?: string): Promise<number>;
sweepTimedOutNodes(nowIso?: string): Promise<number>;
}export interface FridayWorkflowTriggerService {
syncPublishedVersionTriggers(workflowId: string): Promise<void>;
syncAllPublishedWorkflowTriggers(): Promise<void>;
tickCron(nowIso: string, limit?: number): Promise<number>;
handleWebhook(input: FridayWorkflowWebhookInvokeInput): Promise<FridayWorkflowWebhookInvokeResult>;
handleEvent(input: FridayWorkflowTriggerEventInput): Promise<number>;
listRegistrations(workflowId: string): FridayWorkflowTriggerRegistrationEntity[];
setRegistrationEnabled(registrationId: string, enabled: boolean): Promise<void>;
}export interface FridayWorkflowApprovalService {
requestForNode(input: FridayWorkflowApprovalRequestCreateInput): Promise<FridayWorkflowApprovalRequestEntity>;
listPending(input: FridayWorkflowApprovalListInput): FridayWorkflowApprovalRequestEntity[];
approve(input: FridayWorkflowApprovalDecisionInput): Promise<FridayWorkflowApprovalDecisionResult>;
reject(input: FridayWorkflowApprovalDecisionInput): Promise<FridayWorkflowApprovalDecisionResult>;
expirePending(nowIso: string, limit?: number): Promise<number>;
}export interface FridayWorkflowSkillNodeAdapter {
assertWorkflowInvocable(skillId: string): void;
execute(input: FridayWorkflowSkillActionExecutionInput): Promise<FridayWorkflowSkillActionExecutionOutput>;
listWorkflowInvocableSkills(): Array<{
skillId: string;
name: string;
inputSchema?: unknown;
outputSchema?: unknown;
}>;
}Design choice for Decision #1: action execution is unified through skills.
actionType: "skill"-> direct skill.actionType: "ai_completion"-> built-in skillfriday.builtin.ai-completion.actionType: "http_request"-> built-in skillfriday.builtin.http-request.
- Publish flow compiles and validates graph DAG, stores immutable
graph_json+editor_graph_json+config_json, then syncs trigger registrations from trigger nodes. - Trigger fires create run with
trigger_node_idand run context payload. - Scheduler executes only nodes reachable from
trigger_node_id, topological and parallel (respectingmaxParallelism). - Node transitions:
pending -> running -> completed|failed|waiting_approval. - Run transitions:
pending -> running -> paused|completed|failed. - Approval node creates
workflow_approval_requests, emits realtime event, writes approver session message viaFridaySessionService, and pauses run. - Approve:
- mark node
completed; - continue graph from approve branch (or normal outbound if unbranched).
- mark node
- Reject:
- if reject branch edge exists, mark node
failedand continue reject branch; - else fail run with
APPROVAL_REJECTED.
- if reject branch edge exists, mark node
- Checkpoint after every node terminal transition and every run status transition.
- Recovery on process restart:
- reload runs in
pending|running|pausedfrom checkpoints; - resume runnable runs;
- keep paused runs paused.
- reload runs in
- Timeout handling:
- node timeout from node config/default;
- run timeout from version config/default;
- sweeper marks timeout failures and applies failure policy.
crontrigger:- persisted registration with
cron_expression,cron_timezone,next_fire_at; - job
tickCron()scans due rows and starts runs; - dedupe via
workflow_trigger_deliveries.
- persisted registration with
webhooktrigger:- unique tokenized endpoint
/v1/workflow-webhooks/:pathToken; - optional HMAC verification using
secretRef; - run created on
POSTwith raw body + selected headers in trigger payload; - dedupe key from header/path expression.
- unique tokenized endpoint
eventtrigger:- subscription source can be internal hub events or plugin events;
source+eventmatching with optionalfilterExpr;- plugin bridge publishes
source = plugin:<pluginId>events into trigger service.
- Plugin integration:
- channel plugin callbacks (
onInboundMessage,onDeliveryEvent) are bridged to workflow event triggers; - non-channel plugins may emit workflow events through a narrow host bridge API.
- channel plugin callbacks (
| Method | Path | Operation ID | Scope |
|---|---|---|---|
GET |
/v1/workflows/:workflowId/triggers |
workflows.triggers.list |
workflow.read |
PATCH |
/v1/workflow-triggers/:registrationId |
workflows.triggers.update |
workflow.write |
POST |
/v1/workflows/:workflowId/triggers/resync |
workflows.triggers.resync |
workflow.write |
POST |
/v1/workflow-webhooks/:pathToken |
workflows.webhooks.invoke |
public |
GET |
/v1/workflow-approvals |
workflows.approvals.list |
workflow.run |
GET |
/v1/workflow-approvals/:approvalId |
workflows.approvals.get |
workflow.run |
POST |
/v1/workflow-approvals/:approvalId/approve |
workflows.approvals.approve |
workflow.run |
POST |
/v1/workflow-approvals/:approvalId/reject |
workflows.approvals.reject |
workflow.run |
POST |
/v1/workflow-runs/:runId/resume |
workflows.runs.resume |
workflow.run |
Add realtime event names:
workflow.approval.requestedworkflow.approval.approvedworkflow.approval.rejectedworkflow.approval.expired
src/workflows/friday-workflow-engine.constants.tssrc/workflows/model/friday-workflow-engine.types.tssrc/workflows/model/friday-workflow-editor.types.tssrc/workflows/persistence/friday-workflow-trigger-repository.tssrc/workflows/persistence/friday-workflow-trigger-delivery-repository.tssrc/workflows/persistence/friday-workflow-run-checkpoint-repository.tssrc/workflows/persistence/friday-workflow-approval-repository.tssrc/workflows/services/friday-workflow-approval-service.types.tssrc/workflows/services/friday-workflow-approval-service.tssrc/workflows/services/friday-workflow-skill-node-adapter.types.tssrc/workflows/services/friday-workflow-skill-node-adapter.tssrc/workflows/services/friday-workflow-action-executor-registry.tssrc/workflows/services/friday-workflow-event-trigger-bridge.tssrc/api/http/routes/friday-workflow-trigger-routes.tssrc/api/http/routes/friday-workflow-approval-routes.tssrc/api/http/routes/friday-workflow-webhook-routes.tssrc/jobs/workflows/friday-workflow-cron-trigger-job.tssrc/jobs/workflows/friday-workflow-timeout-job.tssrc/state/sqlite/migrations/v009-workflow-engine-triggers-approvals.ts
Expected updates to existing files:
src/workflows/services/friday-workflow-execution-service.tssrc/workflows/services/friday-workflow-trigger-service.tssrc/workflows/engine/friday-workflow-node-executor.tssrc/workflows/runtime/friday-workflow-runtime.tssrc/workflows/index.tssrc/api/model/friday-api-workflow.types.tssrc/api/model/friday-api-realtime.types.tssrc/api/runtime/friday-api-runtime.tssrc/state/sqlite/migrations/index.ts
- Unit: trigger repository CRUD + due cron query + webhook lookup + dedupe delivery constraints.
- Unit: approval repository resolution rules (
pendingonly, idempotent expire path). - Unit: skill adapter rejects non-
workflowinvocation mode skills. - Unit: action registry maps
ai_completionandhttp_requestto built-in skills. - Unit: execution state machine transitions for
waiting_approvalandpaused. - Unit: timeout sweeper for node and run deadlines.
- Unit: reject-branch behavior (
reject_branchvs fail run). - Integration: publish workflow with trigger nodes -> trigger registrations created.
- Integration: cron tick produces run exactly once per dedupe key.
- Integration: webhook POST starts run and stores payload.
- Integration: event trigger fired from plugin-source event bridge.
- Integration: approval request creates session notification message and realtime event.
- Integration: approve/reject API resumes workflow correctly.
- Integration: crash recovery resumes from
workflow_run_checkpoints. - Migration: v009 schema test validates new tables/columns/indexes exist and are usable.
This gives a single backend architecture that satisfies all 4 decisions while reusing Friday’s current workflow/compiler/runtime foundation.