Skip to content

ADR / Spec — Workflow Node Handler Extension Point

SUPERSEDED — đây là spec GỐC, không phải API thực tế

Tài liệu này là bản thiết kế trước khi implement. Bản ship đã lệch spec ở nhiều chỗ (tên NodeKind, NodeOutcome không tồn tại, shape của WorkflowNodeContext, data accumulator/templating chưa làm…). Giữ lại như hồ sơ quyết định (ADR). Nguồn chân lý cho API thực tế: Custom Nodes & Actions và code tại service/api/src/modules/workflow/node-registry.ts + builtins.ts. Các mục dưới đây đã được chú thích những khác biệt chính so với bản ship.

Trạng thái: Accepted → Shipped (đã lệch spec, xem banner trên) (open questions §6 chốt 2026-07-08) · Scope: service/api/src/modules/workflow/ + src/extensions/ Mục tiêu: cho phép extension đăng ký node/step type mới cho workflow engine, node tự xử lý logic riêng (kể cả async/external), có thể trigger ngược để tiến luồng, và failback an toàn khi handler vắng mặt.


1. Bối cảnh (state hiện tại)

Engine hiện dispatch bằng switch/if hardcode, không có registry:

  • engine.ts:469-533activateStep branch theo step.type: start/action auto-advance, end complete/promote, còn lại (approval/condition/notification) park chờ người (engine.ts:524).
  • engine.ts:704executeAction switch theo action_type: update_field / send_notification / promote_version.
  • StepTypeunion đóng ở 3 nơi phải sửa đồng thời để thêm type: types.ts:23, validation.ts:17 (z.enum), migration 037-workflow-tables.ts:31 (column comment/default).
  • Advancement: advanceFromTrx (engine.ts:555) đánh giá transition 2 lượt (:583 approve/reject/always, :595 condition), không match → failInstanceInTrx (engine.ts:542) set instance error + release version về draft.
  • Không match transition / step kế thiếu → instance error.

Điểm mở sẵn: action_type đã là free-string (validation.ts:25 chỉ z.string()), kèm action_config JSON (types.ts:42) và options JSON (types.ts, hiện engine chưa dùng). odp_workflow_steps.options là config-bag lý tưởng cho custom node.

Template có sẵn để nhân bản: AppModuleRegistry (permissions/app-module-registry.ts:6-62) — Map singleton, built-in register lúc import (:66-198), extension append lúc load (manager.ts:133), core đọc lúc runtime (permissions layer). Đây là mẫu chính xác cho registry mới.

Ràng buộc quan trọng — reload = full process restart (cli/start.ts:30-33). Không có live re-registration ở production; registry rebuild mỗi lần boot. Service registry bị seal sau Phase 1.5 (index.ts:33). Node type nằm dạng string trong DB → instance đang chạy sống qua restart nếu handler có mặt lại; rủi ro thật là extension bị gỡ mà DB vẫn còn node đó.


2. Quyết định

Xây WorkflowNodeHandler registry thống nhất — một khung handler duy nhất bao cả "step type" lẫn "action type", thay các switch hardcode. Mở StepType để nhận type do extension đăng ký.

Nguyên tắc cốt lõi — dogfooding built-in. 6 node built-in (start/approval/condition/notification/action/end) không có fast-path riêng: chúng đăng ký qua chính workflowNodeRegistry và được engine dispatch qua đúng một đường lookup mà extension dùng. Engine sau refactor không còn switch/if theo type — chỉ còn registry.get(type).onActivate(). Nhờ vậy cơ chế register + load + dispatch + fallback được kiểm chứng ngay từ trong lõi: nếu built-in chạy đúng qua registry thì extension chắc chắn chạy đúng, không có class lỗi "chỉ xảy ra với node của extension". Đây là ràng buộc thiết kế, không phải chi tiết triển khai — mọi PR thêm behavior cho built-in phải đi qua handler, cấm thêm nhánh switch song song.

Lý do chọn khung thống nhất (thay vì chỉ mở action_type):

  • Custom node có thể là human (chờ duyệt kiểu riêng) hoặc async (chờ external), không chỉ là action fire-and-forget.
  • Một seam dispatch duy nhất dễ suy luận, test, và fallback hơn hai trục (type + action_type).
  • Built-in và extension chia sẻ cùng code path → không drift.

3. Thiết kế

3.1 Interface (bộ khung SDK)

Bản ship khác spec dưới đây

Đối chiếu code thực tế tại node-registry.ts (xem Custom Nodes & Actions):

  • NodeKind bản ship = 'start' | 'end' | 'auto' | 'human' | 'branch' | 'external' (KHÔNG có 'async' — đổi thành 'external').
  • NodeOutcome không tồn tại. Handler trả về StepResult ({ nextStepId?, notifications }) và ra quyết định thông qua ctx.ops.*ctx.ops.advanceApprove() / ctx.ops.park() / ctx.ops.wait() / ctx.ops.completeAtEnd() / ctx.ops.runAction() — chứ không trả { mode: ... }.
  • WorkflowNodeContext bản ship = { instance, step, instanceStepId, assignedUsers, ops } (KHÔNG có effectiveItem, stepResults, trx, services, logger, emitter).
  • onActivate(ctx): Promise<StepResult>; onTimeout nhận WorkflowTimeoutContext và trả TimeoutDecision ({ action: 'advance'; trigger } | { action: 'fail'; reason? }); validateConfig(step, context?) nhận thêm context.outgoing (transitions ra). Không có onCancel.

Bản spec gốc (đã lệch):

ts
// modules/workflow/node-registry.ts (mới)

export type NodeKind = 'auto' | 'human' | 'async';

export interface WorkflowNodeContext {
  instance: WorkflowInstance;
  step: WorkflowStep;                 // config đọc từ step.options / step.action_config
  instanceStep: WorkflowInstanceStep;
  effectiveItem: Record<string, unknown>;   // base + version delta (getEffectiveItem)
  stepResults: Record<string, unknown>;     // accumulator (D6): output các node trước, keyed theo step.key
  trx: Knex.Transaction;              // transaction đang mở của activateStep
  services: ExtensionServices;
  logger: Logger;
  emitter: Emitter;
}

export type NodeOutcome =
  | { mode: 'advance'; trigger: TransitionTrigger; data?: unknown }  // engine tiến bước ngay
  | { mode: 'wait' }                                                 // park; chờ resolveNode
  | { mode: 'fail'; reason: string };                               // fail an toàn (instance -> error)

export interface WorkflowNodeHandler {
  type: string;                       // 'start' | 'approval' | ... | 'acme:esign'
  kind: NodeKind;
  onActivate(ctx: WorkflowNodeContext): Promise<NodeOutcome>;
  onTimeout?(ctx: WorkflowNodeContext): Promise<NodeOutcome>;  // node async quá hạn (D5)
  onCancel?(ctx: WorkflowNodeContext): Promise<void>;   // khi instance bị cancel
  validateConfig?(step: WorkflowStep): void;            // gọi lúc activate workflow
  metadata?: {                        // cho console canvas render node + form config
    label: string;
    icon?: string;
    color?: string;
    configSchema?: unknown;           // JSON schema cho action_config/options
  };
}

3.2 Registry (mirror AppModuleRegistry)

ts
class WorkflowNodeRegistry {
  private handlers = new Map<string, WorkflowNodeHandler>();
  register(h: WorkflowNodeHandler): void;   // throw on duplicate (như app-module-registry:9-35)
  unregister(type: string): void;
  get(type: string): WorkflowNodeHandler | undefined;
  has(type: string): boolean;
  listAll(): WorkflowNodeHandler[];         // cho endpoint GET /workflow-node-types (console)
}
export const workflowNodeRegistry = new WorkflowNodeRegistry();
  • Built-in (start/approval/condition/notification/action/end) register lúc import module workflow qua cùng registry.register() mà extension dùng — logic hiện tại của activateStep/executeAction được bê nguyên vào onActivate của từng handler, không đổi hành vi. Không có đường tắt: built-in là consumer đầu tiên của registry (dogfood, §2).
  • Extension register trong window extensions.register init event (index.ts:32, trước serviceRegistry.seal():33) — cùng API register(), chỉ khác thời điểm.

Thứ tự boot: module workflow phải register built-in trước khi extensions.register phát ra, để activate-time validation và extension đều thấy đủ 6 handler nền.

3.3 Sửa dispatch trong engine

  • activateStep (engine.ts:416): thay branch :469-533 bằng:
    ts
    const handler = workflowNodeRegistry.get(stepDef.type) ?? fallbackHandler(stepDef.type);
    const outcome = await handler.onActivate(ctx);
    // mode 'advance' -> advanceFromTrx(trigger)  | 'wait' -> park | 'fail' -> failInstanceInTrx
  • executeAction (engine.ts:704): action step trở thành handler kind: 'auto'. Các action_type built-in (update_field/send_notification/promote_version) cũng đi qua cùng cơ chế register — mỗi cái là một node handler type='update_field'… HOẶC một sub-registry actionRegistry cùng shape (quyết định ở §6), nhưng dù chọn hình nào thì built-in và extension vẫn chung một register() + một đường dispatch, không switch song song.

3.4 Node async — "tự xử lý rồi trigger ngược"

Đây là năng lực chính. Custom node kind: 'async':

  1. onActivate làm việc của nó (gọi external API, tạo job, gửi webhook…) → trả { mode: 'wait' }. Engine park instance ở step (dùng đúng đường park của approval, engine.ts:524), giữ current_step_id, instance_step.status = 'active'.
  2. Khi việc bên ngoài xong, extension gọi ngược engine để resume:
    ts
    await engine.resolveNode(instanceStepId, { trigger: 'approve' | 'reject' | 'always', data });
    resolveNode = generalize của approveStep hiện tại: mở transaction → advanceFromTrx (engine.ts:555) → đánh giá transition, tiến bước, drainDeferredFor (engine.ts:681). Extension trigger bằng gì tùy nó (endpoint riêng của extension, cron, message queue).

Nhờ vậy: extension A thêm node "ký số", "AI duyệt", "chờ thanh toán"… dùng chung khung activate → wait → resolveNode.

3.5 Failback an toàn (3 tầng)

  1. Activate-time validationPOST /workflows/:id/activate duyệt mọi step, chặn/cảnh báo nếu type chưa có handler (registry.has) hoặc validateConfig fail → không sinh workflow "mồ côi". (Bổ sung vào graph validation hiện có ở services/workflows.ts:412-417.)
  2. Runtime fallback — engine gặp type không có handler: không throw crash instance. fallbackHandler mặc định kind: 'human' → park + log warning + ghi odp_workflow_actions (audit qua logAction, engine.ts:761). Instance đứng chờ admin, không hỏng dây chuyền. (Cấu hình WORKFLOW_STRICT_NODES=true để đổi sang fail thay vì park.)
  3. Config guardvalidateConfig lỗi lúc activate node → { mode: 'fail' }failInstanceInTrx (engine.ts:542) có sẵn: instance error, version về draft, có đường resubmit (tạo instance mới).

3.6 DB & validation

  • Không migration. Dùng type (string) + options JSON làm config bag cho custom node; action_config cho action.
  • validation.ts:17: z.enum([...])z.string().refine(t => workflowNodeRegistry.has(t), 'unknown step type').
  • types.ts:23: StepType giữ union built-in nhưng cho phép nới: type StepType = BuiltinStepType | (string & {}).

3.7 Expose cho extension (qua ServiceRegistry — không nhét field vào context)

Quyết định triển khai (khác bản đề xuất ban đầu)

Bản đầu định thêm workflowNodes vào ApiExtensionContext. Đã đổi: nhét field module-specific vào core context làm loãng context + đảo tầng (core import module). Dùng seam có sẵn context.registry (ServiceRegistry, provide/consume — Guardrails §6):

  • Workflow module provide(workflowNodesRef, workflowNodeRegistry) ở boot (trước serviceRegistry.seal() trong index.ts).
  • Extension consume(workflowNodesRef).register(...) trong init hook chạy sau seal (vd app.before).
  • workflowNodesRef / workflowActionsRef export từ @odp/extensions-sdk.
ts
// trong extension entry
import { defineHook, workflowNodesRef } from '@odp/extensions-sdk';

export default defineHook((hook, context) => {
  hook.init('app.before', () => {
    context.registry.consume(workflowNodesRef).register({
      type: 'acme:esign',
      kind: 'external',
      metadata: { label: 'E-Signature', icon: 'signature', configSchema: {/* ... */} },
      validateConfig(step) { if (!step.options?.provider) throw new Error('provider required'); },
      async onActivate(ctx) {
        await docusign.createEnvelope(ctx.instance.item_id, ctx.step.options);
        return ctx.ops.wait({ timeoutMs: 86_400_000 }); // chờ webhook DocuSign
      },
    });
  });
});

Webhook của extension resume qua engine ref: context.registry.consume(workflowEngineRef).resolveNode(instanceStepId, { trigger: 'approve' }). Xem Custom Nodes & Actions cho hướng dẫn thực thi đầy đủ.

3.8 Data passing giữa node (mượn từ Directus Flows — D6)

CHƯA IMPLEMENT (planned)

Toàn bộ mục này — accumulator stepResults, magic keys $trigger/$last/$item/$env, templating {{...}} trong step.options/action_config, và việc matchesFilter đọc stepResults để rẽ nhánh — chưa có trong bản ship. Bản ship chỉ đánh giá transition condition trên effective item (base + version delta). Node async có cột instance_step.result để lưu payload resume (ResolveNodeInput.data), nhưng không có lớp templating/accumulator nào ở trên. Coi mục này là roadmap.

ODP workflow hiện không truyền data giữa step; node chỉ thấy item. Để node operation-style (transform, gọi API, tính toán) dùng được output của node trước, thêm accumulator — tái dùng cột odp_workflow_instance_steps.result đã có sẵn (không migration):

  • Sau mỗi node, engine ghi giá trị NodeOutcome.data vào instance_step.result của node đó.
  • WorkflowNodeContext.stepResults = map { [step.key]: result } của mọi node đã chạy trong instance, cộng magic keys giống Directus: $trigger (payload khởi động instance), $last (result node ngay trước), $item (effective item), $env.
  • step.options / action_config được template hóa trước khi handler chạy — cú pháp {{stepKey.field}} / {{$last.x}} (dùng lại helper templating nếu ODP đã có, nếu chưa thì thêm micromustache như Directus applyOptionsData).
  • Serializability guard: engine JSON.stringify(data ?? null) trước khi lưu (fail sớm tại node lỗi thay vì hỏng node sau); undefinednull để template không vỡ. Redact secret khi ghi audit.
  • Transition condition (advanceFromTrx, engine.ts:582-604) mở rộng để matchesFilter đọc được cả stepResults, không chỉ effective item → rẽ nhánh theo output node (vd node "AI score" trả {score:0.8} → transition condition: {"$last.score": {"_gt": 0.7}}).

Convention outcome cho node kind:'auto' (operation-style, theo Directus return-vs-throw): handler return bình thường → engine coi như {mode:'advance', trigger:'always', data:<return>}; throw{mode:'fail'} (hoặc rẽ transition reject nếu có). Handler auto không bắt buộc trả NodeOutcome tường minh — chỉ node human/async mới cần wait. Giảm boilerplate, khớp thói quen Directus.


4. Các file phải chạm

Line-ref bên dưới là của engine CŨ (trước refactor) — đã lỗi thời. Bản ship gói built-in vào **một file `modules/workflow/builtins.ts`** (không phải thư mục `builtins/*.ts`), và expose registry qua ServiceRegistry (`workflowNodesRef`/`workflowActionsRef`/`workflowEngineRef` trong `node-registry.ts`) thay vì nhét field vào `ApiExtensionContext`.

FileThay đổi
modules/workflow/node-registry.tsmới — interface + registry + fallbackHandler
modules/workflow/builtins.tsmới (một file) — gói start/approval/condition/notification/action/end thành handler
modules/workflow/engine.tsactivateStep/executeAction dùng registry; thêm resolveNode; handleTimeout/cancel (line-ref cũ đã lỗi thời)
modules/workflow/types.tsStepType nới union; thêm types của handler
modules/workflow/validation.tstype enum → refine theo registry
modules/workflow/routes.tsGET /workflow-node-types (list metadata cho console); activate-time validation
modules/workflow/node-registry.tsexpose registry/engine qua ServiceRegistry ref (thay cho việc nhét workflowNodes vào ApiExtensionContext)
index.tsđảm bảo built-in register trước extensions.register; extension register trong window đó

5. Rollout (đề xuất giai đoạn)

  1. Registry + gói built-in thành handler, thay dispatch — không đổi behavior (regression test workflow hiện có phải xanh).
  2. resolveNode + node async + fallback + validation.
  3. Expose workflowNodes qua context + GET /workflow-node-types.
  4. Console: canvas render node động theo metadata (scope frontend riêng).

6. Decisions (chốt 2026-07-08)

D1 — Hai registry cùng shape, không gộp

workflowNodeRegistry theo type (chính) + actionRegistry theo action_type (phụ, cùng interface WorkflowNodeHandler và cùng API register()). Node action built-in là một handler kind:'auto' với type='action', bên trong nó lookup actionRegistry.get(step.action_type) rồi ủy thác.

  • Lý do: giữ nguyên data model 2-tier hiện tại (type='action' + action_type='...') → không migrate, backward-compat 100% với workflow đang chạy. Mà update_field/send_notification/promote_version vẫn mở rộng được (extension thêm action_type mới). Ràng buộc dogfood (§2) áp cho cả hai registry: cấm switch song song.
  • Không chọn gộp 1 registry vì sẽ phải coi update_field như một node type → lệch data model, cần migrate type của action step hiện có, rủi ro regression cao, lợi ích thấp.

D2 — resolveNode là API nội bộ trusted, không expose endpoint core

Extension gọi engine.resolveNode(...) (trusted). Core không mở endpoint resolve chung; thay vào đó expose engine surface qua ServiceRegistry ref workflowEngineRef (provide('workflow.engine', { resolveNode }) ở boot) — extension consume rồi tự lo authn/authz ở webhook của nó. Extension tự lo authn/authz ở endpoint/webhook của nó (nó là boundary với external). resolveNode ghi logAction với actor = system + extension_id trong data để audit truy vết được.

  • Lý do: engine không biết semantics của external event (chữ ký hợp lệ? thanh toán thật?) → không thể áp workflow.participate một cách có nghĩa. Đẩy auth về đúng nơi nắm ngữ cảnh. Vẫn giữ audit đầy đủ.

D3 — Idempotency bằng status-guard trong transaction

resolveNode mở transaction, SELECT ... FOR UPDATE (như scheduler scheduler.ts:82) trên instance_step, chỉ resume khi instance_step.status='active' && instance.status='running' && current_step_id khớp step. Không thỏa → no-op, trả trạng thái hiện tại (không throw) để webhook retry an toàn. data.idempotency_key (nếu extension truyền) ghi vào odp_workflow_actions và bỏ qua nếu key đã tồn tại cho step đó.

  • Lý do: webhook external gần như luôn có at-least-once delivery → phải chịu được gọi trùng. Status-guard là tối thiểu bắt buộc; idempotency_key là lớp gia cố tùy chọn. Không throw để tránh extension coi retry hợp lệ là lỗi.

D4 — WORKFLOW_STRICT_NODES default false (park + warn)

Runtime gặp type không có handler → park + log warning + audit, chờ admin. Đặt true (→ fail) khuyến nghị cho staging/CI để bắt lỗi sớm. Lưu ý: activate-time validation (§3.5 tầng 1) luôn chặn tạo/activate workflow với node type unknown bất kể cờ này — nên STRICT_NODES chỉ chi phối case hiếm "handler biến mất sau khi workflow đã active" (extension bị gỡ giữa chừng).

  • Lý do: production ưu tiên không gãy dây chuyền; một instance treo chờ admin ít hại hơn hàng loạt instance error khi extension tạm vắng. Fail-fast dồn về CI/staging nơi nó có giá trị.

D5 — Node async tái dùng timeout_minutes + scheduler; handler có onTimeout

Node kind:'async' khi trả {mode:'wait'} có thể set timeout_at từ timeout_minutes. Scheduler hiện có (scheduler.ts, 2 phút/lần) nhặt step async quá hạn và gọi handler.onTimeout?(ctx). Không định nghĩa onTimeout → áp timeout_action như approval (mặc định khuyến nghị auto_reject cho async để không treo vĩnh viễn).

  • Lý do: không phát minh cơ chế polling thứ hai — scheduler + index (status, timeout_at) (migrations/037:130-132) đã sẵn. onTimeout cho extension tự quyết retry/escalate/fail; fallback an toàn khi extension không xử lý.

Thêm interface: WorkflowNodeHandler.onTimeout?(ctx: WorkflowNodeContext): Promise<NodeOutcome> (bổ sung vào §3.1).

D6 — Data accumulator + templating (mượn Directus)

Thêm data-passing giữa node qua cột instance_step.result có sẵn + stepResults trong context + magic keys + templating step.options (§3.8). Node kind:'auto' theo convention return→advance / throw→reject của Directus (không bắt trả NodeOutcome).

  • Lý do: không có data-passing thì "custom operation node" gần như vô dụng (không đọc được output node trước). Directus chứng minh mô hình accumulator + $last/$trigger + micromustache là đủ và quen tay. Dùng cột result sẵn → zero migration.

7. Đối chiếu Directus Flows (reference, directus-12.0.1)

Hai mô hình khác bản chất — không bê nguyên, chỉ mượn có chọn lọc:

Khía cạnhDirectus FlowsODP Workflow (thiết kế này)
Bản chấtAutomation pipeline (stateless)Approval state-machine (stateful)
RegistryMap<type, handler> trên FlowManager (api/src/flows.ts:67); built-in + ext cùng path registerOperation (extensions/manager.ts:1007, 868) — dogfoodGiống: workflowNodeRegistry (§3.2), dogfood built-in (§2)
Handler(options, context) => result; branch implicit return→resolve/throw→reject (flows.ts:577)Auto node theo convention Directus (§3.8); human/async trả NodeOutcomewait
Data flowkeyedData accumulator + $trigger/$last/$env; template {{...}} (flows.ts:397, 555)MượnstepResults + instance_step.result (D6, §3.8)
BranchingLinked-list self-FK resolve/reject (1 successor mỗi loại)Giữ transition model ODP (nhiều transition, trigger+condition+sort_order, eval trên effective item) — giàu hơn, không đổi
Triggerevent/schedule/webhook/manual/operation; webhook chỉ startitems.create/update, versions.submit, manual (giữ nguyên)
Async wait/resumeKHÔNG có — synchronous run-to-completion; sleep chỉ block in-processresolveNode + wait state (§3.4) — territory mới; ODP có lợi thế sẵn instance table để lưu run-state
Boot orderextensionManager.initialize()flowManager.initialize() (app.ts:144)built-in register trước extensions.register (§3.2)

Đáng copy: accumulator + magic keys, templating options, serializability guard, secret redaction, tách reload registry (extension manager) khỏi reload định nghĩa (DB). Không áp dụng: resolve/reject linked-list (ODP transition giàu hơn); mô hình stateless (ODP cần stateful cho approval + version). Directus không giúp được: async suspend/resume — tự thiết kế run-state/resume/timeout (§3.4, D5); ODP đã có instance + instance_steps + scheduler nên không xây từ đầu.

8. Liên quan

  • docs/api/modules/workflow.md — engine hiện tại (step types, execution model, data model)
  • permissions/app-module-registry.ts — template registry
  • extensions/registry.ts — service registry (seal model)

ODP Internal API Documentation