woml-cli 1.0.2 → 1.0.5
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 +750 -88
- package/dist/cli.js +2 -2
- package/dist/cli.js.map +1 -1
- package/package.json +7 -7
package/README.md
CHANGED
|
@@ -1,48 +1,686 @@
|
|
|
1
|
-
# WOML
|
|
1
|
+
# WOML: Workflow Orchestration Markup Language
|
|
2
2
|
|
|
3
|
-
WOML is a markup
|
|
4
|
-
|
|
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
|
+
---
|
|
6
8
|
|
|
7
9
|
## Install
|
|
8
10
|
|
|
9
|
-
|
|
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:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pnpm add -g woml-cli
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Verify:
|
|
10
28
|
|
|
11
29
|
```bash
|
|
12
|
-
bun add --global woml-cli
|
|
13
30
|
woml --version
|
|
14
31
|
```
|
|
15
32
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
33
|
+
**Requirements:** macOS (x64, arm64), Linux (x64, glibc), or Windows (x64, arm64). No database to set up — the default state store is bundled.
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## Document structure
|
|
38
|
+
|
|
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.
|
|
40
|
+
|
|
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
|
+
```
|
|
54
|
+
|
|
55
|
+
Runtime bindings available inside every `<script>`:
|
|
56
|
+
|
|
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
|
|
62
|
+
|
|
63
|
+
Scripts return JSON-compatible values. The Rust engine records every outcome durably.
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## Triggers
|
|
68
|
+
|
|
69
|
+
Triggers decide **when** a workflow runs. Place one or more inside `<triggers>`.
|
|
70
|
+
|
|
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" />
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### `<webhook>` — accept HTTP requests
|
|
80
|
+
|
|
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>
|
|
93
|
+
```
|
|
94
|
+
|
|
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.
|
|
97
|
+
|
|
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" />
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`timezone` defaults to UTC. `on-missed` chooses `skip` or `run-once` after a restart.
|
|
110
|
+
|
|
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}}" />
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
`phone-number-id` is Meta's durable Phone Number ID, not the display phone number.
|
|
184
|
+
|
|
185
|
+
---
|
|
186
|
+
|
|
187
|
+
## Steps
|
|
188
|
+
|
|
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.
|
|
19
310
|
|
|
20
|
-
|
|
311
|
+
---
|
|
21
312
|
|
|
22
|
-
|
|
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.
|
|
397
|
+
|
|
398
|
+
```xml
|
|
399
|
+
<woml>
|
|
400
|
+
<workflow id="organize" name="Organize a folder by file type" version="1.0.0">
|
|
401
|
+
<triggers><manual id="start" /></triggers>
|
|
402
|
+
|
|
403
|
+
<steps>
|
|
404
|
+
<step id="scan">
|
|
405
|
+
<script>
|
|
406
|
+
const { promises: fs } = await import('fs');
|
|
407
|
+
const path = await import('path');
|
|
408
|
+
const folder = context.payload.path ?? '.';
|
|
409
|
+
const entries = await fs.readdir(folder, { withFileTypes: true });
|
|
410
|
+
return {
|
|
411
|
+
folder,
|
|
412
|
+
files: entries
|
|
413
|
+
.filter(e => e.isFile())
|
|
414
|
+
.map(e => ({ name: e.name, ext: path.extname(e.name).toLowerCase() })),
|
|
415
|
+
};
|
|
416
|
+
</script>
|
|
417
|
+
</step>
|
|
418
|
+
|
|
419
|
+
<parallel id="moveAll" concurrency="4">
|
|
420
|
+
<step id="moveImages">
|
|
421
|
+
<script>
|
|
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 };
|
|
432
|
+
</script>
|
|
433
|
+
</step>
|
|
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>
|
|
477
|
+
|
|
478
|
+
<step id="summary">
|
|
479
|
+
<script>
|
|
480
|
+
return {
|
|
481
|
+
message: `Organized ${context.steps.scan.files.length} file(s) into Images/, Docs/, Videos/, Archives/.`
|
|
482
|
+
};
|
|
483
|
+
</script>
|
|
484
|
+
</step>
|
|
485
|
+
</steps>
|
|
486
|
+
</workflow>
|
|
487
|
+
</woml>
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
```bash
|
|
491
|
+
woml run organize.woml --payload '{"path":"/path/to/Downloads"}'
|
|
492
|
+
```
|
|
493
|
+
|
|
494
|
+
---
|
|
495
|
+
|
|
496
|
+
### AI-powered — classify Slack messages and route them to the right channel
|
|
497
|
+
|
|
498
|
+
Send every incoming Slack message to an LLM, classify intent, and forward to a dedicated channel.
|
|
499
|
+
|
|
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>
|
|
510
|
+
|
|
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
|
|
23
587
|
|
|
24
588
|
```xml
|
|
25
589
|
<woml>
|
|
26
|
-
<workflow
|
|
27
|
-
id="hello"
|
|
28
|
-
name="Hello WOML"
|
|
29
|
-
description="Build a greeting from two durable steps."
|
|
30
|
-
version="1.0.0"
|
|
31
|
-
>
|
|
590
|
+
<workflow id="order-guard" version="1.0.0">
|
|
32
591
|
<triggers>
|
|
33
|
-
<
|
|
592
|
+
<webhook id="order"
|
|
593
|
+
path="/webhooks/orders"
|
|
594
|
+
method="POST"
|
|
595
|
+
auth="bearer"
|
|
596
|
+
secret="{{secrets.ORDER_WEBHOOK_TOKEN}}">
|
|
597
|
+
<schema>
|
|
598
|
+
{
|
|
599
|
+
"type": "object",
|
|
600
|
+
"required": ["orderId", "total", "customerId"],
|
|
601
|
+
"properties": {
|
|
602
|
+
"orderId": { "type": "string" },
|
|
603
|
+
"total": { "type": "number" },
|
|
604
|
+
"customerId": { "type": "string" }
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
</schema>
|
|
608
|
+
</webhook>
|
|
34
609
|
</triggers>
|
|
35
610
|
|
|
36
611
|
<steps>
|
|
37
|
-
<step id="
|
|
612
|
+
<step id="risk">
|
|
38
613
|
<script>
|
|
39
|
-
|
|
614
|
+
const customer = await services.http.request({
|
|
615
|
+
method: 'GET',
|
|
616
|
+
url: `https://internal.api/customers/${context.payload.customerId}`,
|
|
617
|
+
timeoutMs: 5000
|
|
618
|
+
});
|
|
619
|
+
return {
|
|
620
|
+
flagged: context.payload.total > 10000 || customer.body.disputes > 0
|
|
621
|
+
};
|
|
40
622
|
</script>
|
|
41
623
|
</step>
|
|
42
624
|
|
|
43
|
-
<step id="
|
|
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>
|
|
649
|
+
</steps>
|
|
650
|
+
</workflow>
|
|
651
|
+
</woml>
|
|
652
|
+
```
|
|
653
|
+
|
|
654
|
+
---
|
|
655
|
+
|
|
656
|
+
### Schedule — daily sales report at 9am weekdays
|
|
657
|
+
|
|
658
|
+
```xml
|
|
659
|
+
<woml>
|
|
660
|
+
<workflow id="daily-report" version="1.0.0">
|
|
661
|
+
<triggers>
|
|
662
|
+
<schedule id="weekdays" cron="0 9 * * MON-FRI" timezone="UTC" />
|
|
663
|
+
</triggers>
|
|
664
|
+
|
|
665
|
+
<steps>
|
|
666
|
+
<step id="totals">
|
|
667
|
+
<script>
|
|
668
|
+
const rows = await services.database.query({
|
|
669
|
+
sql: "SELECT COUNT(*) AS orders, COALESCE(SUM(total), 0) AS revenue FROM orders WHERE created_at >= date('now', '-1 day')",
|
|
670
|
+
parameters: []
|
|
671
|
+
});
|
|
672
|
+
return rows[0];
|
|
673
|
+
</script>
|
|
674
|
+
</step>
|
|
675
|
+
|
|
676
|
+
<step id="publish">
|
|
44
677
|
<script>
|
|
45
|
-
|
|
678
|
+
const { orders, revenue } = context.steps.totals;
|
|
679
|
+
await services.slack.send({
|
|
680
|
+
channel: '#sales',
|
|
681
|
+
text: `Daily report — ${orders} orders, $${revenue.toFixed(2)} revenue.`
|
|
682
|
+
});
|
|
683
|
+
return { sent: true };
|
|
46
684
|
</script>
|
|
47
685
|
</step>
|
|
48
686
|
</steps>
|
|
@@ -50,75 +688,99 @@ Save this as `hello.woml`:
|
|
|
50
688
|
</woml>
|
|
51
689
|
```
|
|
52
690
|
|
|
53
|
-
|
|
691
|
+
---
|
|
54
692
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
693
|
+
### Telegram — answer mentions in your team chat
|
|
694
|
+
|
|
695
|
+
```xml
|
|
696
|
+
<woml>
|
|
697
|
+
<workflow id="telegram-echo" version="1.0.0">
|
|
698
|
+
<triggers>
|
|
699
|
+
<telegram id="incoming"
|
|
700
|
+
events="message"
|
|
701
|
+
bot-token="{{secrets.TELEGRAM_BOT_TOKEN}}" />
|
|
702
|
+
</triggers>
|
|
703
|
+
|
|
704
|
+
<steps>
|
|
705
|
+
<step id="reply">
|
|
706
|
+
<script>
|
|
707
|
+
await services.messaging.send({
|
|
708
|
+
channel: 'telegram',
|
|
709
|
+
conversationId: context.payload.conversationId,
|
|
710
|
+
text: `You said: ${context.payload.text}`
|
|
711
|
+
});
|
|
712
|
+
return { ok: true };
|
|
713
|
+
</script>
|
|
714
|
+
</step>
|
|
715
|
+
</steps>
|
|
716
|
+
</workflow>
|
|
717
|
+
</woml>
|
|
718
|
+
```
|
|
719
|
+
|
|
720
|
+
---
|
|
721
|
+
|
|
722
|
+
### Event — send a confirmation email when another workflow emits `order.created`
|
|
723
|
+
|
|
724
|
+
```xml
|
|
725
|
+
<woml>
|
|
726
|
+
<workflow id="order-confirmation" version="1.0.0">
|
|
727
|
+
<triggers>
|
|
728
|
+
<event id="created"
|
|
729
|
+
name="order.created"
|
|
730
|
+
secret="{{secrets.EVENT_CONTROL_TOKEN}}">
|
|
731
|
+
<schema>
|
|
732
|
+
{
|
|
733
|
+
"type": "object",
|
|
734
|
+
"required": ["orderId", "email"],
|
|
735
|
+
"properties": {
|
|
736
|
+
"orderId": { "type": "string" },
|
|
737
|
+
"email": { "type": "string" }
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
</schema>
|
|
741
|
+
</event>
|
|
742
|
+
</triggers>
|
|
743
|
+
|
|
744
|
+
<steps>
|
|
745
|
+
<step id="confirm">
|
|
746
|
+
<script>
|
|
747
|
+
await services.http.request({
|
|
748
|
+
method: 'POST',
|
|
749
|
+
url: 'https://api.emailprovider.com/v1/send',
|
|
750
|
+
headers: { authorization: `Bearer ${secrets.EMAIL_API_KEY}` },
|
|
751
|
+
body: {
|
|
752
|
+
to: context.payload.email,
|
|
753
|
+
template: 'order-confirmation',
|
|
754
|
+
data: { orderId: context.payload.orderId }
|
|
755
|
+
},
|
|
756
|
+
timeoutMs: 5000
|
|
757
|
+
});
|
|
758
|
+
return { sentTo: context.payload.email };
|
|
759
|
+
</script>
|
|
760
|
+
</step>
|
|
761
|
+
</steps>
|
|
762
|
+
</workflow>
|
|
763
|
+
</woml>
|
|
58
764
|
```
|
|
59
765
|
|
|
60
|
-
|
|
61
|
-
press Ctrl+C to stop it. `woml test hello.woml` is the one-shot form for tests
|
|
62
|
-
and CI.
|
|
766
|
+
---
|
|
63
767
|
|
|
64
|
-
##
|
|
768
|
+
## Common commands
|
|
65
769
|
|
|
66
770
|
```bash
|
|
67
|
-
woml
|
|
68
|
-
woml
|
|
69
|
-
woml run
|
|
70
|
-
woml
|
|
71
|
-
woml
|
|
72
|
-
woml
|
|
73
|
-
woml
|
|
74
|
-
woml
|
|
75
|
-
woml
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
`services.workflows.start()` in the same runtime. Webhook startup prints its URL
|
|
84
|
-
and a generated `curl`; manual triggers print the keyboard instruction.
|
|
85
|
-
|
|
86
|
-
## Runtime bindings
|
|
87
|
-
|
|
88
|
-
Inside scripts:
|
|
89
|
-
|
|
90
|
-
- `context.payload` is input from the trigger or parent workflow;
|
|
91
|
-
- `context.steps.<id>` is a visible successful step/control-flow result;
|
|
92
|
-
- `services` exposes supervised capabilities and imported modules;
|
|
93
|
-
- `secrets.NAME` resolves only declared secrets; and
|
|
94
|
-
- `attempt` describes the current durable retry attempt.
|
|
95
|
-
|
|
96
|
-
Return JSON-compatible data for downstream steps. WOML records durable events
|
|
97
|
-
before advancing the compiled graph and fails closed rather than replaying an
|
|
98
|
-
ambiguous external effect.
|
|
99
|
-
|
|
100
|
-
## Capabilities
|
|
101
|
-
|
|
102
|
-
WOML v1 includes manual and production triggers, retries, choices, switches,
|
|
103
|
-
parallel groups, forked branches, approvals, lifecycle hooks, runtime policies,
|
|
104
|
-
HTTP, SQL databases, storage, cache, events, durable state, local modules,
|
|
105
|
-
reusable steps/providers, communication adapters, workflow call/start, run
|
|
106
|
-
inspection, backup, recovery, and retention.
|
|
107
|
-
|
|
108
|
-
## Documentation
|
|
109
|
-
|
|
110
|
-
- [Getting started](https://github.com/dali-benothmen/woml/blob/master/docs/getting-started.md)
|
|
111
|
-
- [Language reference](https://github.com/dali-benothmen/woml/blob/master/docs/language-reference.md)
|
|
112
|
-
- [CLI reference](https://github.com/dali-benothmen/woml/blob/master/docs/cli-reference.md)
|
|
113
|
-
- [Examples](https://github.com/dali-benothmen/woml/tree/master/examples)
|
|
114
|
-
- [Production deployment](https://github.com/dali-benothmen/woml/blob/master/docs/woml-production-deployment.md)
|
|
115
|
-
- [VS Code extension](https://github.com/dali-benothmen/woml/tree/master/woml-vscode)
|
|
116
|
-
|
|
117
|
-
For support, use
|
|
118
|
-
[GitHub Discussions](https://github.com/dali-benothmen/woml/discussions) or
|
|
119
|
-
open a reproducible [issue](https://github.com/dali-benothmen/woml/issues).
|
|
120
|
-
Report vulnerabilities privately according to the
|
|
121
|
-
[security policy](https://github.com/dali-benothmen/woml/blob/master/SECURITY.md).
|
|
122
|
-
|
|
123
|
-
WOML is licensed under the
|
|
124
|
-
[Apache License 2.0](https://github.com/dali-benothmen/woml/blob/master/LICENSE).
|
|
771
|
+
woml check workflows/ # Validate workflows without running them
|
|
772
|
+
woml run workflows/ # Run in the foreground (Ctrl+C to stop)
|
|
773
|
+
woml run workflows/ --background # Run in the background, survives Ctrl+C
|
|
774
|
+
woml inspect # Show the current state of all runs
|
|
775
|
+
woml list # List known workflows and recent runs
|
|
776
|
+
woml get run_... # Print the full event history of a run
|
|
777
|
+
woml cancel run_... # Cancel a running or pending run
|
|
778
|
+
woml backup backups/latest # Snapshot the durable state store to a file
|
|
779
|
+
woml prune --before 30d --dry-run # Preview which old runs would be purged
|
|
780
|
+
```
|
|
781
|
+
|
|
782
|
+
---
|
|
783
|
+
|
|
784
|
+
## License
|
|
785
|
+
|
|
786
|
+
Apache-2.0.
|