Appearance
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-533—activateStepbranch theostep.type:start/actionauto-advance,endcomplete/promote, còn lại (approval/condition/notification) park chờ người (engine.ts:524).engine.ts:704—executeActionswitch theoaction_type:update_field/send_notification/promote_version.StepTypelà union đóng ở 3 nơi phải sửa đồng thời để thêm type:types.ts:23,validation.ts:17(z.enum), migration037-workflow-tables.ts:31(column comment/default).- Advancement:
advanceFromTrx(engine.ts:555) đánh giá transition 2 lượt (:583approve/reject/always,:595condition), không match →failInstanceInTrx(engine.ts:542) set instanceerror+ 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ặcasync(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):
NodeKindbản ship ='start' | 'end' | 'auto' | 'human' | 'branch' | 'external'(KHÔNG có'async'— đổi thành'external').NodeOutcomekhông tồn tại. Handler trả vềStepResult({ nextStepId?, notifications }) và ra quyết định thông quactx.ops.*—ctx.ops.advanceApprove()/ctx.ops.park()/ctx.ops.wait()/ctx.ops.completeAtEnd()/ctx.ops.runAction()— chứ không trả{ mode: ... }.WorkflowNodeContextbản ship ={ instance, step, instanceStepId, assignedUsers, ops }(KHÔNG cóeffectiveItem,stepResults,trx,services,logger,emitter).onActivate(ctx): Promise<StepResult>;onTimeoutnhậnWorkflowTimeoutContextvà trảTimeoutDecision({ action: 'advance'; trigger } | { action: 'fail'; reason? });validateConfig(step, context?)nhận thêmcontext.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ùngregistry.register()mà extension dùng — logic hiện tại củaactivateStep/executeActionđược bê nguyên vàoonActivatecủ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.registerinit event (index.ts:32, trướcserviceRegistry.seal()ở:33) — cùng APIregister(), chỉ khác thời điểm.
Thứ tự boot: module workflow phải register built-in trước khi
extensions.registerphá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-533bằng:tsconst handler = workflowNodeRegistry.get(stepDef.type) ?? fallbackHandler(stepDef.type); const outcome = await handler.onActivate(ctx); // mode 'advance' -> advanceFromTrx(trigger) | 'wait' -> park | 'fail' -> failInstanceInTrxexecuteAction(engine.ts:704): action step trở thành handlerkind: 'auto'. Cácaction_typebuilt-in (update_field/send_notification/promote_version) cũng đi qua cùng cơ chế register — mỗi cái là một node handlertype='update_field'… HOẶC một sub-registryactionRegistrycùng shape (quyết định ở §6), nhưng dù chọn hình nào thì built-in và extension vẫn chung mộtregister()+ 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':
onActivatelà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ủaapproval,engine.ts:524), giữcurrent_step_id,instance_step.status = 'active'.- 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ủaapproveStephiệ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)
- Activate-time validation —
POST /workflows/:id/activateduyệt mọi step, chặn/cảnh báo nếutypechưa có handler (registry.has) hoặcvalidateConfigfail → không sinh workflow "mồ côi". (Bổ sung vào graph validation hiện có ởservices/workflows.ts:412-417.) - Runtime fallback — engine gặp
typekhông có handler: không throw crash instance.fallbackHandlermặc địnhkind: 'human'→ park + log warning + ghiodp_workflow_actions(audit qualogAction,engine.ts:761). Instance đứng chờ admin, không hỏng dây chuyền. (Cấu hìnhWORKFLOW_STRICT_NODES=trueđể đổi sangfailthay vì park.) - Config guard —
validateConfiglỗi lúc activate node →{ mode: 'fail' }→failInstanceInTrx(engine.ts:542) có sẵn: instanceerror, version vềdraft, có đường resubmit (tạo instance mới).
3.6 DB & validation
- Không migration. Dùng
type(string) +optionsJSON làm config bag cho custom node;action_configcho action. validation.ts:17:z.enum([...])→z.string().refine(t => workflowNodeRegistry.has(t), 'unknown step type').types.ts:23:StepTypegiữ 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ướcserviceRegistry.seal()trongindex.ts). - Extension
consume(workflowNodesRef).register(...)trong init hook chạy sau seal (vdapp.before). workflowNodesRef/workflowActionsRefexport 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.datavàoinstance_step.resultcủ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ư DirectusapplyOptionsData).- 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);undefined→nullđể 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}→ transitioncondition: {"$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ẽ transitionrejectnếu có). Handler auto không bắt buộc trảNodeOutcometường minh — chỉ nodehuman/asyncmới cầnwait. 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`.
| File | Thay đổi |
|---|---|
modules/workflow/node-registry.ts | mới — interface + registry + fallbackHandler |
modules/workflow/builtins.ts | mới (một file) — gói start/approval/condition/notification/action/end thành handler |
modules/workflow/engine.ts | activateStep/executeAction dùng registry; thêm resolveNode; handleTimeout/cancel (line-ref cũ đã lỗi thời) |
modules/workflow/types.ts | StepType nới union; thêm types của handler |
modules/workflow/validation.ts | type enum → refine theo registry |
modules/workflow/routes.ts | GET /workflow-node-types (list metadata cho console); activate-time validation |
modules/workflow/node-registry.ts | expose 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)
- Registry + gói built-in thành handler, thay dispatch — không đổi behavior (regression test workflow hiện có phải xanh).
resolveNode+ node async + fallback + validation.- Expose
workflowNodesqua context +GET /workflow-node-types. - 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_versionvẫn mở rộng được (extension thêmaction_typemớ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_fieldnhư một nodetype→ lệch data model, cần migratetypecủ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.participatemộ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
errorkhi 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.onTimeoutcho 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ộtresultsẵ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ạnh | Directus Flows | ODP Workflow (thiết kế này) |
|---|---|---|
| Bản chất | Automation pipeline (stateless) | Approval state-machine (stateful) |
| Registry | Map<type, handler> trên FlowManager (api/src/flows.ts:67); built-in + ext cùng path registerOperation (extensions/manager.ts:1007, 868) — dogfood | Giố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ả NodeOutcome có wait |
| Data flow | keyedData accumulator + $trigger/$last/$env; template {{...}} (flows.ts:397, 555) | Mượn → stepResults + instance_step.result (D6, §3.8) |
| Branching | Linked-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 |
| Trigger | event/schedule/webhook/manual/operation; webhook chỉ start | items.create/update, versions.submit, manual (giữ nguyên) |
| Async wait/resume | KHÔNG có — synchronous run-to-completion; sleep chỉ block in-process | resolveNode + wait state (§3.4) — territory mới; ODP có lợi thế sẵn instance table để lưu run-state |
| Boot order | extensionManager.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 registryextensions/registry.ts— service registry (seal model)