woml-cli 1.0.4 → 1.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +580 -226
- package/dist/cli.js +511 -36383
- package/dist/custom-notification-provider-host.js +12 -7796
- package/dist/custom-notification-provider-worker.js +4 -170
- package/dist/notification-provider-host.js +40 -26793
- package/dist/script-host-worker.js +6 -1614
- package/dist/script-host.js +13 -9340
- package/package.json +7 -13
- package/dist/cli.js.map +0 -177
- package/dist/custom-notification-provider-host.js.map +0 -97
- package/dist/custom-notification-provider-worker.js.map +0 -10
- package/dist/notification-provider-host.js.map +0 -160
- package/dist/script-host-worker.js.map +0 -11
- package/dist/script-host.js.map +0 -93
package/README.md
CHANGED
|
@@ -1,81 +1,404 @@
|
|
|
1
|
-
<div align="center">
|
|
2
|
-
|
|
3
1
|
# WOML: Workflow Orchestration Markup Language
|
|
4
2
|
|
|
5
|
-
|
|
3
|
+
WOML is a declarative markup language for building and running production-grade workflow automation. A WOML workflow is a structured, HTML-inspired document that compiles into a typed, durable execution graph. Triggers, steps, control flow, lifecycle hooks, concurrency, and human approvals all live in one readable file with a Rust engine underneath.
|
|
4
|
+
|
|
5
|
+
When a step needs real logic, JavaScript runs inside `<script>`, so there is no ceiling on what a workflow can do. WOML handles everything around that code — execution order, retries, concurrency, lifecycle, human-in-the-loop, external services, and a durable, inspectable history of every run.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install -g woml-cli
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Or with Bun:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
bun add -g woml-cli
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Or with pnpm:
|
|
6
22
|
|
|
7
|
-
|
|
23
|
+
```bash
|
|
24
|
+
pnpm add -g woml-cli
|
|
25
|
+
```
|
|
8
26
|
|
|
9
|
-
|
|
27
|
+
Verify:
|
|
10
28
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
[]()
|
|
29
|
+
```bash
|
|
30
|
+
woml --version
|
|
31
|
+
```
|
|
15
32
|
|
|
16
|
-
|
|
33
|
+
**Requirements:** macOS (x64, arm64), Linux (x64, glibc), or Windows (x64, arm64). No database to set up — the default state store is bundled.
|
|
17
34
|
|
|
18
35
|
---
|
|
19
36
|
|
|
20
|
-
|
|
37
|
+
## Document structure
|
|
21
38
|
|
|
22
|
-
|
|
39
|
+
A workflow is one `<woml>` document with a `<workflow>` root plus the standard containers: `<config>` for runtime policies, `<lifecycle>` for hooks, `<triggers>` for what starts a run, and `<steps>` for what runs.
|
|
23
40
|
|
|
24
|
-
|
|
41
|
+
```xml
|
|
42
|
+
<woml>
|
|
43
|
+
<workflow id="..." name="..." version="1.0.0">
|
|
44
|
+
<config concurrency="4" timeout="10m" />
|
|
45
|
+
<lifecycle>
|
|
46
|
+
<on-success><script>...</script></on-success>
|
|
47
|
+
<on-error><script>...</script></on-error>
|
|
48
|
+
</lifecycle>
|
|
49
|
+
<triggers>...</triggers>
|
|
50
|
+
<steps>...</steps>
|
|
51
|
+
</workflow>
|
|
52
|
+
</woml>
|
|
53
|
+
```
|
|
25
54
|
|
|
26
|
-
|
|
55
|
+
Runtime bindings available inside every `<script>`:
|
|
27
56
|
|
|
28
|
-
|
|
57
|
+
- `context.payload` — trigger input
|
|
58
|
+
- `context.steps.<id>` — earlier step output
|
|
59
|
+
- `context.run` — durable run metadata
|
|
60
|
+
- `services.http`, `services.database`, `services.slack`, `services.storage`, `services.cache`, `services.event`, `services.messaging` — supervised capabilities (each must be declared by the workflow or its modules)
|
|
61
|
+
- `secrets.<NAME>` — only the secrets proven necessary at compile time
|
|
29
62
|
|
|
30
|
-
|
|
63
|
+
Scripts return JSON-compatible values. The Rust engine records every outcome durably.
|
|
31
64
|
|
|
32
|
-
|
|
65
|
+
---
|
|
33
66
|
|
|
34
|
-
|
|
35
|
-
- **No ceiling.** Common actions are clean tags; when you need real logic, drop into `<script>` with full JavaScript and any npm package. Automate anything, literally anything.
|
|
36
|
-
- **Triggers for everything.** Manual, webhook, schedule, interval, event, Slack, Telegram, Discord, and WhatsApp, and you can build your own providers for anything else.
|
|
37
|
-
- **Built to run in production.** Durable run history, retries, concurrency, lifecycle hooks, and a fold-from-events core mean you can always see what happened, track down errors, and replay it.
|
|
38
|
-
- **Human-in-the-loop.** Pause a workflow for approvals with real notifications and durable waiting.
|
|
39
|
-
- **Modular.** Reusable step definitions, local modules, and workflows that call other workflows.
|
|
40
|
-
- **Fast core.** The execution engine is written in Rust for speed and reliability under load.
|
|
41
|
-
- **Free and runs anywhere.** Open source, self-hosted, runs on macOS, Linux, and Windows.
|
|
67
|
+
## Triggers
|
|
42
68
|
|
|
43
|
-
|
|
69
|
+
Triggers decide **when** a workflow runs. Place one or more inside `<triggers>`.
|
|
44
70
|
|
|
45
|
-
|
|
46
|
-
|
|
71
|
+
### `<manual>` — run on demand
|
|
72
|
+
|
|
73
|
+
The simplest trigger. Starts a run when the operator calls `woml run` with a payload.
|
|
74
|
+
|
|
75
|
+
```xml
|
|
76
|
+
<manual id="start" />
|
|
47
77
|
```
|
|
48
78
|
|
|
49
|
-
|
|
79
|
+
### `<webhook>` — accept HTTP requests
|
|
50
80
|
|
|
51
|
-
|
|
52
|
-
|
|
81
|
+
Registers a static HTTP route that starts a run for every validated payload.
|
|
82
|
+
|
|
83
|
+
```xml
|
|
84
|
+
<webhook id="hook"
|
|
85
|
+
path="/webhooks/orders"
|
|
86
|
+
method="POST"
|
|
87
|
+
auth="bearer"
|
|
88
|
+
secret="{{secrets.ORDER_WEBHOOK_TOKEN}}">
|
|
89
|
+
<schema>
|
|
90
|
+
{ "type": "object", "required": ["orderId"], "properties": { "orderId": { "type": "string" } } }
|
|
91
|
+
</schema>
|
|
92
|
+
</webhook>
|
|
53
93
|
```
|
|
54
94
|
|
|
55
|
-
|
|
95
|
+
- `auth="bearer"` requires a `secret`; `auth="none"` is for deliberately public routes.
|
|
96
|
+
- The inline `<schema>` is JSON Schema Draft 2020-12; invalid payloads return `400 Bad Request` with the `WOML_TRIGGER_SCHEMA_INVALID` code and never start a run.
|
|
56
97
|
|
|
57
|
-
|
|
58
|
-
|
|
98
|
+
### `<schedule>` — cron expressions
|
|
99
|
+
|
|
100
|
+
Starts a run on a WOML Cron v1 schedule. Five numeric fields (`minute hour day-of-month month day-of-week`), wildcards, lists, inclusive ranges, and `/step` are supported. Seconds, names, and Quartz-only tokens are rejected.
|
|
101
|
+
|
|
102
|
+
```xml
|
|
103
|
+
<schedule id="daily"
|
|
104
|
+
cron="0 9 * * MON-FRI"
|
|
105
|
+
timezone="UTC"
|
|
106
|
+
on-missed="skip" />
|
|
59
107
|
```
|
|
60
108
|
|
|
61
|
-
|
|
109
|
+
`timezone` defaults to UTC. `on-missed` chooses `skip` or `run-once` after a restart.
|
|
62
110
|
|
|
63
|
-
|
|
64
|
-
|
|
111
|
+
### `<interval>` — fixed cadence
|
|
112
|
+
|
|
113
|
+
Starts a run on a fixed interval. The compiler must not translate it into cron if semantics would change.
|
|
114
|
+
|
|
115
|
+
```xml
|
|
116
|
+
<interval id="heartbeat" every="30s" on-missed="skip" />
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### `<event>` — react to internal events
|
|
120
|
+
|
|
121
|
+
Starts a run when another workflow emits a named event through the durable event bus.
|
|
122
|
+
|
|
123
|
+
```xml
|
|
124
|
+
<event id="created"
|
|
125
|
+
name="order.created"
|
|
126
|
+
secret="{{secrets.EVENT_CONTROL_TOKEN}}">
|
|
127
|
+
<schema>
|
|
128
|
+
{ "type": "object", "required": ["orderId"], "properties": { "orderId": { "type": "string" } } }
|
|
129
|
+
</schema>
|
|
130
|
+
</event>
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### `<slack>` — Slack Socket Mode
|
|
134
|
+
|
|
135
|
+
Starts a run for Slack workspace events via a single Socket Mode connection per credential pair.
|
|
136
|
+
|
|
137
|
+
```xml
|
|
138
|
+
<slack id="msg"
|
|
139
|
+
events="app-mention,direct-message"
|
|
140
|
+
channels="ops,alerts"
|
|
141
|
+
bot-token="{{secrets.SLACK_BOT_TOKEN}}"
|
|
142
|
+
app-token="{{secrets.SLACK_APP_TOKEN}}" />
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
`events` accepts `app-mention` and `direct-message`. `channels` is optional and limits mentions to a comma-separated set.
|
|
146
|
+
|
|
147
|
+
### `<telegram>` — Telegram long polling
|
|
148
|
+
|
|
149
|
+
Starts a run for every incoming Telegram message via long polling and durable admission.
|
|
150
|
+
|
|
151
|
+
```xml
|
|
152
|
+
<telegram id="bot"
|
|
153
|
+
events="message"
|
|
154
|
+
bot-token="{{secrets.TELEGRAM_BOT_TOKEN}}" />
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Telegram v1 supports the single `message` event.
|
|
158
|
+
|
|
159
|
+
### `<discord>` — Discord Gateway
|
|
160
|
+
|
|
161
|
+
Starts a run for Discord activity via a shared resumable Gateway connection.
|
|
162
|
+
|
|
163
|
+
```xml
|
|
164
|
+
<discord id="bot"
|
|
165
|
+
events="app-mention,direct-message"
|
|
166
|
+
bot-token="{{secrets.DISCORD_BOT_TOKEN}}" />
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
`channels` is optional and accepts comma-separated numeric channel IDs (17–20 digits). Channel names are rejected because they are mutable display labels.
|
|
170
|
+
|
|
171
|
+
### `<whatsapp>` — WhatsApp Cloud API
|
|
172
|
+
|
|
173
|
+
Starts a run for inbound WhatsApp messages via signed Meta Cloud API callbacks.
|
|
174
|
+
|
|
175
|
+
```xml
|
|
176
|
+
<whatsapp id="bot"
|
|
177
|
+
events="message"
|
|
178
|
+
phone-number-id="123456789012345"
|
|
179
|
+
verify-token="{{secrets.WHATSAPP_VERIFY_TOKEN}}"
|
|
180
|
+
app-secret="{{secrets.WHATSAPP_APP_SECRET}}" />
|
|
65
181
|
```
|
|
66
182
|
|
|
67
|
-
|
|
183
|
+
`phone-number-id` is Meta's durable Phone Number ID, not the display phone number.
|
|
184
|
+
|
|
185
|
+
---
|
|
68
186
|
|
|
69
|
-
##
|
|
187
|
+
## Steps
|
|
70
188
|
|
|
71
|
-
|
|
189
|
+
Steps run sequentially inside `<steps>`. Each `<step>` returns a value that becomes available at `context.steps.<stepId>`.
|
|
190
|
+
|
|
191
|
+
```xml
|
|
192
|
+
<step id="greet">
|
|
193
|
+
<script>
|
|
194
|
+
return { message: `Hello, ${context.payload.name}!` };
|
|
195
|
+
</script>
|
|
196
|
+
</step>
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
---
|
|
200
|
+
|
|
201
|
+
## Control flow
|
|
202
|
+
|
|
203
|
+
Two compact routing primitives cover most branching needs.
|
|
204
|
+
|
|
205
|
+
### `<choose>` — mutually exclusive routes
|
|
206
|
+
|
|
207
|
+
`<choose id="...">` selects the first `<when>` whose `test` reference is true and publishes a merged result at `context.steps.<chooseId>`. The `test` attribute holds exactly one context reference — complex conditions belong in named steps.
|
|
208
|
+
|
|
209
|
+
```xml
|
|
210
|
+
<step id="needsReview">
|
|
211
|
+
<script>
|
|
212
|
+
return { value: context.steps.analysis.risk > 0.3 };
|
|
213
|
+
</script>
|
|
214
|
+
</step>
|
|
215
|
+
|
|
216
|
+
<choose id="reviewRoute">
|
|
217
|
+
<when test="{{context.steps.needsReview.value}}">
|
|
218
|
+
<step id="humanDecision">
|
|
219
|
+
<script>return { routed: 'review' };</script>
|
|
220
|
+
</step>
|
|
221
|
+
<result value="{{context.steps.humanDecision}}" />
|
|
222
|
+
</when>
|
|
223
|
+
<otherwise>
|
|
224
|
+
<step id="automaticDecision">
|
|
225
|
+
<script>return { routed: 'auto' };</script>
|
|
226
|
+
</step>
|
|
227
|
+
<result value="{{context.steps.automaticDecision}}" />
|
|
228
|
+
</otherwise>
|
|
229
|
+
</choose>
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
### `<switch>` — exact-string routing
|
|
233
|
+
|
|
234
|
+
`<switch id="..." value="...">` compares one context reference against ordered string cases and runs exactly one route.
|
|
235
|
+
|
|
236
|
+
```xml
|
|
237
|
+
<switch id="route" value="{{context.steps.classify.intent}}">
|
|
238
|
+
<case value="bug">
|
|
239
|
+
<step id="sendBugs">
|
|
240
|
+
<script>return { routedTo: 'bugs' };</script>
|
|
241
|
+
</step>
|
|
242
|
+
<result value="{{context.steps.sendBugs}}" />
|
|
243
|
+
</case>
|
|
244
|
+
<default>
|
|
245
|
+
<step id="dropNoise">
|
|
246
|
+
<script>return { dropped: true };</script>
|
|
247
|
+
</step>
|
|
248
|
+
<result value="{{context.steps.dropNoise}}" />
|
|
249
|
+
</default>
|
|
250
|
+
</switch>
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
---
|
|
254
|
+
|
|
255
|
+
## Concurrent steps — `<parallel>`
|
|
256
|
+
|
|
257
|
+
`<parallel>` runs its direct child steps concurrently and joins after they finish. A one-step parallel is a valid degenerate fork/join.
|
|
258
|
+
|
|
259
|
+
```xml
|
|
260
|
+
<parallel id="fieldData" concurrency="2" on-error="wait-all">
|
|
261
|
+
<step id="loadWeather">
|
|
262
|
+
<script>return loadWeather(context.payload.fieldId);</script>
|
|
263
|
+
</step>
|
|
264
|
+
<step id="loadSoil">
|
|
265
|
+
<script>return loadSoil(context.payload.fieldId);</script>
|
|
266
|
+
</step>
|
|
267
|
+
</parallel>
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
- `concurrency` caps simultaneous child steps; defaults to the number of children.
|
|
271
|
+
- `on-error` is `fail-fast` (default) or `wait-all`. `fail-fast` stops scheduling new children; `wait-all` lets every child reach its terminal outcome first.
|
|
272
|
+
- All children see the same context view from immediately before the fork.
|
|
273
|
+
- A child cannot reference a sibling's output.
|
|
274
|
+
|
|
275
|
+
For multi-step concurrent routes (each branch holds its own sequence of steps), use `<fork>` and `<branch>` instead.
|
|
276
|
+
|
|
277
|
+
---
|
|
278
|
+
|
|
279
|
+
## Concurrent routes — `<fork>` and `<branch>`
|
|
280
|
+
|
|
281
|
+
`<fork>` runs multiple multi-step branches concurrently and joins on a chosen set. Each `<branch>` may contain steps, choices, switches, parallel groups, and approvals. Branches remain sequential internally while overlapping through the multiplexed Bun host.
|
|
282
|
+
|
|
283
|
+
```xml
|
|
284
|
+
<fork id="distribution" join="all">
|
|
285
|
+
<branch id="tiktok">
|
|
286
|
+
<step id="formatTikTok">
|
|
287
|
+
<script>return { caption: `${context.steps.campaign.title} #automation` };</script>
|
|
288
|
+
</step>
|
|
289
|
+
<step id="publishTikTok">
|
|
290
|
+
<script>return { platform: 'tiktok', caption: context.steps.formatTikTok.caption };</script>
|
|
291
|
+
</step>
|
|
292
|
+
</branch>
|
|
293
|
+
<branch id="instagram">
|
|
294
|
+
<step id="formatInstagram">
|
|
295
|
+
<script>return { caption: `${context.steps.campaign.title}\n${context.steps.campaign.url}` };</script>
|
|
296
|
+
</step>
|
|
297
|
+
<step id="publishInstagram">
|
|
298
|
+
<script>return { platform: 'instagram', caption: context.steps.formatInstagram.caption };</script>
|
|
299
|
+
</step>
|
|
300
|
+
</branch>
|
|
301
|
+
</fork>
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
- `join="all"` (or omitted) waits for every branch.
|
|
305
|
+
- `join="none"` waits for none.
|
|
306
|
+
- A whitespace-separated branch-ID list waits only for those branches.
|
|
307
|
+
- A branch can read context available before the fork and outputs created earlier in that same branch; it cannot read sibling-branch outputs.
|
|
308
|
+
- Nested forks inside a fork-owned branch are rejected.
|
|
309
|
+
- A workflow whose only terminal structure is a fork is rejected.
|
|
310
|
+
|
|
311
|
+
---
|
|
312
|
+
|
|
313
|
+
## Human approvals — `<approval>`
|
|
314
|
+
|
|
315
|
+
`<approval>` is a first-class durable control-flow item. It records that a run is waiting for a decision, optionally fires notifications, suspends the run, and selects exactly one continuation after the decision arrives.
|
|
316
|
+
|
|
317
|
+
```xml
|
|
318
|
+
<approval id="contentApproval"
|
|
319
|
+
name="Content approval"
|
|
320
|
+
description="Ask a moderator to approve or reject"
|
|
321
|
+
timeout="24h"
|
|
322
|
+
on-timeout="reject">
|
|
323
|
+
<notify>
|
|
324
|
+
<slack channels="moderators"
|
|
325
|
+
bot-token="{{secrets.SLACK_BOT_TOKEN}}"
|
|
326
|
+
app-token="{{secrets.SLACK_APP_TOKEN}}" />
|
|
327
|
+
</notify>
|
|
328
|
+
|
|
329
|
+
<step id="hold" />
|
|
330
|
+
<when-approved>
|
|
331
|
+
<step id="publish">
|
|
332
|
+
<script>return { published: true };</script>
|
|
333
|
+
</step>
|
|
334
|
+
<result value="{{context.steps.publish}}" />
|
|
335
|
+
</when-approved>
|
|
336
|
+
<when-rejected>
|
|
337
|
+
<step id="archive">
|
|
338
|
+
<script>return { archived: true };</script>
|
|
339
|
+
</step>
|
|
340
|
+
<result value="{{context.steps.archive}}" />
|
|
341
|
+
</when-rejected>
|
|
342
|
+
</approval>
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
- `timeout` caps how long the approval waits; `on-timeout` chooses `approve`, `reject`, or another arm.
|
|
346
|
+
- Optional `<notify>` fires built-in Slack/Telegram/Discord/WhatsApp notifications when the approval is armed.
|
|
347
|
+
- Exactly one `<when-approved>` or `<when-rejected>` is selected after the decision arrives.
|
|
348
|
+
|
|
349
|
+
---
|
|
350
|
+
|
|
351
|
+
## Notifications — `<notify>`
|
|
352
|
+
|
|
353
|
+
`<notify>` is a container for built-in Slack, Telegram, Discord, or WhatsApp deliveries. It is not a standalone step — it is attached to the parent that arms the notification.
|
|
354
|
+
|
|
355
|
+
### Inside lifecycle hooks
|
|
356
|
+
|
|
357
|
+
Fire a notification when the run finishes successfully or fails.
|
|
358
|
+
|
|
359
|
+
```xml
|
|
360
|
+
<lifecycle>
|
|
361
|
+
<on-success>
|
|
362
|
+
<notify>
|
|
363
|
+
<slack channels="ops"
|
|
364
|
+
bot-token="{{secrets.SLACK_BOT_TOKEN}}"
|
|
365
|
+
app-token="{{secrets.SLACK_APP_TOKEN}}" />
|
|
366
|
+
</notify>
|
|
367
|
+
<script>
|
|
368
|
+
console.log('Run completed successfully');
|
|
369
|
+
</script>
|
|
370
|
+
</on-success>
|
|
371
|
+
<on-error>
|
|
372
|
+
<notify>
|
|
373
|
+
<slack channels="oncall"
|
|
374
|
+
bot-token="{{secrets.SLACK_BOT_TOKEN}}"
|
|
375
|
+
app-token="{{secrets.SLACK_APP_TOKEN}}" />
|
|
376
|
+
</notify>
|
|
377
|
+
<script>
|
|
378
|
+
console.error('Run failed');
|
|
379
|
+
</script>
|
|
380
|
+
</on-error>
|
|
381
|
+
</lifecycle>
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
### Inside an approval
|
|
385
|
+
|
|
386
|
+
Fire a notification when the approval is armed so the right moderator sees the decision request (see the `<approval>` example above).
|
|
387
|
+
|
|
388
|
+
A `<notify>` contains one or more built-in provider tags — `<slack>`, `<telegram>`, `<discord>`, or `<whatsapp>` — and must not contain anything else.
|
|
389
|
+
|
|
390
|
+
---
|
|
391
|
+
|
|
392
|
+
## Real examples
|
|
393
|
+
|
|
394
|
+
### Local automation — organize a folder by file type
|
|
395
|
+
|
|
396
|
+
Run once, sorts every file into the right subfolder. Zero external services, zero API keys.
|
|
72
397
|
|
|
73
398
|
```xml
|
|
74
399
|
<woml>
|
|
75
|
-
<workflow id="organize" name="Organize a folder by file type">
|
|
76
|
-
<triggers>
|
|
77
|
-
<manual id="start" />
|
|
78
|
-
</triggers>
|
|
400
|
+
<workflow id="organize" name="Organize a folder by file type" version="1.0.0">
|
|
401
|
+
<triggers><manual id="start" /></triggers>
|
|
79
402
|
|
|
80
403
|
<steps>
|
|
81
404
|
<step id="scan">
|
|
@@ -93,86 +416,70 @@ Run this once and it sorts every file in a folder into the right place — image
|
|
|
93
416
|
</script>
|
|
94
417
|
</step>
|
|
95
418
|
|
|
96
|
-
<
|
|
97
|
-
<step id="
|
|
419
|
+
<parallel id="moveAll" concurrency="4">
|
|
420
|
+
<step id="moveImages">
|
|
98
421
|
<script>
|
|
99
|
-
|
|
422
|
+
const { promises: fs } = await import('fs');
|
|
423
|
+
const path = await import('path');
|
|
424
|
+
const { folder, files } = context.steps.scan;
|
|
425
|
+
for (const f of files.filter(f => ['.jpg','.jpeg','.png','.gif','.webp','.svg'].includes(f.ext))) {
|
|
426
|
+
const from = path.join(folder, f.name);
|
|
427
|
+
const to = path.join(folder, 'Images', f.name);
|
|
428
|
+
await fs.mkdir(path.dirname(to), { recursive: true });
|
|
429
|
+
await fs.rename(from, to);
|
|
430
|
+
}
|
|
431
|
+
return { ok: true };
|
|
100
432
|
</script>
|
|
101
433
|
</step>
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
<
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
</
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
<script>
|
|
146
|
-
const { promises: fs } = await import('fs');
|
|
147
|
-
const path = await import('path');
|
|
148
|
-
const from = path.join(context.steps.scan.folder, context.item.name);
|
|
149
|
-
const to = path.join(context.steps.scan.folder, 'Archives', context.item.name);
|
|
150
|
-
await fs.mkdir(path.dirname(to), { recursive: true });
|
|
151
|
-
await fs.rename(from, to);
|
|
152
|
-
return { movedTo: 'Archives' };
|
|
153
|
-
</script>
|
|
154
|
-
</step>
|
|
155
|
-
</when>
|
|
156
|
-
<otherwise>
|
|
157
|
-
<step id="move-misc">
|
|
158
|
-
<script>
|
|
159
|
-
const { promises: fs } = await import('fs');
|
|
160
|
-
const path = await import('path');
|
|
161
|
-
const from = path.join(context.steps.scan.folder, context.item.name);
|
|
162
|
-
const to = path.join(context.steps.scan.folder, 'Misc', context.item.name);
|
|
163
|
-
await fs.mkdir(path.dirname(to), { recursive: true });
|
|
164
|
-
await fs.rename(from, to);
|
|
165
|
-
return { movedTo: 'Misc' };
|
|
166
|
-
</script>
|
|
167
|
-
</step>
|
|
168
|
-
</otherwise>
|
|
169
|
-
</choose>
|
|
170
|
-
</for-each>
|
|
434
|
+
<step id="moveDocs">
|
|
435
|
+
<script>
|
|
436
|
+
const { promises: fs } = await import('fs');
|
|
437
|
+
const path = await import('path');
|
|
438
|
+
const { folder, files } = context.steps.scan;
|
|
439
|
+
for (const f of files.filter(f => ['.pdf','.doc','.docx','.txt','.md','.rtf'].includes(f.ext))) {
|
|
440
|
+
const from = path.join(folder, f.name);
|
|
441
|
+
const to = path.join(folder, 'Docs', f.name);
|
|
442
|
+
await fs.mkdir(path.dirname(to), { recursive: true });
|
|
443
|
+
await fs.rename(from, to);
|
|
444
|
+
}
|
|
445
|
+
return { ok: true };
|
|
446
|
+
</script>
|
|
447
|
+
</step>
|
|
448
|
+
<step id="moveVideos">
|
|
449
|
+
<script>
|
|
450
|
+
const { promises: fs } = await import('fs');
|
|
451
|
+
const path = await import('path');
|
|
452
|
+
const { folder, files } = context.steps.scan;
|
|
453
|
+
for (const f of files.filter(f => ['.mp4','.mov','.avi','.mkv','.webm'].includes(f.ext))) {
|
|
454
|
+
const from = path.join(folder, f.name);
|
|
455
|
+
const to = path.join(folder, 'Videos', f.name);
|
|
456
|
+
await fs.mkdir(path.dirname(to), { recursive: true });
|
|
457
|
+
await fs.rename(from, to);
|
|
458
|
+
}
|
|
459
|
+
return { ok: true };
|
|
460
|
+
</script>
|
|
461
|
+
</step>
|
|
462
|
+
<step id="moveArchives">
|
|
463
|
+
<script>
|
|
464
|
+
const { promises: fs } = await import('fs');
|
|
465
|
+
const path = await import('path');
|
|
466
|
+
const { folder, files } = context.steps.scan;
|
|
467
|
+
for (const f of files.filter(f => ['.zip','.tar','.gz','.7z','.rar'].includes(f.ext))) {
|
|
468
|
+
const from = path.join(folder, f.name);
|
|
469
|
+
const to = path.join(folder, 'Archives', f.name);
|
|
470
|
+
await fs.mkdir(path.dirname(to), { recursive: true });
|
|
471
|
+
await fs.rename(from, to);
|
|
472
|
+
}
|
|
473
|
+
return { ok: true };
|
|
474
|
+
</script>
|
|
475
|
+
</step>
|
|
476
|
+
</parallel>
|
|
171
477
|
|
|
172
478
|
<step id="summary">
|
|
173
479
|
<script>
|
|
174
|
-
|
|
175
|
-
|
|
480
|
+
return {
|
|
481
|
+
message: `Organized ${context.steps.scan.files.length} file(s) into Images/, Docs/, Videos/, Archives/.`
|
|
482
|
+
};
|
|
176
483
|
</script>
|
|
177
484
|
</step>
|
|
178
485
|
</steps>
|
|
@@ -180,58 +487,127 @@ Run this once and it sorts every file in a folder into the right place — image
|
|
|
180
487
|
</woml>
|
|
181
488
|
```
|
|
182
489
|
|
|
183
|
-
Save it as `organize.woml` and run it:
|
|
184
|
-
|
|
185
490
|
```bash
|
|
186
491
|
woml run organize.woml --payload '{"path":"/path/to/Downloads"}'
|
|
187
492
|
```
|
|
188
493
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
```mermaid
|
|
192
|
-
graph TD
|
|
193
|
-
A[Manual trigger] --> B[Scan folder for files]
|
|
194
|
-
B --> C[For each file]
|
|
195
|
-
C --> D{File extension?}
|
|
196
|
-
D -->|jpg, png, gif, webp, svg| E[Move to Images/]
|
|
197
|
-
D -->|pdf, doc, txt, md| F[Move to Docs/]
|
|
198
|
-
D -->|mp4, mov, avi, mkv| G[Move to Videos/]
|
|
199
|
-
D -->|zip, tar, gz, dmg| H[Move to Archives/]
|
|
200
|
-
D -->|anything else| I[Move to Misc/]
|
|
201
|
-
E --> J[Summary: organized N files]
|
|
202
|
-
F --> J
|
|
203
|
-
G --> J
|
|
204
|
-
H --> J
|
|
205
|
-
I --> J
|
|
206
|
-
```
|
|
494
|
+
---
|
|
207
495
|
|
|
208
|
-
|
|
496
|
+
### AI-powered — classify Slack messages and route them to the right channel
|
|
209
497
|
|
|
210
|
-
|
|
498
|
+
Send every incoming Slack message to an LLM, classify intent, and forward to a dedicated channel.
|
|
211
499
|
|
|
212
|
-
|
|
500
|
+
```xml
|
|
501
|
+
<woml>
|
|
502
|
+
<workflow id="slack-router" version="1.0.0">
|
|
503
|
+
<triggers>
|
|
504
|
+
<slack id="incoming"
|
|
505
|
+
events="app-mention,direct-message"
|
|
506
|
+
channels="inbox"
|
|
507
|
+
bot-token="{{secrets.SLACK_BOT_TOKEN}}"
|
|
508
|
+
app-token="{{secrets.SLACK_APP_TOKEN}}" />
|
|
509
|
+
</triggers>
|
|
213
510
|
|
|
214
|
-
|
|
511
|
+
<steps>
|
|
512
|
+
<step id="classify">
|
|
513
|
+
<script>
|
|
514
|
+
const response = await services.http.request({
|
|
515
|
+
method: 'POST',
|
|
516
|
+
url: 'https://api.openai.com/v1/chat/completions',
|
|
517
|
+
headers: { authorization: `Bearer ${secrets.OPENAI_API_KEY}` },
|
|
518
|
+
body: {
|
|
519
|
+
model: 'gpt-4o-mini',
|
|
520
|
+
messages: [
|
|
521
|
+
{
|
|
522
|
+
role: 'system',
|
|
523
|
+
content: 'Classify the message into one of: bug, feature, question, noise. Reply with JSON { "intent": "...", "confidence": 0..1 }.'
|
|
524
|
+
},
|
|
525
|
+
{ role: 'user', content: context.payload.text }
|
|
526
|
+
],
|
|
527
|
+
response_format: { type: 'json_object' }
|
|
528
|
+
},
|
|
529
|
+
timeoutMs: 10000
|
|
530
|
+
});
|
|
531
|
+
return JSON.parse(response.body.choices[0].message.content);
|
|
532
|
+
</script>
|
|
533
|
+
</step>
|
|
534
|
+
|
|
535
|
+
<switch id="route" value="{{context.steps.classify.intent}}">
|
|
536
|
+
<case value="bug">
|
|
537
|
+
<step id="sendBugs">
|
|
538
|
+
<script>
|
|
539
|
+
await services.slack.send({
|
|
540
|
+
channel: '#bugs',
|
|
541
|
+
text: `🐛 ${context.payload.text}\n> confidence ${context.steps.classify.confidence}`
|
|
542
|
+
});
|
|
543
|
+
return { routedTo: 'bugs' };
|
|
544
|
+
</script>
|
|
545
|
+
</step>
|
|
546
|
+
<result value="{{context.steps.sendBugs}}" />
|
|
547
|
+
</case>
|
|
548
|
+
<case value="feature">
|
|
549
|
+
<step id="sendFeatures">
|
|
550
|
+
<script>
|
|
551
|
+
await services.slack.send({
|
|
552
|
+
channel: '#feature-requests',
|
|
553
|
+
text: `💡 ${context.payload.text}`
|
|
554
|
+
});
|
|
555
|
+
return { routedTo: 'feature-requests' };
|
|
556
|
+
</script>
|
|
557
|
+
</step>
|
|
558
|
+
<result value="{{context.steps.sendFeatures}}" />
|
|
559
|
+
</case>
|
|
560
|
+
<case value="question">
|
|
561
|
+
<step id="sendQuestions">
|
|
562
|
+
<script>
|
|
563
|
+
await services.slack.send({
|
|
564
|
+
channel: '#questions',
|
|
565
|
+
text: `❓ ${context.payload.text}`
|
|
566
|
+
});
|
|
567
|
+
return { routedTo: 'questions' };
|
|
568
|
+
</script>
|
|
569
|
+
</step>
|
|
570
|
+
<result value="{{context.steps.sendQuestions}}" />
|
|
571
|
+
</case>
|
|
572
|
+
<default>
|
|
573
|
+
<step id="dropNoise">
|
|
574
|
+
<script>return { dropped: true };</script>
|
|
575
|
+
</step>
|
|
576
|
+
<result value="{{context.steps.dropNoise}}" />
|
|
577
|
+
</default>
|
|
578
|
+
</switch>
|
|
579
|
+
</steps>
|
|
580
|
+
</workflow>
|
|
581
|
+
</woml>
|
|
582
|
+
```
|
|
583
|
+
|
|
584
|
+
---
|
|
585
|
+
|
|
586
|
+
### Webhook — flag risky orders, alert Slack
|
|
215
587
|
|
|
216
588
|
```xml
|
|
217
589
|
<woml>
|
|
218
|
-
<workflow id="order-guard">
|
|
590
|
+
<workflow id="order-guard" version="1.0.0">
|
|
219
591
|
<triggers>
|
|
220
|
-
<webhook id="order"
|
|
592
|
+
<webhook id="order"
|
|
593
|
+
path="/webhooks/orders"
|
|
594
|
+
method="POST"
|
|
595
|
+
auth="bearer"
|
|
221
596
|
secret="{{secrets.ORDER_WEBHOOK_TOKEN}}">
|
|
222
597
|
<schema>
|
|
223
598
|
{
|
|
224
599
|
"type": "object",
|
|
225
600
|
"required": ["orderId", "total", "customerId"],
|
|
226
601
|
"properties": {
|
|
227
|
-
"orderId":
|
|
228
|
-
"total":
|
|
602
|
+
"orderId": { "type": "string" },
|
|
603
|
+
"total": { "type": "number" },
|
|
229
604
|
"customerId": { "type": "string" }
|
|
230
605
|
}
|
|
231
606
|
}
|
|
232
607
|
</schema>
|
|
233
608
|
</webhook>
|
|
234
609
|
</triggers>
|
|
610
|
+
|
|
235
611
|
<steps>
|
|
236
612
|
<step id="risk">
|
|
237
613
|
<script>
|
|
@@ -240,35 +616,52 @@ Four triggers, four real automations. Each is a complete, runnable workflow.
|
|
|
240
616
|
url: `https://internal.api/customers/${context.payload.customerId}`,
|
|
241
617
|
timeoutMs: 5000
|
|
242
618
|
});
|
|
243
|
-
return {
|
|
619
|
+
return {
|
|
620
|
+
flagged: context.payload.total > 10000 || customer.body.disputes > 0
|
|
621
|
+
};
|
|
244
622
|
</script>
|
|
245
623
|
</step>
|
|
624
|
+
|
|
625
|
+
<step id="isFlagged">
|
|
626
|
+
<script>return { value: context.steps.risk.flagged };</script>
|
|
627
|
+
</step>
|
|
628
|
+
|
|
629
|
+
<choose id="alertRoute">
|
|
630
|
+
<when test="{{context.steps.isFlagged.value}}">
|
|
631
|
+
<step id="alert">
|
|
632
|
+
<script>
|
|
633
|
+
await services.slack.send({
|
|
634
|
+
channel: '#fraud',
|
|
635
|
+
text: `High-risk order ${context.payload.orderId} ($${context.payload.total}) needs review.`
|
|
636
|
+
});
|
|
637
|
+
return { alerted: true };
|
|
638
|
+
</script>
|
|
639
|
+
</step>
|
|
640
|
+
<result value="{{context.steps.alert}}" />
|
|
641
|
+
</when>
|
|
642
|
+
<otherwise>
|
|
643
|
+
<step id="logOk">
|
|
644
|
+
<script>return { logged: true };</script>
|
|
645
|
+
</step>
|
|
646
|
+
<result value="{{context.steps.logOk}}" />
|
|
647
|
+
</otherwise>
|
|
648
|
+
</choose>
|
|
246
649
|
</steps>
|
|
247
|
-
<choose>
|
|
248
|
-
<when test="context.steps.risk.flagged">
|
|
249
|
-
<step id="alert">
|
|
250
|
-
<script>
|
|
251
|
-
await services.slack.send({
|
|
252
|
-
channel: '#fraud',
|
|
253
|
-
text: `High-risk order ${context.payload.orderId} ($${context.payload.total}) needs review.`
|
|
254
|
-
});
|
|
255
|
-
return { alerted: true };
|
|
256
|
-
</script>
|
|
257
|
-
</step>
|
|
258
|
-
</when>
|
|
259
|
-
</choose>
|
|
260
650
|
</workflow>
|
|
261
651
|
</woml>
|
|
262
652
|
```
|
|
263
653
|
|
|
264
|
-
|
|
654
|
+
---
|
|
655
|
+
|
|
656
|
+
### Schedule — daily sales report at 9am weekdays
|
|
265
657
|
|
|
266
658
|
```xml
|
|
267
659
|
<woml>
|
|
268
|
-
<workflow id="daily-report">
|
|
660
|
+
<workflow id="daily-report" version="1.0.0">
|
|
269
661
|
<triggers>
|
|
270
662
|
<schedule id="weekdays" cron="0 9 * * MON-FRI" timezone="UTC" />
|
|
271
663
|
</triggers>
|
|
664
|
+
|
|
272
665
|
<steps>
|
|
273
666
|
<step id="totals">
|
|
274
667
|
<script>
|
|
@@ -279,6 +672,7 @@ Four triggers, four real automations. Each is a complete, runnable workflow.
|
|
|
279
672
|
return rows[0];
|
|
280
673
|
</script>
|
|
281
674
|
</step>
|
|
675
|
+
|
|
282
676
|
<step id="publish">
|
|
283
677
|
<script>
|
|
284
678
|
const { orders, revenue } = context.steps.totals;
|
|
@@ -294,30 +688,26 @@ Four triggers, four real automations. Each is a complete, runnable workflow.
|
|
|
294
688
|
</woml>
|
|
295
689
|
```
|
|
296
690
|
|
|
297
|
-
|
|
691
|
+
---
|
|
692
|
+
|
|
693
|
+
### Telegram — answer mentions in your team chat
|
|
298
694
|
|
|
299
695
|
```xml
|
|
300
696
|
<woml>
|
|
301
|
-
<workflow id="telegram-
|
|
697
|
+
<workflow id="telegram-echo" version="1.0.0">
|
|
302
698
|
<triggers>
|
|
303
|
-
<telegram id="
|
|
699
|
+
<telegram id="incoming"
|
|
700
|
+
events="message"
|
|
304
701
|
bot-token="{{secrets.TELEGRAM_BOT_TOKEN}}" />
|
|
305
702
|
</triggers>
|
|
703
|
+
|
|
306
704
|
<steps>
|
|
307
|
-
<step id="
|
|
308
|
-
<script>
|
|
309
|
-
const rows = await services.database.query({
|
|
310
|
-
sql: "SELECT COUNT(*) AS today FROM orders WHERE created_at >= date('now')",
|
|
311
|
-
parameters: []
|
|
312
|
-
});
|
|
313
|
-
return { today: rows[0].today };
|
|
314
|
-
</script>
|
|
315
|
-
</step>
|
|
316
|
-
<step id="notify">
|
|
705
|
+
<step id="reply">
|
|
317
706
|
<script>
|
|
318
|
-
await services.
|
|
319
|
-
channel: '
|
|
320
|
-
|
|
707
|
+
await services.messaging.send({
|
|
708
|
+
channel: 'telegram',
|
|
709
|
+
conversationId: context.payload.conversationId,
|
|
710
|
+
text: `You said: ${context.payload.text}`
|
|
321
711
|
});
|
|
322
712
|
return { ok: true };
|
|
323
713
|
</script>
|
|
@@ -327,25 +717,30 @@ Four triggers, four real automations. Each is a complete, runnable workflow.
|
|
|
327
717
|
</woml>
|
|
328
718
|
```
|
|
329
719
|
|
|
330
|
-
|
|
720
|
+
---
|
|
721
|
+
|
|
722
|
+
### Event — send a confirmation email when another workflow emits `order.created`
|
|
331
723
|
|
|
332
724
|
```xml
|
|
333
725
|
<woml>
|
|
334
|
-
<workflow id="order-confirmation">
|
|
726
|
+
<workflow id="order-confirmation" version="1.0.0">
|
|
335
727
|
<triggers>
|
|
336
|
-
<event id="created"
|
|
728
|
+
<event id="created"
|
|
729
|
+
name="order.created"
|
|
730
|
+
secret="{{secrets.EVENT_CONTROL_TOKEN}}">
|
|
337
731
|
<schema>
|
|
338
732
|
{
|
|
339
733
|
"type": "object",
|
|
340
734
|
"required": ["orderId", "email"],
|
|
341
735
|
"properties": {
|
|
342
736
|
"orderId": { "type": "string" },
|
|
343
|
-
"email":
|
|
737
|
+
"email": { "type": "string" }
|
|
344
738
|
}
|
|
345
739
|
}
|
|
346
740
|
</schema>
|
|
347
741
|
</event>
|
|
348
742
|
</triggers>
|
|
743
|
+
|
|
349
744
|
<steps>
|
|
350
745
|
<step id="confirm">
|
|
351
746
|
<script>
|
|
@@ -368,34 +763,7 @@ Four triggers, four real automations. Each is a complete, runnable workflow.
|
|
|
368
763
|
</woml>
|
|
369
764
|
```
|
|
370
765
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
**→ [Browse all examples](./examples/)**
|
|
374
|
-
|
|
375
|
-
## WOML vs alternatives
|
|
376
|
-
|
|
377
|
-
| Tool | How you write it | Readable by the whole team | Self-hosted | No ceiling |
|
|
378
|
-
| -------------- | ------------------- | ----------------------------- | ----------- | ---------- |
|
|
379
|
-
| **WOML** | Markup + JavaScript | ✅ | ✅ | ✅ |
|
|
380
|
-
| n8n | Visual canvas | ⚠️ Until it becomes spaghetti | ✅ | ❌ |
|
|
381
|
-
| Zapier | Visual canvas | ⚠️ Until it becomes spaghetti | ❌ | ❌ |
|
|
382
|
-
| Temporal | Code (TS/Go/Java) | ❌ | ✅ | ✅ |
|
|
383
|
-
| Step Functions | JSON (ASL) | ❌ | ❌ | ⚠️ |
|
|
384
|
-
| Airflow | Python DAGs | ❌ | ✅ | ✅ |
|
|
385
|
-
|
|
386
|
-
WOML is the only one that combines all three: readable as markup, free and self-hosted, and unlimited in what it can express.
|
|
387
|
-
|
|
388
|
-
## What WOML includes
|
|
389
|
-
|
|
390
|
-
- Manual, webhook, schedule, interval, event, Slack, Telegram, Discord, and WhatsApp triggers.
|
|
391
|
-
- Sequential steps, retries, parallel groups, choices, switches, and forked multi-step branches.
|
|
392
|
-
- Durable approvals with provider notifications and shared decisions.
|
|
393
|
-
- Workflow and step lifecycle hooks.
|
|
394
|
-
- Built-in HTTP, SQL database, storage, cache, event, durable-state, messaging, and workflow call/start services.
|
|
395
|
-
- Local JavaScript/TypeScript modules, reusable WOML steps, and custom notification providers.
|
|
396
|
-
- Runtime concurrency, rate-limit, queue, and timeout policies.
|
|
397
|
-
- Foreground and background operation, run inspection, log following, backup, recovery, and retention.
|
|
398
|
-
- A VS Code extension with HTML-style markup and embedded JavaScript syntax.
|
|
766
|
+
---
|
|
399
767
|
|
|
400
768
|
## Common commands
|
|
401
769
|
|
|
@@ -411,22 +779,8 @@ woml backup backups/latest # Snapshot the durable state store to a
|
|
|
411
779
|
woml prune --before 30d --dry-run # Preview which old runs would be purged
|
|
412
780
|
```
|
|
413
781
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
## Documentation
|
|
417
|
-
|
|
418
|
-
Full documentation — language reference, all tags, triggers, control flow, services, modules, and production deployment — lives in the docs.
|
|
419
|
-
|
|
420
|
-
**→ [Read the full documentation](./docs/README.md)**
|
|
421
|
-
|
|
422
|
-
## Support and security
|
|
423
|
-
|
|
424
|
-
Use [GitHub Discussions](https://github.com/dali-benothmen/woml/discussions) for questions and [GitHub Issues](https://github.com/dali-benothmen/woml/issues) for reproducible bugs. Please report vulnerabilities privately according to the [security policy](SECURITY.md).
|
|
425
|
-
|
|
426
|
-
## Contributing
|
|
427
|
-
|
|
428
|
-
Contributions are welcome — bug reports, feature ideas, docs, or a custom provider. See [CONTRIBUTING.md](./CONTRIBUTING.md) to get started.
|
|
782
|
+
---
|
|
429
783
|
|
|
430
784
|
## License
|
|
431
785
|
|
|
432
|
-
|
|
786
|
+
Apache-2.0.
|