From 2167418c5693aeeb4d0fe0c56b896d2e50ca037b Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sun, 31 May 2026 07:47:19 +0800 Subject: [PATCH] refactor(automation): remove Workflow Rules; reclaim `workflow` for state machines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workflow Rules (`WorkflowRuleSchema` + its 7 action sub-schemas) was a spec-only paradigm with no runtime, squatting on the `workflow` metadata-type slot. Meanwhile the `workflow` *core service* contract is the State Machine Engine + approval state management — so the slot was mis-bound. - Delete `automation/workflow.zod.ts`, `workflow.form.ts`, `workflow.test.ts`. - Repoint the `workflow` metadata type, `Stack.workflows`, and `GetWorkflowConfigResponse.workflows` to `StateMachineSchema`, matching the service contract. Core service / `/api/v1/workflow` routes / client SDK unchanged. - Drop the now-inapplicable workflow→object cross-reference check (state machines carry no `objectName`); approval/hook cross-ref coverage retained. - Add ADR-0018 (Unified Node/Action Registry) documenting the direction: one registry-backed node/action contract across Flow / Workflow-Rule / Approval, with Workflow Rules compiled down to Flow rather than run as a separate engine. Verified: spec tsc clean, full spec suite (234 files / 6598 tests) green, spec build OK, client + objectql typecheck clean, zero dangling references. --- docs/adr/0018-unified-node-action-registry.md | 197 +++++++ .../spec/scripts/build-skill-references.ts | 1 - packages/spec/src/api/protocol.zod.ts | 4 +- packages/spec/src/automation/index.ts | 2 - packages/spec/src/automation/workflow.form.ts | 45 -- packages/spec/src/automation/workflow.test.ts | 482 ------------------ packages/spec/src/automation/workflow.zod.ts | 284 ----------- .../spec/src/kernel/metadata-plugin.zod.ts | 2 +- .../spec/src/kernel/metadata-type-schemas.ts | 4 +- packages/spec/src/stack.test.ts | 29 +- packages/spec/src/stack.zod.ts | 15 +- .../spec/src/system/metadata-form-registry.ts | 3 +- 12 files changed, 211 insertions(+), 857 deletions(-) create mode 100644 docs/adr/0018-unified-node-action-registry.md delete mode 100644 packages/spec/src/automation/workflow.form.ts delete mode 100644 packages/spec/src/automation/workflow.test.ts delete mode 100644 packages/spec/src/automation/workflow.zod.ts diff --git a/docs/adr/0018-unified-node-action-registry.md b/docs/adr/0018-unified-node-action-registry.md new file mode 100644 index 000000000..160b30d3b --- /dev/null +++ b/docs/adr/0018-unified-node-action-registry.md @@ -0,0 +1,197 @@ +# ADR-0018: Unified Node/Action Registry across Flow, Workflow-Rule & Approval + +**Status**: Draft (2026-05-31) +**Deciders**: ObjectStack Protocol Architects +**Builds on**: [ADR-0005](./0005-metadata-customization-overlay.md) (one Zod source of truth per metadata type), [ADR-0012](./0012-notification-platform.md) (generalized outbox in `service-messaging`) +**Consumers**: `@objectstack/spec` (`automation/`), `@objectstack/services/service-automation`, `@objectstack/plugins/plugin-approvals`, `@objectstack/plugins/plugin-webhooks` → `service-messaging`, every plugin that registers a node executor, `../objectui` (`plugin-workflow` designer) + +--- + +## TL;DR + +The platform ships **three authoring paradigms** for business logic — visual **Flow** (canvas), declarative **Workflow Rules**, and **Approval Processes** — plus a graphical **designer** in `../objectui`. Each one independently hardcodes a *closed* list of the node/action types it understands. The four lists do not agree, and none of them is the runtime's actual extension point. + +The result: the same outbound concept ("call an HTTP endpoint" / "notify a human") appears under **five different names** across the codebase, the designer can paint nodes the engine cannot execute, and a plugin that registers a brand-new node type is silently rejected by spec validation and never appears in the palette — directly defeating the plugin mechanism this platform is built on. + +This ADR proposes **one registry-backed node/action contract** that all three paradigms and the designer consume. The runtime registry is already open (`registerNodeExecutor(type: string)`); we make the **protocol** and the **designer** consumers of it instead of parallel closed enums. We also **consolidate the duplicated outbound verbs** onto two shared executors (`http` / `notify`) backed by the ADR-0012 outbox, which closes the reliability gap where `http_request` today is a bare `fetch()` with no retry. + +--- + +## Context + +### The platform has many processes, by design + +This is not an accident to be "fixed" by collapsing everything into one engine. Salesforce ships Flow + Workflow Rules + Approval Processes + Process Builder as distinct paradigms for distinct personas, and ObjectStack mirrors that. Multiple **authoring** paradigms are correct. What is *not* correct is that each paradigm reinvents the **execution vocabulary** beneath it. + +### The four divergent vocabularies (evidence) + +| Subsystem | Protocol | Runtime | Node/action vocabulary | "outbound" verbs | +|:---|:---|:---|:---|:---| +| **Flow (canvas)** | `automation/flow.zod.ts` → `FlowNodeAction` (**closed `z.enum`**) | ✅ `service-automation` (partial) | start, end, decision, assignment, loop, get/create/update/delete_record, **http_request**, script, screen, wait, subflow, connector_action, parallel_gateway, join_gateway, boundary_event | `http_request` | +| **Workflow Rules** | `automation/workflow.zod.ts` → `WorkflowAction` (closed `discriminatedUnion`) | ❌ **none — spec-only** | field_update, email_alert, **http_call**, task_creation, push_notification, custom_script, connector_action | `http_call`, `email_alert`, `push_notification` | +| **Approval Process** | `automation/approval.zod.ts` → `ApprovalActionType` (closed `z.enum`) | ✅ `plugin-approvals` (partial) | field_update, email_alert, **webhook**, script, connector_action, **inbox_notify** | `webhook`, `inbox_notify`, `email_alert` | +| **Designer** | `../objectui` `types/workflow.ts` → `FlowNodeType` (closed union) | — (UI only) | task, user_task, service_task, script_task, approval, condition, parallel_gateway, join_gateway, boundary_event, delay, **notification**, **webhook** | `notification`, `webhook` | +| *(planned)* Notification | [ADR-0012](./0012-notification-platform.md) | `service-messaging` | — | `notify` | + +The same "send something outbound" concept has **five names**: `http_request`, `http_call`, `webhook`, `notification`, `notify`. "Notify a human" has four: `inbox_notify`, `push_notification`, `notification`, `email_alert`. + +### Three concrete failures this causes + +1. **Plugin extensibility is broken at the protocol layer.** The runtime engine is *already* an open registry — `registerNodeExecutor(executor)` keys on a free `string` type, and `getRegisteredNodeTypes()` enumerates them (`service-automation/src/engine.ts`). But `registerFlow()` runs `FlowSchema.parse(definition)`, and `FlowNodeSchema.type` is the **closed** `FlowNodeAction` enum. A plugin that registers a new executor type produces flows that **fail spec validation**. The open runtime is locked shut by a closed protocol. + +2. **The designer paints what the engine can't run.** `TOOLBAR_NODE_TYPES` in `plugin-workflow/src/FlowDesigner.tsx` is hardcoded, and its `FlowNodeType` vocabulary (BPMN-flavored: `task` / `service_task` / `notification` / `webhook`) matches *neither* `flow.zod.ts` *nor* the runtime executors (`decision`, `http_request`, `create_record`, …). A `notification` node dragged onto the canvas has **no executor** behind it. A plugin-registered node type **never appears in the palette**. + +3. **The reliability machinery is built once and reused nowhere.** `plugin-webhooks` has the durable outbox / exponential retry / cluster-lock / dead-letter (per ADR-0012 §"What plugin-webhooks already provides"). Yet Flow's `http_request` executor is a bare `fetch()` with no retry, no idempotency, no outbox (`service-automation/src/plugins/http-connector-plugin.ts`). Workflow Rules' `http_call` has no runtime at all. Approval's `webhook` action is a third implementation. Four would-be HTTP callers, zero sharing the one reliable substrate. + +### The answer already half-exists and is unused + +`automation/node-executor.zod.ts` already defines **`NodeExecutorDescriptor`** — `id`, `name`, `nodeTypes`, `version`, `supportsPause`, `supportsCancellation`, `supportsRetry`, `configSchemaRef`. This is exactly the "plugin contributes a node type, with metadata" shape. Today it is an orphan: nothing registers one, and the designer does not read it. The fix is largely to **promote and wire what is already specified**, not to invent a new abstraction. + +--- + +## Decision + +Adopt a single **registry-backed node/action contract**. The runtime executor registry is the source of truth; the protocol and the designer become its consumers. Concretely: + +1. **Protocol stops hardcoding.** Promote `NodeExecutorDescriptor` to the canonical, cross-paradigm **Action descriptor**. Replace the three closed enums with "built-in descriptors + open extension". `FlowNodeSchema.type` becomes a validated `string` (checked against the registry at parse time), not a frozen enum. +2. **Runtime publishes descriptors and consolidates outbound verbs.** Each executor upgrades from `{ type, execute }` to also publish a descriptor. The five outbound names collapse onto **one `http` (callout) executor and one `notify` executor**, the latter backed by the ADR-0012 `service-messaging` outbox. Flow, Workflow Rules, and Approval all invoke the same two. +3. **Designer renders from the registry.** The palette is populated from descriptors (label / icon / category / config schema), not a hardcoded list. The BPMN shape vocabulary is demoted to a presentation/`bpmn-interop` concern, not the authoring vocabulary. +4. **Workflow Rules compile to Flow.** Do not build a fourth runtime. The declarative rule becomes a simplified authoring view that compiles down to Flow nodes over the same executor registry. + +This is **not** "change the protocol *or* the frontend" — both change, in that order, because the deep fix is the contract. Fixing the frontend first would re-freeze today's wrong vocabulary. + +--- + +## Proposed Design + +### 1. The unified Action descriptor + +Extend the existing `NodeExecutorDescriptor` (`automation/node-executor.zod.ts`) with the metadata a palette and a config form need, and the capability flags the dispatcher needs: + +```ts +// automation/node-executor.zod.ts (extended) +export const ActionDescriptorSchema = z.object({ + // ── identity ─────────────────────────────────────────────── + type: z.string(), // 'http' | 'notify' | 'create_record' | plugin-defined + version: z.string(), // semver of the executor + name: z.string(), // human label (i18n key) + description: z.string().optional(), + + // ── palette presentation (NEW — was missing) ────────────── + icon: z.string().optional(), // icon id resolved by the designer + category: z.enum(['logic','data','io','human','control','custom']).default('custom'), + paradigms: z.array(z.enum(['flow','workflow_rule','approval'])) + .default(['flow']), // which authoring surfaces may offer this action + + // ── config contract (NEW — drives Studio form + parse validation) ── + configSchema: z.unknown().optional(), // JSON Schema (compiled from the executor's Zod) + + // ── capabilities (existing + reliability) ───────────────── + supportsPause: z.boolean().default(false), + supportsCancellation: z.boolean().default(false), + supportsRetry: z.boolean().default(true), + needsOutbox: z.boolean().default(false), // true → dispatch via service-messaging + isAsync: z.boolean().default(false), // request/response that suspends the flow +}); +``` + +> One Zod source per metadata type (Prime Directive 8 / ADR-0005): this *is* that one source for "what a node/action is". `FlowNodeAction`, `WorkflowAction`, and `ApprovalActionType` stop being independent truths and become **seed descriptor sets** registered at boot. + +### 2. Open the protocol's node type + +```ts +// automation/flow.zod.ts +// BEFORE: type: FlowNodeAction (closed enum — rejects plugin types) +// AFTER: +export const FlowNodeSchema = lazySchema(() => z.object({ + id: z.string(), + type: z.string().describe('Action type — validated against the action registry at registerFlow()'), + label: z.string(), + config: z.record(z.string(), z.unknown()).optional(), + // …unchanged… +})); +``` + +`FlowNodeAction` is **retained as an exported const array of built-in type ids** (documentation + the seed set), but it no longer gates `type`. Validation moves from "is it in this enum" to "is it a registered action type, and does `config` satisfy that action's `configSchema`" — performed in `registerFlow()` against the live registry. This is the only way a plugin-registered node can ever be a legal flow. + +### 3. Runtime: descriptor-publishing executors + the registry as truth + +`NodeExecutor` gains an optional `descriptor`: + +```ts +export interface NodeExecutor { + readonly type: string; + readonly descriptor?: ActionDescriptor; // NEW — published into the registry + execute(node, variables, context): Promise; +} +``` + +`AutomationEngine` already exposes `getRegisteredNodeTypes()`; add `getActionDescriptors(): ActionDescriptor[]`. This single method backs both **flow validation** (server side) and the **designer palette** (an API: `GET /api/v1/automation/actions`). + +### 4. Consolidate the outbound verbs + +Two executors replace the five names: + +| New executor | Replaces | Backed by | Notes | +|:---|:---|:---|:---| +| `http` | Flow `http_request`, Workflow-Rule `http_call`, Approval `webhook` | `service-messaging` outbox (ADR-0012) | `needsOutbox: true`, `supportsRetry: true`. Sync request/response variant sets `isAsync: true` and suspends the flow via the existing `wait` pause/resume seam. | +| `notify` | Workflow-Rule `push_notification`, Approval `inbox_notify`, designer `notification`, ADR-0012 `notify` | `service-messaging` channels | Channel selection (inbox/email/push) handled by the notification platform, not by N node types. | + +Both are registered once and offered to all three paradigms via `descriptor.paradigms`. **This is where the `http_request`-has-no-retry gap closes**: HTTP becomes an outbox-backed executor, so every paradigm inherits retry / idempotency / dead-letter for free, exactly as ADR-0012 intended for `notify`. + +Migration aliases keep old flows valid: the registry registers `http_request` / `http_call` / `webhook` as **deprecated aliases** of `http` for one major version (same pattern ADR-0012 uses for `plugin-email`). + +### 5. Designer: palette from the registry + +In `../objectui` `plugin-workflow`: + +* Delete the hardcoded `FlowNodeType` union and `TOOLBAR_NODE_TYPES`. +* Fetch `GET /api/v1/automation/actions`; render the palette grouped by `descriptor.category`, labeled by `name`/`icon`. +* Generate each node's config form from `descriptor.configSchema` (the platform already does JSON-Schema-driven form generation elsewhere). +* The BPMN node shapes (`task`/`service_task`/gateways/`boundary_event`) become a **rendering/export** concern owned by `bpmn-interop`, decoupled from the authoring action list. A plugin node renders with its declared icon; it does not need a bespoke BPMN shape. + +### 6. Workflow Rules → Flow compiler + +`WorkflowAction` (declarative) compiles to a small Flow graph over the shared executors: + +``` +on_update(condition) ──► [ http ] ──► [ notify ] ──► [ field_update→update_record ] ──► end +``` + +No fourth engine. Workflow Rules stays a **simplified authoring view** for business users; execution, reliability, and observability are Flow's. This matches the industry trajectory (Salesforce is retiring Workflow Rules in favor of Flow) and means every fix to the executor registry benefits all three surfaces at once. + +--- + +## Migration + +| Step | Change | Compatibility | +|:---|:---|:---| +| M1 | Extend `NodeExecutorDescriptor` → `ActionDescriptor`; `FlowNodeSchema.type` → validated `string`; `registerFlow()` validates against registry | Existing flows: all current `FlowNodeAction` values are seed-registered, so they keep validating. | +| M2 | Built-in executors publish descriptors; add `getActionDescriptors()` + `GET /api/v1/automation/actions` | Additive. | +| M3 | Introduce `http` + `notify` executors backed by `service-messaging`; register `http_request`/`http_call`/`webhook` as deprecated aliases | Old node types keep running via alias. | +| M4 | Designer palette + config forms driven by the registry; remove hardcoded `FlowNodeType` | Designer-only; old saved graphs still load. | +| M5 | Workflow-Rule → Flow compiler; `plugin-approvals` action executor delegates to the shared `http`/`notify` executors | Approval/Workflow-Rule definitions unchanged; execution path converges. | + +--- + +## Consequences + +**Positive** +* A plugin that registers an executor is *automatically* a legal flow node **and** appears in the designer palette — the plugin mechanism finally works end to end. +* One reliable HTTP path and one notify path; retry/outbox/dead-letter built once, inherited everywhere (closes the `http_request` reliability gap and folds in ADR-0012's `notify`). +* The four vocabularies converge to one registry; "five names for one concept" goes away. +* Studio observability (ADR-0012 §13 Deliveries/Dead-letter) automatically covers Flow and Approval HTTP/notify, not just notifications. + +**Negative / risks** +1. **Parse-time validation now depends on the live registry.** A flow authored against a plugin that is later disabled fails validation. Mitigation: validate with a "known types" snapshot + warn (not hard-fail) on unknown types, mirroring `flow.form.ts` errorHandling (`fail | retry | continue`). +2. **Designer rewrite touches `../objectui`** (separate repo / release train). Phased: registry API ships first; the palette can read it before the hardcoded list is deleted. +3. **Async HTTP (request/response) needs the pause/resume seam.** The `wait` executor / `node-executor.zod.ts` resume payload already exists; the `http` executor's `isAsync` path reuses it rather than inventing suspension. +4. **JSON Schema ⇆ Zod round-trip** for `configSchema`. Compile Zod → JSON Schema at descriptor publish time (build step), don't hand-maintain both. + +--- + +## Open questions + +1. Does `connector_action` (the one verb already present in all three paradigms) become the *general* extension action, with `http`/`notify` as well-known specializations — or stay peer-level? Leaning: keep peer-level; `connector_action` targets a registered connector, `http` is raw. +2. Should `screen` / `user_task` (human-input nodes) carry their own descriptor category (`human`) that the runtime treats as always-`isAsync`? Likely yes. +3. Where does the action registry live for **cross-environment** consistency — is it per-environment (a plugin enabled in env A but not B yields different palettes)? Tie to the package/environment model (ADR-0006). diff --git a/packages/spec/scripts/build-skill-references.ts b/packages/spec/scripts/build-skill-references.ts index 3314654ff..af5c3f313 100644 --- a/packages/spec/scripts/build-skill-references.ts +++ b/packages/spec/scripts/build-skill-references.ts @@ -71,7 +71,6 @@ const SKILL_MAP: Record = { ], 'objectstack-automation': [ 'automation/flow.zod.ts', - 'automation/workflow.zod.ts', 'automation/trigger-registry.zod.ts', 'automation/approval.zod.ts', 'automation/state-machine.zod.ts', diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index 392571877..d666f4237 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -14,7 +14,7 @@ import { } from './analytics.zod'; import { RealtimePresenceSchema, TransportProtocol } from './realtime.zod'; import { ObjectPermissionSchema, FieldPermissionSchema } from '../security/permission.zod'; -import { WorkflowRuleSchema } from '../automation/workflow.zod'; +import { StateMachineSchema } from '../automation/state-machine.zod'; import { TranslationDataSchema } from '../system/translation.zod'; import type { GetFeedRequest, @@ -666,7 +666,7 @@ export const GetWorkflowConfigRequestSchema = lazySchema(() => z.object({ export const GetWorkflowConfigResponseSchema = lazySchema(() => z.object({ object: z.string().describe('Object name'), - workflows: z.array(WorkflowRuleSchema).describe('Active workflow rules for this object'), + workflows: z.array(StateMachineSchema).describe('Active state-machine workflows for this object'), })); export const WorkflowStateSchema = lazySchema(() => z.object({ diff --git a/packages/spec/src/automation/index.ts b/packages/spec/src/automation/index.ts index dc9f4bf2a..6705883e5 100644 --- a/packages/spec/src/automation/index.ts +++ b/packages/spec/src/automation/index.ts @@ -1,8 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -export * from './workflow.zod'; -export { workflowForm } from './workflow.form'; export * from './flow.zod'; export { flowForm } from './flow.form'; export * from './execution.zod'; diff --git a/packages/spec/src/automation/workflow.form.ts b/packages/spec/src/automation/workflow.form.ts deleted file mode 100644 index e750e5cf3..000000000 --- a/packages/spec/src/automation/workflow.form.ts +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { defineForm } from '../ui/view.zod'; - -/** - * Workflow Metadata Form - * - * Form layout for creating/editing declarative workflow rule metadata definitions. - */ -export const workflowForm = defineForm({ - schemaId: 'workflow', - type: 'simple', - sections: [ - { - label: 'Basics', - description: 'Identity and the object/event that triggers it.', - columns: 2, - fields: [ - { field: 'name', required: true, colSpan: 1, helpText: 'Unique identifier (snake_case)' }, - { field: 'objectName', widget: 'ref:object', required: true, colSpan: 1, helpText: 'Which object triggers this workflow' }, - { field: 'triggerType', required: true, colSpan: 1, helpText: 'When to run: on_create, on_update, on_delete, schedule' }, - { field: 'active', colSpan: 1, helpText: 'Enable/disable this workflow' }, - { field: 'description', widget: 'textarea', colSpan: 2, helpText: 'What this workflow does' }, - { field: 'criteria', widget: 'textarea', colSpan: 2, helpText: 'CEL expression: only run when this condition is true' }, - ], - }, - { - label: 'Actions', - description: 'What this workflow does when fired.', - fields: [ - { field: 'actions', type: 'repeater', helpText: 'Actions to execute immediately (field update, email, API call, etc.)' }, - { field: 'timeTriggers', type: 'repeater', helpText: 'Scheduled actions (e.g., send reminder 1 day before deadline)' }, - ], - }, - { - label: 'Advanced', - description: 'Ordering and execution behaviour.', - collapsible: true, - collapsed: true, - fields: [ - { field: 'executionOrder', helpText: 'Run order when multiple workflows match (lower = earlier)' }, - ], - }, - ], -}); diff --git a/packages/spec/src/automation/workflow.test.ts b/packages/spec/src/automation/workflow.test.ts deleted file mode 100644 index d6c5029d1..000000000 --- a/packages/spec/src/automation/workflow.test.ts +++ /dev/null @@ -1,482 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - WorkflowRuleSchema, - WorkflowTriggerType, - FieldUpdateActionSchema, - EmailAlertActionSchema, - ConnectorActionRefSchema, - HttpCallActionSchema, - TaskCreationActionSchema, - PushNotificationActionSchema, - CustomScriptActionSchema, - WorkflowActionSchema, - type WorkflowRule, -} from './workflow.zod'; - -describe('WorkflowTriggerType', () => { - it('should accept all trigger types', () => { - const types = ['on_create', 'on_update', 'on_create_or_update', 'on_delete', 'schedule']; - - types.forEach(type => { - expect(() => WorkflowTriggerType.parse(type)).not.toThrow(); - }); - }); - - it('should reject invalid trigger types', () => { - expect(() => WorkflowTriggerType.parse('invalid')).toThrow(); - expect(() => WorkflowTriggerType.parse('on_save')).toThrow(); - }); -}); - -describe('FieldUpdateActionSchema', () => { - it('should accept field update action', () => { - const action = { - name: 'set_status', - type: 'field_update' as const, - field: 'status', - value: 'approved', - }; - - expect(() => FieldUpdateActionSchema.parse(action)).not.toThrow(); - }); - - it('should accept field update with formula', () => { - const action = { - name: 'calculate_total', - type: 'field_update' as const, - field: 'total_amount', - value: 'quantity * price', - }; - - expect(() => FieldUpdateActionSchema.parse(action)).not.toThrow(); - }); -}); - -describe('EmailAlertActionSchema', () => { - it('should accept email alert action', () => { - const action = { - name: 'notify_manager', - type: 'email_alert' as const, - template: 'approval_request', - recipients: ['manager@example.com'], - }; - - expect(() => EmailAlertActionSchema.parse(action)).not.toThrow(); - }); - - it('should accept email alert with multiple recipients', () => { - const action = { - name: 'notify_team', - type: 'email_alert' as const, - template: 'task_assigned', - recipients: ['user1@example.com', 'user2@example.com', '{owner.email}'], - }; - - expect(() => EmailAlertActionSchema.parse(action)).not.toThrow(); - }); -}); - -describe('ConnectorActionRefSchema', () => { - it('should accept generic connector action (e.g. Slack)', () => { - const action = { - name: 'notify_slack', - type: 'connector_action' as const, - connectorId: 'slack', - actionId: 'post_message', - input: { - channel: '#general', - text: 'New lead created!', - }, - }; - - expect(() => ConnectorActionRefSchema.parse(action)).not.toThrow(); - }); - - it('should accept generic connector action (e.g. Twilio)', () => { - const action = { - name: 'send_sms', - type: 'connector_action' as const, - connectorId: 'twilio', - actionId: 'send_sms', - input: { - to: '+1234567890', - message: 'Your order has been shipped!', - }, - }; - - expect(() => ConnectorActionRefSchema.parse(action)).not.toThrow(); - }); - - it('should validate input is a record', () => { - const action = { - name: 'invalid_action', - type: 'connector_action' as const, - connectorId: 'slack', - actionId: 'post_message', - input: 'invalid_input', // Should be an object - }; - - expect(() => ConnectorActionRefSchema.parse(action)).toThrow(); - }); -}); - -describe('HttpCallActionSchema', () => { - it('should accept basic GET request', () => { - const action = { - name: 'fetch_data', - type: 'http_call' as const, - url: 'https://api.example.com/data', - method: 'GET' as const, - }; - - expect(() => HttpCallActionSchema.parse(action)).not.toThrow(); - }); - - it('should accept POST request with body and headers', () => { - const action = { - name: 'send_data', - type: 'http_call' as const, - url: 'https://api.example.com/create', - method: 'POST' as const, - headers: { - 'Content-Type': 'application/json', - 'X-API-Version': 'v1', - }, - body: JSON.stringify({ - name: '{record.name}', - value: '{record.value}', - }), - }; - - expect(() => HttpCallActionSchema.parse(action)).not.toThrow(); - }); - - it('should accept all HTTP methods', () => { - const methods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as const; - - methods.forEach(method => { - const action = { - name: `test_${method.toLowerCase()}`, - type: 'http_call' as const, - url: 'https://api.example.com/test', - method, - }; - expect(() => HttpCallActionSchema.parse(action)).not.toThrow(); - }); - }); -}); - -describe('TaskCreationActionSchema', () => { - it('should accept minimal task creation', () => { - const action = { - name: 'create_followup_task', - type: 'task_creation' as const, - taskObject: 'task', - subject: 'Follow up with customer', - }; - - expect(() => TaskCreationActionSchema.parse(action)).not.toThrow(); - }); - - it('should accept complete task creation', () => { - const action = { - name: 'create_project_task', - type: 'task_creation' as const, - taskObject: 'project_task', - subject: 'Review and approve proposal', - description: 'Please review the proposal and provide feedback', - assignedTo: '{owner.manager_id}', - dueDate: 'TODAY() + 7', - priority: 'high', - relatedTo: '{record.id}', - additionalFields: { - environment_id: '{record.environment_id}', - estimated_hours: 4, - }, - }; - - expect(() => TaskCreationActionSchema.parse(action)).not.toThrow(); - }); -}); - -describe('PushNotificationActionSchema', () => { - it('should accept basic push notification', () => { - const action = { - name: 'send_push', - type: 'push_notification' as const, - title: 'New Message', - body: 'You have a new message from support', - recipients: ['user-123', 'user-456'], - }; - - expect(() => PushNotificationActionSchema.parse(action)).not.toThrow(); - }); - - it('should accept push notification with full configuration', () => { - const action = { - name: 'send_rich_push', - type: 'push_notification' as const, - title: 'Order Shipped', - body: 'Your order #12345 has been shipped!', - recipients: ['{customer.device_token}'], - data: { - orderId: '{record.id}', - trackingNumber: '{record.tracking_number}', - }, - badge: 1, - sound: 'notification.wav', - clickAction: '/orders/{record.id}', - }; - - expect(() => PushNotificationActionSchema.parse(action)).not.toThrow(); - }); -}); - -describe('CustomScriptActionSchema', () => { - it('should accept JavaScript script with defaults', () => { - const action = { - name: 'run_custom_logic', - type: 'custom_script' as const, - code: 'console.log("Hello from workflow");', - }; - - const result = CustomScriptActionSchema.parse(action); - expect(result.language).toBe('javascript'); - expect(result.timeout).toBe(30000); - }); - - it('should accept TypeScript script', () => { - const action = { - name: 'calculate_score', - type: 'custom_script' as const, - language: 'typescript' as const, - code: ` - const score = record.value1 * 0.3 + record.value2 * 0.7; - return { score }; - `, - timeout: 10000, - context: { - record: '{record}', - user: '{$User}', - }, - }; - - expect(() => CustomScriptActionSchema.parse(action)).not.toThrow(); - }); - - it('should accept Python script', () => { - const action = { - name: 'data_processing', - type: 'custom_script' as const, - language: 'python' as const, - code: 'import json\nresult = json.dumps({"processed": True})', - timeout: 60000, - }; - - expect(() => CustomScriptActionSchema.parse(action)).not.toThrow(); - }); -}); - -describe('WorkflowActionSchema', () => { - it('should accept field update action', () => { - const action = { - name: 'update_field', - type: 'field_update' as const, - field: 'priority', - value: 'high', - }; - - expect(() => WorkflowActionSchema.parse(action)).not.toThrow(); - }); - - it('should accept email alert action', () => { - const action = { - name: 'send_email', - type: 'email_alert' as const, - template: 'welcome_email', - recipients: ['user@example.com'], - }; - - expect(() => WorkflowActionSchema.parse(action)).not.toThrow(); - }); - - it('should accept connector action (SMS)', () => { - const action = { - name: 'send_sms', - type: 'connector_action' as const, - connectorId: 'twilio', - actionId: 'send_sms', - input: { - recipients: ['+1234567890'], - message: 'Test message', - }, - }; - - expect(() => WorkflowActionSchema.parse(action)).not.toThrow(); - }); - - it('should accept connector action (Slack)', () => { - const action = { - name: 'send_slack', - type: 'connector_action' as const, - connectorId: 'slack', - actionId: 'post_message', - input: { - channel: '#general', - message: 'Test message', - }, - }; - - expect(() => WorkflowActionSchema.parse(action)).not.toThrow(); - }); - - it('should accept connector action (Teams)', () => { - const action = { - name: 'send_teams', - type: 'connector_action' as const, - connectorId: 'teams', - actionId: 'send_message', - input: { - channel: 'channel-id', - message: 'Test message', - }, - }; - - expect(() => WorkflowActionSchema.parse(action)).not.toThrow(); - }); - - it('should accept HTTP call action', () => { - const action = { - name: 'api_call', - type: 'http_call' as const, - url: 'https://api.example.com/endpoint', - method: 'POST' as const, - }; - - expect(() => WorkflowActionSchema.parse(action)).not.toThrow(); - }); - - it('should accept task creation action', () => { - const action = { - name: 'create_task', - type: 'task_creation' as const, - taskObject: 'task', - subject: 'Follow up', - }; - - expect(() => WorkflowActionSchema.parse(action)).not.toThrow(); - }); - - it('should accept push notification action', () => { - const action = { - name: 'send_push', - type: 'push_notification' as const, - title: 'Notification', - body: 'You have a new update', - recipients: ['user-123'], - }; - - expect(() => WorkflowActionSchema.parse(action)).not.toThrow(); - }); -}); - -describe('WorkflowRuleSchema', () => { - it('should accept valid workflow rule', () => { - const rule = { - name: 'new_lead_process', - objectName: 'lead', - triggerType: 'on_create', - criteria: 'amount > 1000', - active: true, - actions: [ - { - name: 'set_status', - type: 'field_update' as const, - field: 'status', - value: 'new', - }, - { - name: 'notify_team', - type: 'connector_action' as const, - connectorId: 'slack', - actionId: 'post_message', - input: { channel: '#sales', text: 'New high value lead!' }, - }, - ], - timeTriggers: [ - { - timeLength: 2, - timeUnit: 'days', - offsetDirection: 'after', - offsetFrom: 'trigger_date', - actions: [ - { - name: 'followup_check', - type: 'task_creation' as const, - taskObject: 'task', - subject: 'Follow up lead', - dueDate: 'TODAY()', - }, - ], - }, - ], - }; - - expect(() => WorkflowRuleSchema.parse(rule)).not.toThrow(); - const parsed = WorkflowRuleSchema.parse(rule); - expect(parsed.name).toBe('new_lead_process'); - }); - - it('should reject invalid workflow name (PascalCase)', () => { - const rule = { - name: 'NewLeadProcess', // Invalid - objectName: 'lead', - triggerType: 'on_create', - }; - expect(() => WorkflowRuleSchema.parse(rule)).toThrow(); - }); - - it('should reject invalid workflow name (spaces)', () => { - const rule = { - name: 'new lead process', // Invalid - objectName: 'lead', - triggerType: 'on_create', - }; - expect(() => WorkflowRuleSchema.parse(rule)).toThrow(); - }); -}); - -// ============================================================================ -// Protocol Improvement Tests: Workflow executionOrder -// ============================================================================ - -describe('WorkflowRuleSchema - executionOrder', () => { - it('should accept workflow with custom executionOrder', () => { - const result = WorkflowRuleSchema.parse({ - name: 'first_workflow', - objectName: 'order', - triggerType: 'on_create', - executionOrder: 10, - }); - expect(result.executionOrder).toBe(10); - }); - - it('should default executionOrder to 100', () => { - const result = WorkflowRuleSchema.parse({ - name: 'default_order', - objectName: 'order', - triggerType: 'on_create', - }); - expect(result.executionOrder).toBe(100); - }); - - it('should accept executionOrder of 0', () => { - const result = WorkflowRuleSchema.parse({ - name: 'first_ever', - objectName: 'lead', - triggerType: 'on_update', - executionOrder: 0, - }); - expect(result.executionOrder).toBe(0); - }); -}); diff --git a/packages/spec/src/automation/workflow.zod.ts b/packages/spec/src/automation/workflow.zod.ts deleted file mode 100644 index 0c073f23d..000000000 --- a/packages/spec/src/automation/workflow.zod.ts +++ /dev/null @@ -1,284 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { z } from 'zod'; -import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; -import { ExpressionInputSchema } from '../shared/expression.zod'; - -/** - * Trigger events for workflow automation - */ -import { lazySchema } from '../shared/lazy-schema'; -export const WorkflowTriggerType = z.enum([ - 'on_create', // When record is created - 'on_update', // When record is updated - 'on_create_or_update', // Both - 'on_delete', // When record is deleted - 'schedule' // Time-based (cron) -]); - -/** - * Schema for Workflow Field Update Action - * @example - * { - * name: "update_status", - * type: "field_update", - * field: "status", - * value: "approved" - * } - */ -export const FieldUpdateActionSchema = lazySchema(() => z.object({ - name: z.string().describe('Action name'), - type: z.literal('field_update'), - field: z.string().describe('Field to update'), - value: z.unknown().describe('Value or Formula to set'), -})); - -/** - * Schema for Workflow Email Alert Action - * @example - * { - * name: "send_approval_email", - * type: "email_alert", - * template: "approval_request_email", - * recipients: ["user_id_123", "manager_field"] - * } - */ -export const EmailAlertActionSchema = lazySchema(() => z.object({ - name: z.string().describe('Action name'), - type: z.literal('email_alert'), - template: z.string().describe('Email template ID/DevName'), - recipients: z.array(z.string()).describe('List of recipient emails or user IDs'), -})); - -/** - * Schema for Connector Action Reference - * Executes a capability defined in an integration connector. - * Replaces hardcoded vendor actions (Slack, Twilio, etc). - * - * @example Send Slack Message - * { - * name: "notify_slack", - * type: "connector_action", - * connectorId: "slack", - * actionId: "post_message", - * input: { - * channel: "#general", - * text: "New deal closed: {name}" - * } - * } - */ -export const ConnectorActionRefSchema = lazySchema(() => z.object({ - name: z.string().describe('Action name'), - type: z.literal('connector_action'), - connectorId: z.string().describe('Target Connector ID (e.g. slack, twilio)'), - actionId: z.string().describe('Target Action ID (e.g. send_message)'), - input: z.record(z.string(), z.unknown()).describe('Input parameters matching the action schema'), -})); - -/** - * Schema for HTTP Callout Action - * Makes a REST API call to an external service. - * @example - * { - * name: "sync_to_erp", - * type: "http_call", - * url: "https://erp.api/orders", - * method: "POST", - * headers: { "Authorization": "Bearer {token}" }, - * body: "{ ... }" - * } - */ -export const HttpCallActionSchema = lazySchema(() => z.object({ - name: z.string().describe('Action name'), - type: z.literal('http_call'), - url: z.string().describe('Target URL'), - method: z.enum(['GET', 'POST', 'PUT', 'DELETE', 'PATCH']).default('POST').describe('HTTP Method'), - headers: z.record(z.string(), z.string()).optional().describe('HTTP Headers'), - body: z.string().optional().describe('Request body (JSON or text)'), -})); - -/** - * Schema for Workflow Task Creation Action - * @example - * { - * name: "create_followup_task", - * type: "task_creation", - * taskObject: "tasks", - * subject: "Follow up with client", - * dueDate: "TODAY() + 3" - * } - */ -export const TaskCreationActionSchema = lazySchema(() => z.object({ - name: z.string().describe('Action name'), - type: z.literal('task_creation'), - taskObject: z.string().describe('Task object name (e.g., "task", "project_task")'), - subject: z.string().describe('Task subject/title'), - description: z.string().optional().describe('Task description'), - assignedTo: z.string().optional().describe('User ID or field reference for assignee'), - dueDate: ExpressionInputSchema.optional().describe('Due date — CEL expression evaluated at task creation, e.g. cel`now() + duration("P3D")`'), - priority: z.string().optional().describe('Task priority'), - relatedTo: z.string().optional().describe('Related record ID or field reference'), - additionalFields: z.record(z.string(), z.unknown()).optional().describe('Additional custom fields'), -})); - -/** - * Schema for Workflow Push Notification Action - */ -export const PushNotificationActionSchema = lazySchema(() => z.object({ - name: z.string().describe('Action name'), - type: z.literal('push_notification'), - title: z.string().describe('Notification title'), - body: z.string().describe('Notification body text'), - recipients: z.array(z.string()).describe('User IDs or device tokens'), - data: z.record(z.string(), z.unknown()).optional().describe('Additional data payload'), - badge: z.number().optional().describe('Badge count (iOS)'), - sound: z.string().optional().describe('Notification sound'), - clickAction: z.string().optional().describe('Action/URL when notification is clicked'), -})); - -/** - * Schema for Workflow Custom Script Action - */ -export const CustomScriptActionSchema = lazySchema(() => z.object({ - name: z.string().describe('Action name'), - type: z.literal('custom_script'), - language: z.enum(['javascript', 'typescript', 'python']).default('javascript').describe('Script language'), - code: z.string().describe('Script code to execute'), - timeout: z.number().default(30000).describe('Execution timeout in milliseconds'), - context: z.record(z.string(), z.unknown()).optional().describe('Additional context variables'), -})); - -/** - * Universal Workflow Action Schema - * Union of all supported action types. - */ -export const WorkflowActionSchema = lazySchema(() => z.discriminatedUnion('type', [ - FieldUpdateActionSchema, - EmailAlertActionSchema, - HttpCallActionSchema, - ConnectorActionRefSchema, - TaskCreationActionSchema, - PushNotificationActionSchema, - CustomScriptActionSchema, -])); - -export type WorkflowAction = z.infer; - -/** - * Time Trigger Definition - * Schedules actions to run relative to a specific time or date field. - */ -export const TimeTriggerSchema = lazySchema(() => z.object({ - id: z.string().optional().describe('Unique identifier'), - - /** Timing Logic */ - timeLength: z.number().int().describe('Duration amount (e.g. 1, 30)'), - timeUnit: z.enum(['minutes', 'hours', 'days']).describe('Unit of time'), - - /** Reference Point */ - offsetDirection: z.enum(['before', 'after']).describe('Before or After the reference date'), - offsetFrom: z.enum(['trigger_date', 'date_field']).describe('Basis for calculation'), - dateField: z.string().optional().describe('Date field to calculate from (required if offsetFrom is date_field)'), - - /** Actions */ - actions: z.array(WorkflowActionSchema).describe('Actions to execute at the scheduled time'), -})); - -/** - * Schema for Workflow Rules (Automation) - * - * **NAMING CONVENTION:** - * Workflow names are machine identifiers and must be lowercase snake_case. - * - * @example Good workflow names - * - 'send_welcome_email' - * - 'update_lead_status' - * - 'notify_manager_on_close' - * - 'calculate_discount' - * - * @example Bad workflow names (will be rejected) - * - 'SendWelcomeEmail' (PascalCase) - * - 'updateLeadStatus' (camelCase) - * - 'Send Welcome Email' (spaces) - * - * @example Complete Workflow - * { - * name: "new_lead_process", - * objectName: "lead", - * triggerType: "on_create", - * criteria: "amount > 1000", - * active: true, - * actions: [ - * { - * name: "set_status", - * type: "field_update", - * field: "status", - * value: "new" - * }, - * { - * name: "notify_team", - * type: "connector_action", - * connectorId: "slack", - * actionId: "post_message", - * input: { channel: "#sales", text: "New high value lead!" } - * } - * ], - * timeTriggers: [ - * { - * timeLength: 2, - * timeUnit: "days", - * offsetDirection: "after", - * offsetFrom: "trigger_date", - * actions: [ - * { - * name: "followup_check", - * type: "task_creation", - * taskObject: "task", - * subject: "Follow up lead", - * dueDate: "TODAY()" - * } - * ] - * } - * ] - * } - */ -export const WorkflowRuleSchema = lazySchema(() => z.object({ - /** Machine name */ - name: SnakeCaseIdentifierSchema.describe('Unique workflow name (lowercase snake_case)'), - - /** Target Object */ - objectName: z.string().describe('Target Object'), - - /** When to evaluate the rule */ - triggerType: WorkflowTriggerType.describe('When to evaluate'), - - /** Human-readable description of what this workflow does */ - description: z.string().optional().describe('Human-readable description of what this workflow does'), - - /** - * Condition to start the workflow. - * If empty, runs on every trigger event. - */ - criteria: ExpressionInputSchema.optional().describe('Predicate (CEL). If TRUE, actions execute.'), - - /** Actions to execute immediately */ - actions: z.array(WorkflowActionSchema).optional().describe('Immediate actions'), - - /** - * Time-Dependent Actions - * Actions scheduled to run in the future. - */ - timeTriggers: z.array(TimeTriggerSchema).optional().describe('Scheduled actions relative to trigger or date field'), - - /** Active status */ - active: z.boolean().default(true).describe('Whether this workflow is active'), - - /** Execution Order */ - executionOrder: z.number().int().min(0).default(100).describe('Deterministic execution order when multiple workflows match (lower runs first)'), - - /** Recursion Control */ - reevaluateOnChange: z.boolean().default(false).describe('Re-evaluate rule if field updates change the record validity'), -})); - -export type WorkflowRule = z.infer; -export type TimeTrigger = z.infer; diff --git a/packages/spec/src/kernel/metadata-plugin.zod.ts b/packages/spec/src/kernel/metadata-plugin.zod.ts index 24fea657d..1ecfa6ac9 100644 --- a/packages/spec/src/kernel/metadata-plugin.zod.ts +++ b/packages/spec/src/kernel/metadata-plugin.zod.ts @@ -86,7 +86,7 @@ export const MetadataTypeSchema = lazySchema(() => z.enum([ // Automation Protocol 'flow', // Visual logic flows (FlowSchema) - 'workflow', // State machines (WorkflowSchema) + 'workflow', // State machines (StateMachineSchema) 'approval', // Approval processes (ApprovalSchema) 'job', // Background / scheduled jobs (JobSchema) diff --git a/packages/spec/src/kernel/metadata-type-schemas.ts b/packages/spec/src/kernel/metadata-type-schemas.ts index 95a4b448f..d961ddebd 100644 --- a/packages/spec/src/kernel/metadata-type-schemas.ts +++ b/packages/spec/src/kernel/metadata-type-schemas.ts @@ -41,7 +41,7 @@ import { ActionSchema } from '../ui/action.zod'; import { ReportSchema } from '../ui/report.zod'; import { FlowSchema } from '../automation/flow.zod'; -import { WorkflowRuleSchema } from '../automation/workflow.zod'; +import { StateMachineSchema } from '../automation/state-machine.zod'; import { ApprovalProcessSchema } from '../automation/approval.zod'; import { JobSchema } from '../system/job.zod'; @@ -81,7 +81,7 @@ const BUILTIN_METADATA_TYPE_SCHEMAS: Partial> = // Automation Protocol flow: FlowSchema, - workflow: WorkflowRuleSchema, + workflow: StateMachineSchema, approval: ApprovalProcessSchema, job: JobSchema, diff --git a/packages/spec/src/stack.test.ts b/packages/spec/src/stack.test.ts index be1e4bf53..de9e81445 100644 --- a/packages/spec/src/stack.test.ts +++ b/packages/spec/src/stack.test.ts @@ -459,20 +459,6 @@ describe('defineStack', () => { expect(() => defineStack(config as any, { strict: true })).toThrow('defineStack validation failed'); }); - it('should detect workflow referencing undefined object in strict mode', () => { - const config = { - manifest: baseManifest, - objects: [ - { name: 'task', fields: { title: { type: 'text' } } }, - ], - workflows: [ - { name: 'update_status', objectName: 'nonexistent', triggerType: 'on_create' }, - ], - }; - expect(() => defineStack(config, { strict: true })).toThrow('nonexistent'); - expect(() => defineStack(config, { strict: true })).toThrow('cross-reference validation failed'); - }); - it('should detect approval referencing undefined object in strict mode', () => { const config = { manifest: baseManifest, @@ -510,9 +496,6 @@ describe('defineStack', () => { objects: [ { name: 'lead', fields: { status: { type: 'text' } } }, ], - workflows: [ - { name: 'qualify_lead', objectName: 'lead', triggerType: 'on_create' }, - ], hooks: [ { name: 'enrich_lead', object: 'lead', events: ['beforeInsert'] }, ], @@ -523,8 +506,8 @@ describe('defineStack', () => { it('should skip cross-reference validation when no objects are defined', () => { const config = { manifest: baseManifest, - workflows: [ - { name: 'some_workflow', objectName: 'external_object', triggerType: 'on_create' }, + hooks: [ + { name: 'some_hook', object: 'external_object', events: ['beforeInsert'] }, ], }; // No objects defined, so cross-ref validation is skipped @@ -695,8 +678,8 @@ describe('defineStack - Map Format Support', () => { objects: { task: { fields: { title: { type: 'text' } } }, }, - workflows: { - update_status: { objectName: 'task', triggerType: 'on_create' }, + hooks: { + update_status: { object: 'task', events: ['beforeInsert'] }, }, }; @@ -710,8 +693,8 @@ describe('defineStack - Map Format Support', () => { objects: { task: { fields: { title: { type: 'text' } } }, }, - workflows: { - bad_workflow: { objectName: 'nonexistent', triggerType: 'on_create' }, + hooks: { + bad_hook: { object: 'nonexistent', events: ['beforeInsert'] }, }, }; diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index 5c8bbf5d6..c0acae0eb 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -25,7 +25,7 @@ import { ThemeSchema } from './ui/theme.zod'; // Automation Protocol import { ApprovalProcessSchema } from './automation/approval.zod'; -import { WorkflowRuleSchema } from './automation/workflow.zod'; +import { StateMachineSchema } from './automation/state-machine.zod'; import { FlowSchema } from './automation/flow.zod'; import { JobSchema } from './system/job.zod'; @@ -216,7 +216,7 @@ export const ObjectStackDefinitionSchema = lazySchema(() => z.object({ * ObjectFlow: Automation Layer * Business logic, approvals, and workflows. */ - workflows: z.array(WorkflowRuleSchema).optional().describe('Event-driven workflows'), + workflows: z.array(StateMachineSchema).optional().describe('State-machine workflow definitions (record lifecycle state management)'), approvals: z.array(ApprovalProcessSchema).optional().describe('Approval processes'), flows: z.array(FlowSchema).optional().describe('Screen Flows'), jobs: z.array(JobSchema).optional().describe('Background / Scheduled Jobs (run by IJobService on cron/interval/once schedules)'), @@ -502,17 +502,6 @@ function validateCrossReferences(config: ObjectStackDefinition): string[] { if (objectNames.size === 0) return errors; - // Validate workflow → object references (uses `objectName`) - if (config.workflows) { - for (const workflow of config.workflows) { - if (workflow.objectName && !objectNames.has(workflow.objectName)) { - errors.push( - `Workflow '${workflow.name}' references object '${workflow.objectName}' which is not defined in objects.`, - ); - } - } - } - // Validate approval → object references if (config.approvals) { for (const approval of config.approvals) { diff --git a/packages/spec/src/system/metadata-form-registry.ts b/packages/spec/src/system/metadata-form-registry.ts index 9a684e4c9..145dd5d60 100644 --- a/packages/spec/src/system/metadata-form-registry.ts +++ b/packages/spec/src/system/metadata-form-registry.ts @@ -44,7 +44,7 @@ import { import { roleForm } from '../identity'; import { permissionForm } from '../security'; import { agentForm, toolForm, skillForm } from '../ai'; -import { flowForm, workflowForm, approvalForm } from '../automation'; +import { flowForm, approvalForm } from '../automation'; import { emailTemplateForm } from './email-template.form'; /** @@ -77,7 +77,6 @@ export const METADATA_FORM_REGISTRY: Readonly> = Object tool: toolForm, skill: skillForm, flow: flowForm, - workflow: workflowForm, approval: approvalForm, permission: permissionForm, profile: permissionForm,