Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Every non-trivial PR adds a bullet under `## [Unreleased]`. Trivial edits (typos
## [Unreleased]

### Added
- Unified `triggers` data model — the plumbing for the triggers-detachment epic ([#927](https://github.com/Appsilon/mediforce/issues/927)). A `TriggerResource` schema (discriminated union over `manual` / `webhook` / `cron`, `event` reserved), `TriggerRepository` interface + in-memory double, a Postgres `triggers` table (migration `0030`, keyed by `(namespace, workflow_name, trigger_name)`, partial webhook-path uniqueness + partial enabled-by-type index) with a parity-tested repo, and an authorized wrapper reachable at `scope.triggers` (workspace-gated) and `scope.system.triggers` (heartbeat's cross-namespace `listEnabledByType('cron')`). No user-visible behaviour change — nothing reads or writes the table yet; the cron-only `cron_trigger_state` overlay is left untouched. See [ADR-0011](docs/adr/0011-triggers-detached-unified-resource.md) [#928](https://github.com/Appsilon/mediforce/issues/928).
- Integration test coverage for the workflow engine's full execution loop (`packages/workflow-engine/src/__tests__/integration.test.ts`): mixed agent → human → agent → terminal lifecycle with state asserted at every transition, agent-output propagation into `instance.variables` and the downstream step's input, agent-crash → failure → retry → complete recovery, and verdict-based routing (approve vs revise) — all driven through the in-memory repos, no emulators [#794](https://github.com/Appsilon/mediforce/pull/794).

### Fixed
Expand Down
17 changes: 17 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,23 @@ _Avoid_: "Workflow Instance" (briefly proposed but inconsistent with the
project's own "Run" vocabulary), "Process Instance" (legacy schema name
only), "Workflow" alone (ambiguous — Definition or Run?).

**Trigger** *(detached mutable resource — ADR-0011)*:
What causes a Workflow to run, or makes it hand-startable. Three live kinds:
`manual` (a person starts a Run), `webhook` (an inbound HTTP call starts a Run),
`cron` (a schedule starts Runs). `event` is a reserved fourth kind with no
runtime yet. A Trigger is a **first-class mutable** resource keyed by
`(Namespace, Workflow, trigger name)`, attached to a Workflow **independently of
its immutable Workflow Definition** and managed like a Secret — added, toggled,
retimed, and imported/exported without registering a new Definition version.
_Code:_ the persisted resource is `TriggerResource*` **only transitionally** —
the name `Trigger` / `TriggerSchema` is still held by the legacy trigger
*declaration* embedded in `process-definition.ts`; when the triggers-detachment
epic makes the Definition trigger-free, `TriggerResource` renames back to
`Trigger`.
_Avoid_: conflating the detached Trigger resource with the embedded Definition
trigger declaration (legacy, being removed), or with the **Trigger Payload** on
a Workflow Run (the data a firing hands the Run).

**Workflow Step** *(config; static)*:
A node in a Workflow Definition's DAG. Defines `executor: human | agent |
script | cowork | action`, optional autonomy level (agent steps),
Expand Down
67 changes: 67 additions & 0 deletions docs/adr/0011-triggers-detached-unified-resource.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
---
status: proposed
---

# Triggers are detached resources in a unified table; the Workflow Definition is trigger-free

A **Trigger** (`manual`, `webhook`, `cron`) is a first-class **mutable** resource
keyed by `(namespace, workflowName, name)`, stored in **one unified `triggers`
table** discriminated by `type`, and attached to a Workflow independently of its
immutable versioned Definition. Triggers are managed from CLI and UI and are
portable across instances via an importable/exportable trigger-config file. The
end state of the triggers-detachment epic is that **the Workflow Definition no
longer declares triggers at all**.

This ADR records the *target model* and lands the data layer (schema, repo,
Postgres table, authorized wrapper) as pure plumbing. The behavioural flip —
the heartbeat, `ManualTrigger`, and `WebhookRouter` reading the table instead of
`def.triggers`, and `triggers.min(1)` leaving the Definition schema — happens in
later epic issues; **nothing reads or writes the `triggers` table when this ADR
is committed.**

Webhook callable URLs are **derived** from `(host, namespace, workflow, path)`
and never stored, so import re-derives them for the target instance; cron
fire-cursors (`lastTriggeredAt`) anchor to `now` on import so a materialized
schedule never back-fires.

**Driver:** workflows must be portable across instances without baking instance
state into the spec; operators must add / modify / stop any trigger type without
registering a new Definition version; and triggers should behave like Secrets —
detached resources a workflow uses, not fields embedded in the immutable spec.

## Considered options

- **Keep triggers in the Definition (status quo).** Rejected: couples
operational toggles to immutable versions, is not portable, and bakes
instance-specific webhook state into the spec.
- **Capability flags in the Definition + detached wiring.** Rejected: confusing
to declare every type `yes` while only one is wired; still mixes spec and
operations.
- **Cron-only mutable overlay (PR #870, `feat/cron-trigger-management`, never
merged).** This branch generalised the `cron_trigger_state` last-fire cursor
into a live `(namespace, schedule, enabled)` overlay, but only for cron —
`manual` and `webhook` stayed embedded in the Definition, leaving two
divergent mechanisms. Superseded by this decision before merge; its shapes are
the starting point this table generalises. (No ADR was ever committed for that
branch, so this ADR supersedes a design, not a prior ADR.)
- **One table per trigger type.** Rejected: import/export and the unified
Triggers tab would fan out across tables for no benefit; partial indexes give
type-specific constraints on a single table.

## Consequences

- The `cron_trigger_state` overlay (migration `0005`) is **left in place and
untouched** by this issue; its generalisation into `triggers` and the
heartbeat cron→trigger rename land in a later epic issue.
- `triggers` is created additively (migration `0030`) with no seed. Seeding from
existing Definitions happens per-type in later issues.
- `triggers.min(1)` and the triggers array will leave the Definition schema when
the Definition becomes trigger-free; register / import / validate stop reading
them and existing definitions' declared triggers migrate into the table.
- The persisted resource schema is named `TriggerResource*` for now because the
embedded `TriggerSchema` in `process-definition.ts` still owns the `Trigger` /
`TriggerSchema` names; the rename-back happens when the embedded declaration is
removed. See CONTEXT.md "Trigger".
- `listEnabledByType('cron')` is the cross-namespace read the heartbeat will use;
it runs as a system actor via `scope.system.triggers`. Workspace-scoped callers
go through `scope.triggers` (the authorized wrapper).
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
InMemoryProcessInstanceRepository,
InMemoryProcessRepository,
InMemoryToolCatalogRepository,
InMemoryTriggerRepository,
InMemoryAgentOAuthTokenRepository,
InMemoryUserProfileRepository,
InMemoryTaskAttachmentRepository,
Expand Down Expand Up @@ -176,6 +177,7 @@ export interface TestScopeOverrides {
readonly agentDefinitionRepo?: InMemoryAgentDefinitionRepository;
readonly coworkSessionRepo?: InMemoryCoworkSessionRepository;
readonly cronTriggerStateRepo?: InMemoryCronTriggerStateRepository;
readonly triggerRepo?: InMemoryTriggerRepository;
readonly toolCatalogRepo?: InMemoryToolCatalogRepository;
readonly oauthProviderRepo?: InMemoryOAuthProviderRepository;
readonly agentOAuthTokenRepo?: InMemoryAgentOAuthTokenRepository;
Expand Down Expand Up @@ -227,6 +229,7 @@ export function createTestScope(overrides: TestScopeOverrides = {}): CallerScope
agentDefinitionRepo: overrides.agentDefinitionRepo ?? new InMemoryAgentDefinitionRepository(),
coworkSessionRepo: overrides.coworkSessionRepo ?? new InMemoryCoworkSessionRepository(instanceRepo),
cronTriggerStateRepo: overrides.cronTriggerStateRepo ?? new InMemoryCronTriggerStateRepository(),
triggerRepo: overrides.triggerRepo ?? new InMemoryTriggerRepository(),
toolCatalogRepo: overrides.toolCatalogRepo ?? new InMemoryToolCatalogRepository(),
namespaceRepo: overrides.namespaceRepo ?? stubNamespaceRepo,
userProfileRepo: overrides.userProfileRepo ?? new InMemoryUserProfileRepository(),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import type {
TriggerRepository,
TriggerResource,
TriggerType,
TriggerUpdate,
} from '@mediforce/platform-core';
import type { CallerIdentity } from '../auth';
import { AuthorizedScope } from './authorized-repository';

/**
* Workspace-scoped access to the unified `triggers` table (ADR-0011 /
* ADR-0004). Each row carries its own `namespace`, so gating is a direct
* `canSeeNamespace(row.namespace)` predicate — no parent lookup.
*
* `listEnabledByType` is the one cross-namespace read (the cron heartbeat's
* `type='cron'` sweep); through this wrapper it is filtered to the caller's
* namespaces. The heartbeat itself runs as a system actor and reaches the
* unwrapped repo via `scope.system.triggers`.
*/
export class AuthorizedTriggerRepository extends AuthorizedScope {
constructor(
caller: CallerIdentity,
private readonly raw: TriggerRepository,
) {
super(caller);
}

listByWorkflow = async (namespace: string, workflowName: string): Promise<TriggerResource[]> => {
if (!this.canSeeNamespace(namespace)) return [];
return this.raw.listByWorkflow(namespace, workflowName);
};

listEnabledByType = async (type: TriggerType): Promise<TriggerResource[]> => {
const rows = await this.raw.listEnabledByType(type);
return rows.filter((row) => this.canSeeNamespace(row.namespace));
};

create = async (trigger: TriggerResource): Promise<TriggerResource> => {
this.assertNamespaceWrite(trigger.namespace);
return this.raw.create(trigger);
};

update = async (
namespace: string,
workflowName: string,
name: string,
patch: TriggerUpdate,
): Promise<TriggerResource> => {
this.assertNamespaceWrite(namespace);
return this.raw.update(namespace, workflowName, name, patch);
};

recordTriggered = async (
namespace: string,
workflowName: string,
name: string,
triggeredAt: string,
): Promise<void> => {
this.assertNamespaceWrite(namespace);
return this.raw.recordTriggered(namespace, workflowName, name, triggeredAt);
};

delete = async (namespace: string, workflowName: string, name: string): Promise<void> => {
this.assertNamespaceWrite(namespace);
return this.raw.delete(namespace, workflowName, name);
};

deleteByWorkflow = async (namespace: string, workflowName: string): Promise<void> => {
this.assertNamespaceWrite(namespace);
return this.raw.deleteByWorkflow(namespace, workflowName);
};
}
9 changes: 9 additions & 0 deletions packages/platform-api/src/repositories/caller-scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
ModelRegistryRepository,
NamespaceRepository,
PlatformSettingsRepository,
TriggerRepository,
UserDirectoryService,
UserProfileRepository,
} from '@mediforce/platform-core';
Expand All @@ -31,6 +32,7 @@ import type { AuthorizedHumanTaskRepository } from './authorized-human-task-repo
import type { AuthorizedOAuthProviderRepository } from './authorized-oauth-provider-repository';
import type { AuthorizedTaskAttachmentRepository } from './authorized-task-attachment-repository';
import type { AuthorizedToolCatalogRepository } from './authorized-tool-catalog-repository';
import type { AuthorizedTriggerRepository } from './authorized-trigger-repository';
import type { AuthorizedWorkflowDefinitionRepository } from './authorized-workflow-definition-repository';
import type { AuthorizedWorkflowRunRepository } from './authorized-workflow-run-repository';
import type { AuthorizedWorkflowSecretRepository } from './authorized-workflow-secret-repository';
Expand Down Expand Up @@ -74,6 +76,7 @@ export interface CallerScope {
readonly agentOAuthTokens: AuthorizedAgentOAuthTokenRepository;
readonly workspaceSecrets: AuthorizedWorkspaceSecretRepository;
readonly workflowSecrets: AuthorizedWorkflowSecretRepository;
readonly triggers: AuthorizedTriggerRepository;

// Deployment-global pass-throughs
readonly models: ModelRegistryRepository;
Expand Down Expand Up @@ -104,6 +107,12 @@ export interface SystemServices {
readonly manualTrigger: ManualTrigger;
readonly cronTrigger: CronTrigger;
readonly webhookRouter: WebhookRouter;
/**
* Unwrapped {@link TriggerRepository} for the cron heartbeat's
* cross-namespace `listEnabledByType('cron')` sweep (system-actor only,
* ADR-0011). Workspace-scoped callers use `scope.triggers`.
*/
readonly triggers: TriggerRepository;
readonly agentRunner: AgentRunner;
/**
* Unscoped byte store for task attachments (ADR-0003). Reached only after a
Expand Down
5 changes: 5 additions & 0 deletions packages/platform-api/src/repositories/create-caller-scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type {
ProcessInstanceRepository,
ProcessRepository,
ToolCatalogRepository,
TriggerRepository,
UserDirectoryService,
UserProfileRepository,
WorkflowSecretsRepository,
Expand All @@ -46,6 +47,7 @@ import { AuthorizedHumanTaskRepository } from './authorized-human-task-repositor
import { AuthorizedOAuthProviderRepository } from './authorized-oauth-provider-repository';
import { AuthorizedTaskAttachmentRepository } from './authorized-task-attachment-repository';
import { AuthorizedToolCatalogRepository } from './authorized-tool-catalog-repository';
import { AuthorizedTriggerRepository } from './authorized-trigger-repository';
import { AuthorizedWorkflowDefinitionRepository } from './authorized-workflow-definition-repository';
import { AuthorizedWorkflowRunRepository } from './authorized-workflow-run-repository';
import { AuthorizedWorkflowSecretRepository } from './authorized-workflow-secret-repository';
Expand All @@ -69,6 +71,7 @@ export interface CallerScopeServices {
readonly agentDefinitionRepo: AgentDefinitionRepository;
readonly coworkSessionRepo: CoworkSessionRepository;
readonly cronTriggerStateRepo: CronTriggerStateRepository;
readonly triggerRepo: TriggerRepository;
readonly toolCatalogRepo: ToolCatalogRepository;
readonly namespaceRepo: NamespaceRepository;
readonly userProfileRepo: UserProfileRepository;
Expand Down Expand Up @@ -138,6 +141,7 @@ export function createCallerScope(
services.secretsRepo,
),
workflowSecrets: new AuthorizedWorkflowSecretRepository(caller, services.secretsRepo),
triggers: new AuthorizedTriggerRepository(caller, services.triggerRepo),

models: services.modelRegistryRepo,
plugins: services.pluginRegistry,
Expand All @@ -149,6 +153,7 @@ export function createCallerScope(
engine: services.engine,
manualTrigger: services.manualTrigger,
cronTrigger: services.cronTrigger,
triggers: services.triggerRepo,
webhookRouter: services.webhookRouter,
agentRunner: services.agentRunner,
blobStore: services.blobStore,
Expand Down
5 changes: 5 additions & 0 deletions packages/platform-api/src/services/platform-services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
PostgresOAuthProviderRepository,
PostgresAgentOAuthTokenRepository,
PostgresCronTriggerStateRepository,
PostgresTriggerRepository,
PostgresAgentRunRepository,
PostgresHumanTaskRepository,
PostgresTaskAttachmentRepository,
Expand Down Expand Up @@ -39,6 +40,7 @@ import type {
BlobStore,
CoworkSessionRepository,
CronTriggerStateRepository,
TriggerRepository,
EmailProviderInfo,
HandoffRepository,
HumanTaskRepository,
Expand Down Expand Up @@ -128,6 +130,7 @@ export interface PlatformServices {
agentDefinitionRepo: AgentDefinitionRepository;
coworkSessionRepo: CoworkSessionRepository;
cronTriggerStateRepo: CronTriggerStateRepository;
triggerRepo: TriggerRepository;
toolCatalogRepo: ToolCatalogRepository;
namespaceRepo: NamespaceRepository;
userProfileRepo: UserProfileRepository;
Expand Down Expand Up @@ -271,6 +274,7 @@ export function getPlatformServices(): PlatformServices {
new PostgresCoworkSessionRepository(pg, instanceRepo);
const cronTriggerStateRepo: CronTriggerStateRepository =
new PostgresCronTriggerStateRepository(pg);
const triggerRepo: TriggerRepository = new PostgresTriggerRepository(pg);
const toolCatalogRepo: ToolCatalogRepository = new PostgresToolCatalogRepository(pg);
const namespaceRepo: NamespaceRepository = new PostgresNamespaceRepository(pg);
const userProfileRepo: UserProfileRepository = new PostgresUserProfileRepository(pg);
Expand Down Expand Up @@ -487,6 +491,7 @@ export function getPlatformServices(): PlatformServices {
agentDefinitionRepo,
coworkSessionRepo,
cronTriggerStateRepo,
triggerRepo,
toolCatalogRepo,
namespaceRepo,
userProfileRepo,
Expand Down
16 changes: 16 additions & 0 deletions packages/platform-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,13 @@ export {
WorkflowSecretsSchema,
NamespaceSecretsSchema,
CronTriggerStateSchema,
TriggerTypeSchema,
TriggerResourceSchema,
CronTriggerResourceSchema,
WebhookTriggerResourceSchema,
ManualTriggerResourceSchema,
CronTriggerConfigSchema,
ManualTriggerConfigSchema,
McpServerConfigSchema,
AgentMcpBindingSchema,
AgentMcpBindingMapSchema,
Expand Down Expand Up @@ -204,6 +211,12 @@ export type {
WorkflowSecrets,
NamespaceSecrets,
CronTriggerState,
TriggerType,
TriggerResource,
CronTriggerResource,
WebhookTriggerResource,
ManualTriggerResource,
TriggerConfig,
McpServerConfig,
AgentMcpBinding,
AgentMcpBindingMap,
Expand Down Expand Up @@ -255,6 +268,8 @@ export type {
ListAgentRunsPage,
CoworkSessionRepository,
CronTriggerStateRepository,
TriggerRepository,
TriggerUpdate,
ToolCatalogRepository,
NamespaceRepository,
NamespaceUpdates,
Expand Down Expand Up @@ -352,6 +367,7 @@ export {
NoopNotificationService,
InMemoryCoworkSessionRepository,
InMemoryCronTriggerStateRepository,
InMemoryTriggerRepository,
InMemoryOAuthProviderRepository,
InMemoryAgentOAuthTokenRepository,
InMemoryAgentRunRepository,
Expand Down
1 change: 1 addition & 0 deletions packages/platform-core/src/interfaces/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export type {
} from './agent-run-repository';
export type { CoworkSessionRepository } from './cowork-session-repository';
export type { CronTriggerStateRepository } from './cron-trigger-state-repository';
export type { TriggerRepository, TriggerUpdate } from './trigger-repository';
export type { ToolCatalogRepository } from './tool-catalog-repository';
export type { NamespaceRepository, NamespaceUpdates } from './namespace-repository';
export type { NamespaceSecretsRepository } from './namespace-secrets-repository';
Expand Down
Loading