@jiggai/recipes 0.4.50 → 0.4.51
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 +25 -0
- package/docs/BUNDLED_RECIPES.md +5 -0
- package/docs/WORKFLOW_EXAMPLES.md +11 -2
- package/docs/WORKFLOW_EXAMPLES_BUNDLED.md +313 -0
- package/index.ts +12 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +2 -2
- package/src/handlers/workflows.ts +10 -0
- package/src/lib/workflows/workflow-queue.ts +136 -2
- package/src/lib/workflows/workflow-utils.ts +5 -1
- package/src/lib/workflows/workflow-worker.ts +59 -258
package/README.md
CHANGED
|
@@ -53,12 +53,17 @@ It is built for people who want durable artifacts on disk, not hidden app state.
|
|
|
53
53
|
|
|
54
54
|
```bash
|
|
55
55
|
openclaw plugins install @jiggai/recipes
|
|
56
|
+
openclaw plugins enable recipes
|
|
56
57
|
openclaw gateway restart
|
|
57
58
|
openclaw plugins list
|
|
58
59
|
```
|
|
59
60
|
|
|
61
|
+
> If you see `plugins.allow is empty; discovered non-bundled plugins may auto-load`, run `openclaw plugins enable recipes` to explicitly allowlist the plugin and suppress the warning.
|
|
62
|
+
|
|
60
63
|
> **Note:** The OpenClaw plugin installer enforces `pluginApi` version checks. If you see a version mismatch error, use the npm install method below instead.
|
|
61
64
|
|
|
65
|
+
> During install you may see: `Plugin "recipes" has 2 suspicious code pattern(s)`. This is expected — ClawRecipes reads API keys from your OpenClaw config and passes them to media generation scripts (e.g., DALL-E, Kling). This is required for workflow execution and is not a security concern.
|
|
66
|
+
|
|
62
67
|
**npm install**
|
|
63
68
|
|
|
64
69
|
```bash
|
|
@@ -183,8 +188,28 @@ openclaw recipes workflows resume \
|
|
|
183
188
|
--run-id <runId>
|
|
184
189
|
```
|
|
185
190
|
|
|
191
|
+
### Bundled workflow examples
|
|
192
|
+
|
|
193
|
+
Four ready-to-run workflow examples ship under
|
|
194
|
+
[`examples/workflows/`](examples/workflows/):
|
|
195
|
+
|
|
196
|
+
- [`marketing-cadence-v1`](examples/workflows/marketing-cadence-v1/) — text-only marketing cadence
|
|
197
|
+
- [`marketing-image-generation-handoff`](examples/workflows/marketing-image-generation-handoff/) — image generation + handoff to social publish
|
|
198
|
+
- [`marketing-video-generation-handoff`](examples/workflows/marketing-video-generation-handoff/) — video generation cadence
|
|
199
|
+
- [`social-media-publish`](examples/workflows/social-media-publish/) — handoff target that publishes via kitchen-plugin-marketing + Postiz
|
|
200
|
+
|
|
201
|
+
Each example directory contains the workflow JSON, a `cron-jobs.example.json`,
|
|
202
|
+
an `install-crons.sh` helper, and a per-example README with prerequisites
|
|
203
|
+
and install steps. See [docs/WORKFLOW_EXAMPLES_BUNDLED.md](docs/WORKFLOW_EXAMPLES_BUNDLED.md)
|
|
204
|
+
for the catalog, required cron jobs, and a recommendation on
|
|
205
|
+
`agents.defaults.maxConcurrent` sizing.
|
|
206
|
+
|
|
186
207
|
See also:
|
|
187
208
|
- [docs/WORKFLOW_RUNS_FILE_FIRST.md](docs/WORKFLOW_RUNS_FILE_FIRST.md)
|
|
209
|
+
- [docs/WORKFLOW_NODES.md](docs/WORKFLOW_NODES.md)
|
|
210
|
+
- [docs/WORKFLOW_APPROVALS.md](docs/WORKFLOW_APPROVALS.md)
|
|
211
|
+
- [docs/WORKFLOW_EXAMPLES.md](docs/WORKFLOW_EXAMPLES.md) — small, hand-rolled pattern snippets
|
|
212
|
+
- [docs/WORKFLOW_EXAMPLES_BUNDLED.md](docs/WORKFLOW_EXAMPLES_BUNDLED.md) — full bundled examples + cron/concurrency guide
|
|
188
213
|
- [docs/OUTBOUND_POSTING.md](docs/OUTBOUND_POSTING.md)
|
|
189
214
|
|
|
190
215
|
---
|
package/docs/BUNDLED_RECIPES.md
CHANGED
|
@@ -6,6 +6,11 @@ ClawRecipes ships with bundled recipes in:
|
|
|
6
6
|
recipes/default/
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
+
> **Looking for bundled workflow examples** (not team recipes)? See
|
|
10
|
+
> [`WORKFLOW_EXAMPLES_BUNDLED.md`](./WORKFLOW_EXAMPLES_BUNDLED.md) and the
|
|
11
|
+
> [`examples/workflows/`](../examples/workflows/) directory for four
|
|
12
|
+
> ready-to-run workflow definitions with matching cron-install scripts.
|
|
13
|
+
|
|
9
14
|
You can browse them with:
|
|
10
15
|
|
|
11
16
|
```bash
|
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
# Workflow examples
|
|
2
2
|
|
|
3
|
-
This document gives you copyable workflow patterns for ClawRecipes
|
|
3
|
+
This document gives you copyable workflow **patterns** for ClawRecipes —
|
|
4
|
+
small snippets that illustrate individual node kinds and graph shapes.
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
> **Looking for full, production-style workflows?**
|
|
7
|
+
> See [`WORKFLOW_EXAMPLES_BUNDLED.md`](./WORKFLOW_EXAMPLES_BUNDLED.md) and
|
|
8
|
+
> [`examples/workflows/`](../examples/workflows/). Those are four end-to-end
|
|
9
|
+
> workflows (marketing cadence variants + social publish) complete with
|
|
10
|
+
> matching cron jobs and install scripts, ready to drop into a fresh team.
|
|
11
|
+
|
|
12
|
+
Use this doc together with:
|
|
6
13
|
- [WORKFLOW_RUNS_FILE_FIRST.md](WORKFLOW_RUNS_FILE_FIRST.md) — concepts, node kinds, triggers, runs, edges
|
|
14
|
+
- [WORKFLOW_NODES.md](WORKFLOW_NODES.md) — reference for every node type
|
|
15
|
+
- [WORKFLOW_EXAMPLES_BUNDLED.md](WORKFLOW_EXAMPLES_BUNDLED.md) — full bundled examples + cron + concurrency guide
|
|
7
16
|
- [OUTBOUND_POSTING.md](OUTBOUND_POSTING.md) — publishing/posting setup
|
|
8
17
|
|
|
9
18
|
---
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
# Bundled workflow examples
|
|
2
|
+
|
|
3
|
+
ClawRecipes ships four ready-to-run workflow examples under
|
|
4
|
+
[`examples/workflows/`](../examples/workflows/). Each example is a copy of a
|
|
5
|
+
workflow currently running in production, with all team-specific ids,
|
|
6
|
+
credentials, and secrets replaced by placeholders. You can drop any of them
|
|
7
|
+
into a freshly scaffolded team, install the matching crons, and run it
|
|
8
|
+
end-to-end.
|
|
9
|
+
|
|
10
|
+
Unlike the hand-rolled snippets in [`WORKFLOW_EXAMPLES.md`](./WORKFLOW_EXAMPLES.md),
|
|
11
|
+
these are complete, multi-node, multi-agent workflows. Use this doc if you
|
|
12
|
+
want to:
|
|
13
|
+
|
|
14
|
+
- See what a full production cadence looks like (research → draft → QC →
|
|
15
|
+
approve → post, with optional image/video generation and social-team
|
|
16
|
+
handoff)
|
|
17
|
+
- Bootstrap your own team's workflows by copying and adapting one
|
|
18
|
+
- Understand the cron setup a real workflow needs
|
|
19
|
+
|
|
20
|
+
## The four examples at a glance
|
|
21
|
+
|
|
22
|
+
| Example | Media | Pattern | Agents used |
|
|
23
|
+
|---|---|---|---|
|
|
24
|
+
| [`marketing-cadence-v1`](../examples/workflows/marketing-cadence-v1/) | none | Self-posting (text only) | analyst, copywriter, compliance, lead |
|
|
25
|
+
| [`marketing-image-generation-handoff`](../examples/workflows/marketing-image-generation-handoff/) | image | **Handoff** to social-team workflow | analyst, copywriter, **designer**, compliance, lead |
|
|
26
|
+
| [`marketing-video-generation-handoff`](../examples/workflows/marketing-video-generation-handoff/) | video | Self-posting (see note in README) | analyst, copywriter, **designer**, compliance, lead |
|
|
27
|
+
| [`social-media-publish`](../examples/workflows/social-media-publish/) | passthrough | Handoff target — publishes via kitchen-plugin-marketing + Postiz | social-team-lead |
|
|
28
|
+
|
|
29
|
+
Each example directory contains:
|
|
30
|
+
|
|
31
|
+
- `*.workflow.json` — the workflow definition, ready to copy into a team's
|
|
32
|
+
`shared-context/workflows/` directory
|
|
33
|
+
- `cron-jobs.example.json` — the cron jobs required for the workflow, in a
|
|
34
|
+
format suitable for `openclaw cron import --from-file` bulk import
|
|
35
|
+
- `install-crons.sh` — the same cron jobs as an idempotent shell script
|
|
36
|
+
using individual `openclaw cron add` calls
|
|
37
|
+
- `README.md` — per-example prerequisites, install steps, gotchas, and
|
|
38
|
+
configuration points
|
|
39
|
+
|
|
40
|
+
## Quick start
|
|
41
|
+
|
|
42
|
+
### 1. Scaffold the teams
|
|
43
|
+
|
|
44
|
+
The marketing examples expect `teamId=marketing-team`; the social example
|
|
45
|
+
expects `teamId=social-team`. ClawRecipes' built-in recipes create all the
|
|
46
|
+
required agents:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
openclaw recipes scaffold --recipe marketing-team --team-id marketing-team
|
|
50
|
+
openclaw recipes scaffold --recipe social-team --team-id social-team
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
If you want different team ids, edit the relevant fields in the workflow
|
|
54
|
+
JSON (all `*-team-*` agentId references) and set `TEAM_ID` before running
|
|
55
|
+
`install-crons.sh`.
|
|
56
|
+
|
|
57
|
+
### 2. Copy a workflow into the team's workspace
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
cp examples/workflows/marketing-image-generation-handoff/marketing-image-generation-handoff.workflow.json \
|
|
61
|
+
~/.openclaw/workspace-marketing-team/shared-context/workflows/
|
|
62
|
+
|
|
63
|
+
cp examples/workflows/social-media-publish/social-media-publish.workflow.json \
|
|
64
|
+
~/.openclaw/workspace-social-team/shared-context/workflows/
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### 3. Install the required crons
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
bash examples/workflows/marketing-image-generation-handoff/install-crons.sh
|
|
71
|
+
bash examples/workflows/social-media-publish/install-crons.sh
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Or bulk-import via JSON:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
openclaw cron import --from-file examples/workflows/marketing-image-generation-handoff/cron-jobs.example.json
|
|
78
|
+
openclaw cron import --from-file examples/workflows/social-media-publish/cron-jobs.example.json
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### 4. Tune placeholders
|
|
82
|
+
|
|
83
|
+
Before running, **edit the workflow JSON** to replace the placeholders left
|
|
84
|
+
in the examples:
|
|
85
|
+
|
|
86
|
+
- `<your-telegram-user-id>` — the `approvalTarget` in the marketing examples'
|
|
87
|
+
`meta` block and on the `approval` node
|
|
88
|
+
- `<your-postiz-integration-id>` — the `integrationIds` in
|
|
89
|
+
`marketing-image-generation-handoff`'s handoff variable mapping
|
|
90
|
+
|
|
91
|
+
And (for `social-media-publish`) set the gateway env vars:
|
|
92
|
+
|
|
93
|
+
- `CK_BASE_URL` — a URL the gateway process can use to reach its own Kitchen
|
|
94
|
+
HTTP endpoints (e.g. `http://127.0.0.1:7777`)
|
|
95
|
+
- `CK_AUTH` — `<user>:<password>` for Kitchen basic-auth
|
|
96
|
+
|
|
97
|
+
### 5. Trigger a run
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
openclaw recipes workflows enqueue \
|
|
101
|
+
--team-id marketing-team \
|
|
102
|
+
--workflow-id marketing-image-generation-handoff
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Or use the ClawKitchen UI: **teams → workflows → Run**.
|
|
106
|
+
|
|
107
|
+
## Cron requirements
|
|
108
|
+
|
|
109
|
+
Every workflow requires **two classes of crons** running periodically:
|
|
110
|
+
|
|
111
|
+
### Runner-tick (1 per team)
|
|
112
|
+
|
|
113
|
+
`workflow-runner-tick:<teamId>` — claims runs in `queued` status, auto-skips
|
|
114
|
+
`start`/`end` nodes, and enqueues the first runnable node onto its owning
|
|
115
|
+
agent's worker queue. Without this, new runs never leave `queued`.
|
|
116
|
+
|
|
117
|
+
Default schedule in bundled example cron files: `*/5 * * * *` (every 5 minutes).
|
|
118
|
+
Default timeout: 900 seconds.
|
|
119
|
+
|
|
120
|
+
### Worker-tick (1 per agent referenced in any workflow)
|
|
121
|
+
|
|
122
|
+
`workflow-worker:<teamId>:<agentId>` — drains that agent's queue and
|
|
123
|
+
executes the node work. Each tick:
|
|
124
|
+
|
|
125
|
+
1. Dequeues up to 5 tasks from the queue
|
|
126
|
+
2. For each task: acquires a per-node lock, loads the run file, resolves
|
|
127
|
+
the node config, executes the node (`llm`, `tool`, `media-*`,
|
|
128
|
+
`human_approval`, `handoff`), writes results, and enqueues the next
|
|
129
|
+
runnable node
|
|
130
|
+
|
|
131
|
+
Default schedule: `*/5 * * * *`. Default timeout: 120 seconds per session.
|
|
132
|
+
|
|
133
|
+
### How many crons?
|
|
134
|
+
|
|
135
|
+
Rule of thumb: **1 runner + N workers per team**, where N is the number of
|
|
136
|
+
distinct `agentId` values referenced by any node across all workflows
|
|
137
|
+
installed in that team. Worker crons are shared between workflows — if two
|
|
138
|
+
workflows both use `marketing-team-lead`, you still only need one lead
|
|
139
|
+
worker.
|
|
140
|
+
|
|
141
|
+
The four bundled examples need:
|
|
142
|
+
|
|
143
|
+
| Team | Workflows installed | Runners | Workers | Total |
|
|
144
|
+
|---|---|---|---|---|
|
|
145
|
+
| `marketing-team` | v1 only | 1 | 4 (analyst, copywriter, compliance, lead) | 5 |
|
|
146
|
+
| `marketing-team` | v1 + image-handoff | 1 | 5 (+designer) | 6 |
|
|
147
|
+
| `marketing-team` | v1 + image-handoff + video-handoff | 1 | 5 | 6 |
|
|
148
|
+
| `social-team` | social-media-publish | 1 | 1 (lead) | 2 |
|
|
149
|
+
|
|
150
|
+
## Gateway concurrency — raise `agents.defaults.maxConcurrent`
|
|
151
|
+
|
|
152
|
+
> ⚠️ **This is the single most common cause of workflow runs appearing
|
|
153
|
+
> "stuck"** on first install.
|
|
154
|
+
|
|
155
|
+
Every workflow cron fires an isolated agent session through the gateway's
|
|
156
|
+
agent system. That session invokes the `openclaw recipes workflows
|
|
157
|
+
(runner|worker)-tick` CLI command via the `exec` tool, waits for it, then
|
|
158
|
+
responds `NO_REPLY`. Each of those in-flight sessions counts against the
|
|
159
|
+
gateway's `agents.defaults.maxConcurrent` limit.
|
|
160
|
+
|
|
161
|
+
The default is:
|
|
162
|
+
|
|
163
|
+
```json
|
|
164
|
+
"agents": {
|
|
165
|
+
"defaults": {
|
|
166
|
+
"maxConcurrent": 8,
|
|
167
|
+
"subagents": { "maxConcurrent": 8 }
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
A minimal marketing+social setup (image-handoff + social-publish) already
|
|
173
|
+
runs **1 marketing-runner + 5 marketing-workers + 1 social-runner +
|
|
174
|
+
1 social-worker = 8 workflow crons**. Add any memory/heartbeat crons on top
|
|
175
|
+
and you are over budget. When that happens:
|
|
176
|
+
|
|
177
|
+
- Crons back up into a scheduler queue and fire 15–30 minutes later than
|
|
178
|
+
intended
|
|
179
|
+
- Workflow runs appear to stall mid-pipeline
|
|
180
|
+
- Session failures cascade because nothing can retry while the queue is
|
|
181
|
+
saturated
|
|
182
|
+
|
|
183
|
+
### Recommended setting for example-backed setups
|
|
184
|
+
|
|
185
|
+
Edit `~/.openclaw/openclaw.json`:
|
|
186
|
+
|
|
187
|
+
```json
|
|
188
|
+
"agents": {
|
|
189
|
+
"defaults": {
|
|
190
|
+
"maxConcurrent": 24,
|
|
191
|
+
"subagents": { "maxConcurrent": 24 }
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Then restart the gateway:
|
|
197
|
+
|
|
198
|
+
```bash
|
|
199
|
+
openclaw gateway restart
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Adjust higher if you run many workflows or many teams. A reasonable formula:
|
|
203
|
+
|
|
204
|
+
> `maxConcurrent ≥ (total workflow crons across all teams) + (other crons firing on overlapping schedules) + 4 headroom`
|
|
205
|
+
|
|
206
|
+
## Model selection
|
|
207
|
+
|
|
208
|
+
Bundled cron payloads **do not hard-code a model**. When the gateway runs
|
|
209
|
+
a tick session, it falls back to its default model (typically configured
|
|
210
|
+
under `agents.defaults.model` in `openclaw.json`).
|
|
211
|
+
|
|
212
|
+
You can pin a specific model in three ways:
|
|
213
|
+
|
|
214
|
+
1. **Shell install**: `MODEL=openai/gpt-5.4 bash install-crons.sh`
|
|
215
|
+
|
|
216
|
+
2. **JSON import**: uncomment the `model` field in the example
|
|
217
|
+
`cron-jobs.example.json` before importing
|
|
218
|
+
|
|
219
|
+
3. **After install**: `openclaw cron edit <cron-id> --model openai/gpt-5.4`
|
|
220
|
+
|
|
221
|
+
Worker-tick sessions don't need smart models — they just run one
|
|
222
|
+
predictable shell command and respond `NO_REPLY`. A cheap/fast model is
|
|
223
|
+
ideal. Runner-ticks similarly just invoke a CLI command.
|
|
224
|
+
|
|
225
|
+
### ClawKitchen default-cron model override
|
|
226
|
+
|
|
227
|
+
If you use ClawKitchen's **"Install worker cron(s)"** button in the workflow
|
|
228
|
+
editor instead of the bundled example files, you can set a model override
|
|
229
|
+
globally via env var on the gateway:
|
|
230
|
+
|
|
231
|
+
```json
|
|
232
|
+
"env": {
|
|
233
|
+
"vars": {
|
|
234
|
+
"KITCHEN_WORKFLOW_CRON_MODEL": "openai/gpt-5.4"
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
When set, every workflow cron that Kitchen installs will carry that
|
|
240
|
+
`--model` flag. Leave unset to fall back to the gateway default.
|
|
241
|
+
|
|
242
|
+
You can also override the schedule:
|
|
243
|
+
|
|
244
|
+
```json
|
|
245
|
+
"env": {
|
|
246
|
+
"vars": {
|
|
247
|
+
"KITCHEN_WORKFLOW_CRON_SCHEDULE": "*/10 * * * *"
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
## Handoff chain
|
|
253
|
+
|
|
254
|
+
`marketing-image-generation-handoff` contains a `handoff` node that targets
|
|
255
|
+
`social-media-publish`. To use the handoff end-to-end:
|
|
256
|
+
|
|
257
|
+
1. Install both workflows in their respective teams (`marketing-team` and
|
|
258
|
+
`social-team`)
|
|
259
|
+
2. Ensure `social-media-publish`'s runtime prerequisites are satisfied
|
|
260
|
+
(kitchen-plugin-marketing installed, Postiz account configured, gateway
|
|
261
|
+
env vars set — see the example's README)
|
|
262
|
+
3. Trigger a run of `marketing-image-generation-handoff`
|
|
263
|
+
|
|
264
|
+
When the approval step clears, the `handoff_social_instagram` node fires
|
|
265
|
+
and enqueues a new run in `social-team`. You'll see it in that team's runs
|
|
266
|
+
page (or via `openclaw recipes workflows list-runs --team-id social-team`).
|
|
267
|
+
|
|
268
|
+
The handoff is **fire-and-forget** by default — the marketing run completes
|
|
269
|
+
without waiting for the social publish to succeed. Check the target team's
|
|
270
|
+
runs page for downstream status.
|
|
271
|
+
|
|
272
|
+
## Troubleshooting
|
|
273
|
+
|
|
274
|
+
### Runs start but nothing happens past the first node
|
|
275
|
+
Check that the **worker-tick cron for the agent owning that node** is
|
|
276
|
+
installed and enabled. Each agent referenced in the workflow needs its own
|
|
277
|
+
worker-tick.
|
|
278
|
+
|
|
279
|
+
### Worker-tick returns `skipped_locked` on every task
|
|
280
|
+
A previous worker session crashed mid-execution and left a lock file in
|
|
281
|
+
`workflow-runs/<runId>/locks/<nodeId>.lock`. Locks expire after ~12 minutes.
|
|
282
|
+
Either wait, or manually delete the stale lock file.
|
|
283
|
+
|
|
284
|
+
### `fetch failed` during `store_and_publish` (social-media-publish)
|
|
285
|
+
The gateway process can't reach its own Kitchen HTTP endpoint via the URL
|
|
286
|
+
in the request's `host` header. Set `CK_BASE_URL` in the gateway env to a
|
|
287
|
+
URL that IS reachable from inside the gateway process (e.g.
|
|
288
|
+
`http://127.0.0.1:7777` if Kitchen binds loopback).
|
|
289
|
+
|
|
290
|
+
### Runs hang at `waiting_workers` forever with no new events
|
|
291
|
+
Check `openclaw cron list --json` for your workflow crons — are they
|
|
292
|
+
firing? If not, the gateway cron scheduler is probably saturated. Bump
|
|
293
|
+
`agents.defaults.maxConcurrent` (see the Gateway concurrency section
|
|
294
|
+
above) and restart.
|
|
295
|
+
|
|
296
|
+
### Node locks from crashed workers pile up duplicate queue entries
|
|
297
|
+
Fixed in ClawRecipes 0.4.50+ via a `hasPendingTaskFor` guard in the
|
|
298
|
+
worker-tick. If you're on an older version, upgrade.
|
|
299
|
+
|
|
300
|
+
## See also
|
|
301
|
+
|
|
302
|
+
- [`examples/workflows/README.md`](../examples/workflows/README.md) — top-level
|
|
303
|
+
index with install commands
|
|
304
|
+
- [`WORKFLOW_RUNS_FILE_FIRST.md`](./WORKFLOW_RUNS_FILE_FIRST.md) — how the
|
|
305
|
+
runner/worker split works internally
|
|
306
|
+
- [`WORKFLOW_NODES.md`](./WORKFLOW_NODES.md) — reference for all node types
|
|
307
|
+
used in these examples
|
|
308
|
+
- [`WORKFLOW_APPROVALS.md`](./WORKFLOW_APPROVALS.md) — how the `human_approval`
|
|
309
|
+
node integrates with Telegram, Slack, and the web UI
|
|
310
|
+
- [`MEDIA_GENERATION.md`](./MEDIA_GENERATION.md) — provider setup for
|
|
311
|
+
`media-image` / `media-video` nodes
|
|
312
|
+
- [`WORKFLOW_EXAMPLES.md`](./WORKFLOW_EXAMPLES.md) — hand-rolled smaller
|
|
313
|
+
workflow examples showing individual node types
|
package/index.ts
CHANGED
|
@@ -46,7 +46,7 @@ import {
|
|
|
46
46
|
import { handleScaffold, scaffoldAgentFromRecipe } from "./src/handlers/scaffold";
|
|
47
47
|
import { handleAddRoleToTeam } from "./src/handlers/team-add-role";
|
|
48
48
|
import { reconcileRecipeCronJobs } from "./src/handlers/cron";
|
|
49
|
-
import { handleWorkflowsApprove, handleWorkflowsPollApprovals, handleWorkflowsResume, handleWorkflowsRun, handleWorkflowsRunnerOnce, handleWorkflowsRunnerTick, handleWorkflowsWorkerTick } from "./src/handlers/workflows";
|
|
49
|
+
import { handleWorkflowsApprove, handleWorkflowsCleanupQueues, handleWorkflowsPollApprovals, handleWorkflowsResume, handleWorkflowsRun, handleWorkflowsRunnerOnce, handleWorkflowsRunnerTick, handleWorkflowsWorkerTick } from "./src/handlers/workflows";
|
|
50
50
|
import { handleMediaDriversList } from "./src/handlers/media-drivers";
|
|
51
51
|
import { listRecipeFiles, loadRecipeById, workspacePath } from "./src/lib/recipes";
|
|
52
52
|
import {
|
|
@@ -750,6 +750,17 @@ workflows
|
|
|
750
750
|
console.log(JSON.stringify(res, null, 2));
|
|
751
751
|
});
|
|
752
752
|
|
|
753
|
+
workflows
|
|
754
|
+
.command("cleanup-queues")
|
|
755
|
+
.description("Remove stale queue tasks for runs that are completed, errored, or deleted")
|
|
756
|
+
.requiredOption("--team-id <teamId>", "Team id (workspace-<teamId>)")
|
|
757
|
+
.action(async (options: { teamId?: string }) => {
|
|
758
|
+
const res = await handleWorkflowsCleanupQueues(api, {
|
|
759
|
+
teamId: String(options.teamId ?? ''),
|
|
760
|
+
});
|
|
761
|
+
console.log(JSON.stringify(res, null, 2));
|
|
762
|
+
});
|
|
763
|
+
|
|
753
764
|
cmd
|
|
754
765
|
.command("move-ticket")
|
|
755
766
|
.description("Move a ticket between backlog/in-progress/testing/done (updates Status: line)")
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jiggai/recipes",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.51",
|
|
4
4
|
"description": "ClawRecipes plugin for OpenClaw (markdown recipes -> scaffold agents/teams)",
|
|
5
5
|
"main": "index.ts",
|
|
6
6
|
"type": "commonjs",
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"./index.ts"
|
|
10
10
|
],
|
|
11
11
|
"compat": {
|
|
12
|
-
"pluginApi": "
|
|
12
|
+
"pluginApi": ">=1.0.0",
|
|
13
13
|
"pluginApiRange": ">=2026.3"
|
|
14
14
|
},
|
|
15
15
|
"build": {
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { OpenClawPluginApi } from 'openclaw/plugin-sdk';
|
|
2
2
|
import { approveWorkflowRun, enqueueWorkflowRun, pollWorkflowApprovals, resumeWorkflowRun, runWorkflowRunnerOnce, runWorkflowRunnerTick, runWorkflowWorkerTick } from '../lib/workflows/workflow-runner';
|
|
3
|
+
import { cleanupQueues } from '../lib/workflows/workflow-queue';
|
|
4
|
+
import { resolveTeamDir } from '../lib/workspace';
|
|
3
5
|
|
|
4
6
|
export async function handleWorkflowsRun(api: OpenClawPluginApi, opts: {
|
|
5
7
|
teamId: string;
|
|
@@ -79,3 +81,11 @@ export async function handleWorkflowsPollApprovals(api: OpenClawPluginApi, opts:
|
|
|
79
81
|
if (!opts.teamId) throw new Error('--team-id is required');
|
|
80
82
|
return pollWorkflowApprovals(api, { teamId: opts.teamId, limit: opts.limit });
|
|
81
83
|
}
|
|
84
|
+
|
|
85
|
+
export async function handleWorkflowsCleanupQueues(api: OpenClawPluginApi, opts: {
|
|
86
|
+
teamId: string;
|
|
87
|
+
}) {
|
|
88
|
+
if (!opts.teamId) throw new Error('--team-id is required');
|
|
89
|
+
const teamDir = resolveTeamDir(api, opts.teamId);
|
|
90
|
+
return cleanupQueues(teamDir);
|
|
91
|
+
}
|
|
@@ -84,10 +84,58 @@ export async function enqueueTask(teamDir: string, agentId: string, task: Omit<Q
|
|
|
84
84
|
...task,
|
|
85
85
|
};
|
|
86
86
|
const p = queuePathFor(teamDir, agentId);
|
|
87
|
+
|
|
88
|
+
// If the cursor is at or beyond the current file size, the file was
|
|
89
|
+
// truncated/rotated since the cursor was written. Reset to 0 so the
|
|
90
|
+
// worker will see this new task after it is appended.
|
|
91
|
+
const st = await loadState(teamDir, agentId);
|
|
92
|
+
let fileSize = 0;
|
|
93
|
+
try {
|
|
94
|
+
fileSize = (await fs.stat(p)).size;
|
|
95
|
+
} catch { /* file doesn't exist yet — fileSize stays 0 */ }
|
|
96
|
+
if (st.offsetBytes > 0 && st.offsetBytes >= fileSize) {
|
|
97
|
+
await writeState(teamDir, agentId, { offsetBytes: 0, updatedAt: new Date().toISOString() });
|
|
98
|
+
}
|
|
99
|
+
|
|
87
100
|
await fs.appendFile(p, JSON.stringify(entry) + '\n', 'utf8');
|
|
88
101
|
return { ok: true as const, path: p, task: entry };
|
|
89
102
|
}
|
|
90
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Check whether a pending task matching {runId, nodeId} already exists in
|
|
106
|
+
* the queue past the current cursor. Used to avoid enqueueing duplicates
|
|
107
|
+
* when a re-queue would otherwise race with an in-flight tick.
|
|
108
|
+
*/
|
|
109
|
+
export async function hasPendingTaskFor(
|
|
110
|
+
teamDir: string,
|
|
111
|
+
agentId: string,
|
|
112
|
+
match: { runId: string; nodeId: string }
|
|
113
|
+
): Promise<boolean> {
|
|
114
|
+
const qPath = queuePathFor(teamDir, agentId);
|
|
115
|
+
if (!(await fileExists(qPath))) return false;
|
|
116
|
+
|
|
117
|
+
const st = await loadState(teamDir, agentId);
|
|
118
|
+
let raw: string;
|
|
119
|
+
try {
|
|
120
|
+
raw = await fs.readFile(qPath, 'utf8');
|
|
121
|
+
} catch { // intentional: best-effort read
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Only consider lines at/after the cursor.
|
|
126
|
+
const tail = raw.slice(st.offsetBytes);
|
|
127
|
+
for (const line of tail.split('\n')) {
|
|
128
|
+
if (!line.trim()) continue;
|
|
129
|
+
try {
|
|
130
|
+
const t = JSON.parse(line) as QueueTask;
|
|
131
|
+
if (t && t.runId === match.runId && t.nodeId === match.nodeId) return true;
|
|
132
|
+
} catch { // intentional: skip malformed queue line
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
|
|
91
139
|
type QueueState = {
|
|
92
140
|
offsetBytes: number;
|
|
93
141
|
updatedAt: string;
|
|
@@ -123,10 +171,16 @@ export async function readNextTasks(teamDir: string, agentId: string, opts?: { l
|
|
|
123
171
|
return { ok: true as const, tasks: [] as QueueTask[], consumed: 0, message: 'Queue file not present.' };
|
|
124
172
|
}
|
|
125
173
|
|
|
126
|
-
|
|
174
|
+
let st = await loadState(teamDir, agentId);
|
|
127
175
|
const fh = await fs.open(qPath, 'r');
|
|
128
176
|
try {
|
|
129
177
|
const stat = await fh.stat();
|
|
178
|
+
// If the queue file was truncated/rotated, the cursor may point past EOF.
|
|
179
|
+
// Reset to 0 so we re-scan from the beginning instead of silently skipping tasks.
|
|
180
|
+
if (st.offsetBytes > stat.size) {
|
|
181
|
+
st = { offsetBytes: 0, updatedAt: new Date().toISOString() };
|
|
182
|
+
await writeState(teamDir, agentId, st);
|
|
183
|
+
}
|
|
130
184
|
if (st.offsetBytes >= stat.size) {
|
|
131
185
|
return { ok: true as const, tasks: [] as QueueTask[], consumed: 0, message: 'No new tasks.' };
|
|
132
186
|
}
|
|
@@ -173,7 +227,7 @@ export async function dequeueNextTask(
|
|
|
173
227
|
return { ok: true as const, task: null as DequeuedTask | null, message: 'Queue file not present.' };
|
|
174
228
|
}
|
|
175
229
|
|
|
176
|
-
|
|
230
|
+
let st = await loadState(teamDir, agentId);
|
|
177
231
|
const workerId = String(opts?.workerId ?? `worker:${process.pid}`);
|
|
178
232
|
const leaseSeconds = typeof opts?.leaseSeconds === 'number' ? opts.leaseSeconds : undefined;
|
|
179
233
|
|
|
@@ -219,6 +273,12 @@ export async function dequeueNextTask(
|
|
|
219
273
|
const fh = await fs.open(qPath, 'r');
|
|
220
274
|
try {
|
|
221
275
|
const stat = await fh.stat();
|
|
276
|
+
// If the queue file was truncated/rotated, the cursor may point past EOF.
|
|
277
|
+
// Reset to 0 so we re-scan from the beginning instead of silently skipping tasks.
|
|
278
|
+
if (st.offsetBytes > stat.size) {
|
|
279
|
+
st = { offsetBytes: 0, updatedAt: new Date().toISOString() };
|
|
280
|
+
await writeState(teamDir, agentId, st);
|
|
281
|
+
}
|
|
222
282
|
if (st.offsetBytes < stat.size) {
|
|
223
283
|
const toRead = Math.min(stat.size - st.offsetBytes, 256 * 1024);
|
|
224
284
|
const buf = Buffer.alloc(toRead);
|
|
@@ -336,3 +396,77 @@ export async function compactQueue(teamDir: string, agentId: string, opts?: { mi
|
|
|
336
396
|
|
|
337
397
|
return { ok: true as const, compacted: true, removedBytes: st.offsetBytes, remainingBytes: remaining.length };
|
|
338
398
|
}
|
|
399
|
+
|
|
400
|
+
const TERMINAL_STATUSES = new Set(['completed', 'error', 'canceled', 'done', 'failed']);
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Remove queue tasks whose runs no longer exist or are in a terminal state.
|
|
404
|
+
* Returns summary of what was cleaned.
|
|
405
|
+
*/
|
|
406
|
+
export async function cleanupQueues(teamDir: string): Promise<{
|
|
407
|
+
ok: true;
|
|
408
|
+
queuesProcessed: number;
|
|
409
|
+
tasksRemoved: number;
|
|
410
|
+
tasksKept: number;
|
|
411
|
+
}> {
|
|
412
|
+
const qDir = queueDir(teamDir);
|
|
413
|
+
const runsDir = path.join(teamDir, 'shared-context', 'workflow-runs');
|
|
414
|
+
|
|
415
|
+
let files: string[];
|
|
416
|
+
try {
|
|
417
|
+
files = (await fs.readdir(qDir)).filter((f) => f.endsWith('.jsonl'));
|
|
418
|
+
} catch {
|
|
419
|
+
return { ok: true, queuesProcessed: 0, tasksRemoved: 0, tasksKept: 0 };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
let totalRemoved = 0;
|
|
423
|
+
let totalKept = 0;
|
|
424
|
+
|
|
425
|
+
for (const file of files) {
|
|
426
|
+
const qPath = path.join(qDir, file);
|
|
427
|
+
let raw: string;
|
|
428
|
+
try {
|
|
429
|
+
raw = await fs.readFile(qPath, 'utf8');
|
|
430
|
+
} catch { continue; }
|
|
431
|
+
|
|
432
|
+
const lines = raw.split('\n').filter((l) => l.trim());
|
|
433
|
+
if (!lines.length) continue;
|
|
434
|
+
|
|
435
|
+
const kept: string[] = [];
|
|
436
|
+
for (const line of lines) {
|
|
437
|
+
try {
|
|
438
|
+
const task = JSON.parse(line) as QueueTask;
|
|
439
|
+
const runPath = path.join(runsDir, task.runId, 'run.json');
|
|
440
|
+
|
|
441
|
+
let remove = false;
|
|
442
|
+
try {
|
|
443
|
+
const runRaw = await fs.readFile(runPath, 'utf8');
|
|
444
|
+
const run = JSON.parse(runRaw) as { status?: string };
|
|
445
|
+
if (TERMINAL_STATUSES.has(run.status ?? '')) remove = true;
|
|
446
|
+
} catch {
|
|
447
|
+
// Run file doesn't exist — orphaned task
|
|
448
|
+
remove = true;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (remove) {
|
|
452
|
+
totalRemoved++;
|
|
453
|
+
} else {
|
|
454
|
+
kept.push(line);
|
|
455
|
+
totalKept++;
|
|
456
|
+
}
|
|
457
|
+
} catch {
|
|
458
|
+
// Malformed line — discard
|
|
459
|
+
totalRemoved++;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (kept.length !== lines.length) {
|
|
464
|
+
await fs.writeFile(qPath, kept.length ? kept.join('\n') + '\n' : '', 'utf8');
|
|
465
|
+
// Reset cursor state since we rewrote the file
|
|
466
|
+
const agentId = file.replace(/\.jsonl$/, '');
|
|
467
|
+
await writeState(teamDir, agentId, { offsetBytes: 0, updatedAt: new Date().toISOString() });
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
return { ok: true, queuesProcessed: files.length, tasksRemoved: totalRemoved, tasksKept: totalKept };
|
|
472
|
+
}
|
|
@@ -340,6 +340,10 @@ export async function loadRunFile(teamDir: string, runsDir: string, runId: strin
|
|
|
340
340
|
export async function writeRunFile(runPath: string, fn: (cur: RunLog) => RunLog) {
|
|
341
341
|
const raw = await readTextFile(runPath);
|
|
342
342
|
const cur = JSON.parse(raw) as RunLog;
|
|
343
|
-
const
|
|
343
|
+
const next0 = fn(cur);
|
|
344
|
+
const next = {
|
|
345
|
+
...next0,
|
|
346
|
+
updatedAt: new Date().toISOString(),
|
|
347
|
+
};
|
|
344
348
|
await fs.writeFile(runPath, JSON.stringify(next, null, 2), 'utf8');
|
|
345
349
|
}
|
|
@@ -9,7 +9,7 @@ import { resolveTeamDir } from '../workspace';
|
|
|
9
9
|
import { getDriver } from './media-drivers/registry';
|
|
10
10
|
import { GenericDriver } from './media-drivers/generic.driver';
|
|
11
11
|
import type { WorkflowLane, WorkflowNode, RunLog } from './workflow-types';
|
|
12
|
-
import { dequeueNextTask, enqueueTask, releaseTaskClaim, compactQueue } from './workflow-queue';
|
|
12
|
+
import { dequeueNextTask, enqueueTask, hasPendingTaskFor, releaseTaskClaim, compactQueue } from './workflow-queue';
|
|
13
13
|
import { loadPriorLlmInput, loadProposedPostTextFromPriorNode } from './workflow-node-output-readers';
|
|
14
14
|
import { readTextFile } from './workflow-runner-io';
|
|
15
15
|
import { resolveApprovalBindingTarget } from './workflow-node-executor';
|
|
@@ -552,7 +552,17 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
|
|
|
552
552
|
return Math.max(MIN_NODE_LOCK_TTL_MS, timeoutMs + LOCK_TTL_BUFFER_MS);
|
|
553
553
|
};
|
|
554
554
|
|
|
555
|
-
|
|
555
|
+
// We want to process up to `limit` ACTUAL executions per tick. Stale tasks
|
|
556
|
+
// (runs that are already past the dequeued node — e.g. after a terminal
|
|
557
|
+
// run leaves dead entries in a shared agent queue) don't do any real work,
|
|
558
|
+
// so they shouldn't consume the execution budget; otherwise a backlog of
|
|
559
|
+
// stale tasks can starve in-flight runs for several ticks.
|
|
560
|
+
//
|
|
561
|
+
// Cap the total number of dequeue attempts to keep a pathological queue
|
|
562
|
+
// from turning a single tick into an unbounded scan.
|
|
563
|
+
let executedCount = 0;
|
|
564
|
+
const maxDequeues = Math.max(limit * 4, limit + 20);
|
|
565
|
+
for (let totalDequeues = 0; executedCount < limit && totalDequeues < maxDequeues; totalDequeues++) {
|
|
556
566
|
const dq = await dequeueNextTask(teamDir, agentId, { workerId, leaseSeconds: 120 });
|
|
557
567
|
if (!dq.ok || !dq.task) break;
|
|
558
568
|
|
|
@@ -562,6 +572,21 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
|
|
|
562
572
|
const lockDir = path.join(runDir, 'locks');
|
|
563
573
|
const lockPath = path.join(lockDir, `${task.nodeId}.lock`);
|
|
564
574
|
let lockHeld = false;
|
|
575
|
+
// Tracks whether this dequeue should count against the execution budget.
|
|
576
|
+
// Defaults to true; the stale-skip path below flips it to false so stale
|
|
577
|
+
// tasks (queue entries for nodes that already advanced past success)
|
|
578
|
+
// don't starve real work from the `limit` budget.
|
|
579
|
+
//
|
|
580
|
+
// NOTE: `skipped_locked` does NOT flip this flag. Lock contention means
|
|
581
|
+
// real work is pending (just held up by another worker or a ghost lock),
|
|
582
|
+
// and the skipped_locked branch re-enqueues the task to avoid losing it.
|
|
583
|
+
// Letting lock contention escape the budget here would amplify that
|
|
584
|
+
// re-enqueue within a single tick: each dequeue past the cursor would
|
|
585
|
+
// drop a new copy at the tail (hasPendingTaskFor can't see it yet), and
|
|
586
|
+
// the loop would fabricate up to `maxDequeues` duplicates for one stuck
|
|
587
|
+
// lock. Bounding lock contention by the normal limit keeps the queue
|
|
588
|
+
// growth to O(limit) per tick.
|
|
589
|
+
let countedTowardLimit = true;
|
|
565
590
|
|
|
566
591
|
try {
|
|
567
592
|
if (task.kind !== 'execute_node') continue;
|
|
@@ -622,17 +647,29 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
|
|
|
622
647
|
await fs.writeFile(lockPath, JSON.stringify(lockInfo, null, 2), { encoding: 'utf8', flag: 'wx' });
|
|
623
648
|
lockHeld = true;
|
|
624
649
|
} catch { // intentional: lock contention, skip task
|
|
650
|
+
// Counts against the budget — see note on `countedTowardLimit`.
|
|
625
651
|
results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'skipped_locked' });
|
|
626
652
|
continue;
|
|
627
653
|
}
|
|
628
654
|
} else {
|
|
629
|
-
//
|
|
630
|
-
|
|
631
|
-
|
|
655
|
+
// The lock is still live and held by another worker. The cursor has
|
|
656
|
+
// already advanced past this task, so under naive logic we'd lose it.
|
|
657
|
+
// Re-enqueue — but ONLY if no equivalent task is already pending.
|
|
658
|
+
// Otherwise repeated ticks against a stuck lock would pile up dozens
|
|
659
|
+
// of duplicates for the same {runId, nodeId}.
|
|
660
|
+
const alreadyPending = await hasPendingTaskFor(teamDir, agentId, {
|
|
632
661
|
runId: task.runId,
|
|
633
662
|
nodeId: task.nodeId,
|
|
634
|
-
kind: 'execute_node',
|
|
635
663
|
});
|
|
664
|
+
if (!alreadyPending) {
|
|
665
|
+
await enqueueTask(teamDir, agentId, {
|
|
666
|
+
teamId,
|
|
667
|
+
runId: task.runId,
|
|
668
|
+
nodeId: task.nodeId,
|
|
669
|
+
kind: 'execute_node',
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
// Counts against the budget — see note on `countedTowardLimit`.
|
|
636
673
|
results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'skipped_locked' });
|
|
637
674
|
continue;
|
|
638
675
|
}
|
|
@@ -678,6 +715,7 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
|
|
|
678
715
|
currentlyRunnableIdx === null ||
|
|
679
716
|
String(workflow.nodes[currentlyRunnableIdx]?.id ?? '') !== String(node.id)
|
|
680
717
|
) {
|
|
718
|
+
countedTowardLimit = false;
|
|
681
719
|
results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'skipped_stale' });
|
|
682
720
|
continue;
|
|
683
721
|
}
|
|
@@ -739,77 +777,8 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
|
|
|
739
777
|
await ensureDir(path.dirname(nodeOutputAbs));
|
|
740
778
|
|
|
741
779
|
const promptRaw = promptTemplateInline ? promptTemplateInline : await readTextFile(promptPathAbs);
|
|
742
|
-
|
|
743
|
-
// Build template variables (same as fs.write/fs.append)
|
|
744
|
-
const vars = {
|
|
745
|
-
date: new Date().toISOString(),
|
|
746
|
-
'run.id': runId,
|
|
747
|
-
'run.timestamp': runId,
|
|
748
|
-
'workflow.id': String(workflow.id ?? ''),
|
|
749
|
-
'workflow.name': String(workflow.name ?? workflow.id ?? workflowFile),
|
|
750
|
-
};
|
|
751
|
-
|
|
752
|
-
// Load node outputs and make them available as template variables
|
|
753
|
-
const { run: runSnap } = await loadRunFile(teamDir, runsDir, task.runId);
|
|
754
780
|
|
|
755
|
-
|
|
756
|
-
if (runSnap.triggerInput && typeof runSnap.triggerInput === 'object') {
|
|
757
|
-
for (const [key, value] of Object.entries(runSnap.triggerInput)) {
|
|
758
|
-
if (typeof value === 'string') {
|
|
759
|
-
vars[`trigger.${key}`] = value;
|
|
760
|
-
} else if (value !== null && value !== undefined) {
|
|
761
|
-
vars[`trigger.${key}`] = JSON.stringify(value);
|
|
762
|
-
}
|
|
763
|
-
}
|
|
764
|
-
}
|
|
765
|
-
for (const nr of (runSnap.nodeResults ?? [])) {
|
|
766
|
-
const nid = String((nr as Record<string, unknown>).nodeId ?? '');
|
|
767
|
-
const nrOutPath = String((nr as Record<string, unknown>).nodeOutputPath ?? '');
|
|
768
|
-
if (nid && nrOutPath) {
|
|
769
|
-
try {
|
|
770
|
-
const outAbs = path.resolve(teamDir, nrOutPath);
|
|
771
|
-
const outputContent = await fs.readFile(outAbs, 'utf8');
|
|
772
|
-
vars[`${nid}.output`] = outputContent;
|
|
773
|
-
|
|
774
|
-
// Parse JSON outputs and make fields accessible
|
|
775
|
-
try {
|
|
776
|
-
const parsed = JSON.parse(outputContent.trim());
|
|
777
|
-
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
778
|
-
for (const [key, value] of Object.entries(parsed)) {
|
|
779
|
-
if (typeof value === 'string') {
|
|
780
|
-
vars[`${nid}.${key}`] = value;
|
|
781
|
-
|
|
782
|
-
// Special handling for 'text' field - try to parse as nested JSON
|
|
783
|
-
if (key === 'text') {
|
|
784
|
-
try {
|
|
785
|
-
const nestedParsed = JSON.parse(value);
|
|
786
|
-
if (nestedParsed && typeof nestedParsed === 'object' && !Array.isArray(nestedParsed)) {
|
|
787
|
-
for (const [nestedKey, nestedValue] of Object.entries(nestedParsed)) {
|
|
788
|
-
if (typeof nestedValue === 'string') {
|
|
789
|
-
vars[`${nid}.${nestedKey}`] = nestedValue;
|
|
790
|
-
} else if (nestedValue !== null && nestedValue !== undefined) {
|
|
791
|
-
vars[`${nid}.${nestedKey}_json`] = JSON.stringify(nestedValue);
|
|
792
|
-
}
|
|
793
|
-
}
|
|
794
|
-
}
|
|
795
|
-
} catch {
|
|
796
|
-
// If nested parsing fails, just keep the text field as is
|
|
797
|
-
}
|
|
798
|
-
}
|
|
799
|
-
} else if (value !== null && value !== undefined) {
|
|
800
|
-
// For non-string values, provide JSON representation
|
|
801
|
-
vars[`${nid}.${key}_json`] = JSON.stringify(value);
|
|
802
|
-
}
|
|
803
|
-
}
|
|
804
|
-
}
|
|
805
|
-
} catch {
|
|
806
|
-
// If output isn't valid JSON, skip parsing but keep raw output
|
|
807
|
-
}
|
|
808
|
-
} catch { /* node output may not exist */ }
|
|
809
|
-
}
|
|
810
|
-
}
|
|
811
|
-
|
|
812
|
-
// Apply template variable replacement
|
|
781
|
+
const vars = await buildTemplateVars(teamDir, runsDir, runId, workflowFile, workflow);
|
|
813
782
|
const prompt = templateReplace(promptRaw, vars);
|
|
814
783
|
|
|
815
784
|
// Build output format instructions from outputFields when defined
|
|
@@ -1071,7 +1040,7 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
|
|
|
1071
1040
|
`\nReply with:`,
|
|
1072
1041
|
`- approve ${code}`,
|
|
1073
1042
|
`- decline ${code} <what to change>`,
|
|
1074
|
-
`\n(You can also review in Kitchen: http://localhost:7777/teams/${teamId}/workflows/${workflow.id ?? ''})`,
|
|
1043
|
+
`\n(You can also review in Kitchen: ${process.env['CK_BASE_URL'] || 'http://localhost:7777'}/teams/${teamId}/workflows/${workflow.id ?? ''})`,
|
|
1075
1044
|
].join('\n');
|
|
1076
1045
|
|
|
1077
1046
|
await toolsInvoke<ToolTextResult>(api, {
|
|
@@ -1114,61 +1083,7 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
|
|
|
1114
1083
|
if (!relPathRaw) throw new Error('fs.append requires args.path');
|
|
1115
1084
|
if (!contentRaw) throw new Error('fs.append requires args.content');
|
|
1116
1085
|
|
|
1117
|
-
const vars =
|
|
1118
|
-
date: new Date().toISOString(),
|
|
1119
|
-
'run.id': runId,
|
|
1120
|
-
'workflow.id': String(workflow.id ?? ''),
|
|
1121
|
-
'workflow.name': String(workflow.name ?? workflow.id ?? workflowFile),
|
|
1122
|
-
};
|
|
1123
|
-
|
|
1124
|
-
// Load node outputs (same as fs.write)
|
|
1125
|
-
const { run: runSnap } = await loadRunFile(teamDir, runsDir, task.runId);
|
|
1126
|
-
for (const nr of (runSnap.nodeResults ?? [])) {
|
|
1127
|
-
const nid = String((nr as Record<string, unknown>).nodeId ?? '');
|
|
1128
|
-
const nrOutPath = String((nr as Record<string, unknown>).nodeOutputPath ?? '');
|
|
1129
|
-
if (nid && nrOutPath) {
|
|
1130
|
-
try {
|
|
1131
|
-
const outAbs = path.resolve(teamDir, nrOutPath);
|
|
1132
|
-
const outputContent = await fs.readFile(outAbs, 'utf8');
|
|
1133
|
-
vars[`${nid}.output`] = outputContent;
|
|
1134
|
-
|
|
1135
|
-
// Parse JSON outputs and make fields accessible
|
|
1136
|
-
try {
|
|
1137
|
-
const parsed = JSON.parse(outputContent.trim());
|
|
1138
|
-
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
1139
|
-
for (const [key, value] of Object.entries(parsed)) {
|
|
1140
|
-
if (typeof value === 'string') {
|
|
1141
|
-
vars[`${nid}.${key}`] = value;
|
|
1142
|
-
|
|
1143
|
-
// Special handling for 'text' field - try to parse as nested JSON
|
|
1144
|
-
if (key === 'text') {
|
|
1145
|
-
try {
|
|
1146
|
-
const nestedParsed = JSON.parse(value);
|
|
1147
|
-
if (nestedParsed && typeof nestedParsed === 'object' && !Array.isArray(nestedParsed)) {
|
|
1148
|
-
for (const [nestedKey, nestedValue] of Object.entries(nestedParsed)) {
|
|
1149
|
-
if (typeof nestedValue === 'string') {
|
|
1150
|
-
vars[`${nid}.${nestedKey}`] = nestedValue;
|
|
1151
|
-
} else if (nestedValue !== null && nestedValue !== undefined) {
|
|
1152
|
-
vars[`${nid}.${nestedKey}_json`] = JSON.stringify(nestedValue);
|
|
1153
|
-
}
|
|
1154
|
-
}
|
|
1155
|
-
}
|
|
1156
|
-
} catch {
|
|
1157
|
-
// If nested parsing fails, just keep the text field as is
|
|
1158
|
-
}
|
|
1159
|
-
}
|
|
1160
|
-
} else if (value !== null && value !== undefined) {
|
|
1161
|
-
// For non-string values, provide JSON representation
|
|
1162
|
-
vars[`${nid}.${key}_json`] = JSON.stringify(value);
|
|
1163
|
-
}
|
|
1164
|
-
}
|
|
1165
|
-
}
|
|
1166
|
-
} catch {
|
|
1167
|
-
// If output isn't valid JSON, skip parsing but keep raw output
|
|
1168
|
-
}
|
|
1169
|
-
} catch { /* node output may not exist */ }
|
|
1170
|
-
}
|
|
1171
|
-
}
|
|
1086
|
+
const vars = await buildTemplateVars(teamDir, runsDir, runId, workflowFile, workflow);
|
|
1172
1087
|
|
|
1173
1088
|
const relPath = templateReplace(relPathRaw, vars);
|
|
1174
1089
|
const content = templateReplace(contentRaw, vars);
|
|
@@ -1184,67 +1099,12 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
|
|
|
1184
1099
|
const result = { appendedTo: path.relative(teamDir, abs), bytes: Buffer.byteLength(content, 'utf8') };
|
|
1185
1100
|
await fs.writeFile(artifactPath, JSON.stringify({ ok: true, tool: toolName, args: toolArgs, result }, null, 2) + '\n', 'utf8');
|
|
1186
1101
|
|
|
1187
|
-
|
|
1188
1102
|
} else if (toolName === 'fs.write') {
|
|
1189
1103
|
const relPathRaw = String(toolArgs.path ?? '').trim();
|
|
1190
1104
|
const contentRaw = String(toolArgs.content ?? '');
|
|
1191
1105
|
if (!relPathRaw) throw new Error('fs.write requires args.path');
|
|
1192
1106
|
|
|
1193
|
-
const vars =
|
|
1194
|
-
date: new Date().toISOString(),
|
|
1195
|
-
'run.id': runId,
|
|
1196
|
-
'run.timestamp': runId,
|
|
1197
|
-
'workflow.id': String(workflow.id ?? ''),
|
|
1198
|
-
'workflow.name': String(workflow.name ?? workflow.id ?? workflowFile),
|
|
1199
|
-
};
|
|
1200
|
-
// Also inject node outputs so templates like {{brand_review.output}} resolve
|
|
1201
|
-
const { run: runSnap } = await loadRunFile(teamDir, runsDir, task.runId);
|
|
1202
|
-
for (const nr of (runSnap.nodeResults ?? [])) {
|
|
1203
|
-
const nid = String((nr as Record<string, unknown>).nodeId ?? '');
|
|
1204
|
-
const nrOutPath = String((nr as Record<string, unknown>).nodeOutputPath ?? '');
|
|
1205
|
-
if (nid && nrOutPath) {
|
|
1206
|
-
try {
|
|
1207
|
-
const outAbs = path.resolve(teamDir, nrOutPath);
|
|
1208
|
-
const outputContent = await fs.readFile(outAbs, 'utf8');
|
|
1209
|
-
vars[`${nid}.output`] = outputContent;
|
|
1210
|
-
|
|
1211
|
-
// Parse JSON outputs and make fields accessible
|
|
1212
|
-
try {
|
|
1213
|
-
const parsed = JSON.parse(outputContent.trim());
|
|
1214
|
-
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
1215
|
-
for (const [key, value] of Object.entries(parsed)) {
|
|
1216
|
-
if (typeof value === 'string') {
|
|
1217
|
-
vars[`${nid}.${key}`] = value;
|
|
1218
|
-
|
|
1219
|
-
// Special handling for 'text' field - try to parse as nested JSON
|
|
1220
|
-
if (key === 'text') {
|
|
1221
|
-
try {
|
|
1222
|
-
const nestedParsed = JSON.parse(value);
|
|
1223
|
-
if (nestedParsed && typeof nestedParsed === 'object' && !Array.isArray(nestedParsed)) {
|
|
1224
|
-
for (const [nestedKey, nestedValue] of Object.entries(nestedParsed)) {
|
|
1225
|
-
if (typeof nestedValue === 'string') {
|
|
1226
|
-
vars[`${nid}.${nestedKey}`] = nestedValue;
|
|
1227
|
-
} else if (nestedValue !== null && nestedValue !== undefined) {
|
|
1228
|
-
vars[`${nid}.${nestedKey}_json`] = JSON.stringify(nestedValue);
|
|
1229
|
-
}
|
|
1230
|
-
}
|
|
1231
|
-
}
|
|
1232
|
-
} catch {
|
|
1233
|
-
// If nested parsing fails, just keep the text field as is
|
|
1234
|
-
}
|
|
1235
|
-
}
|
|
1236
|
-
} else if (value !== null && value !== undefined) {
|
|
1237
|
-
// For non-string values, provide JSON representation
|
|
1238
|
-
vars[`${nid}.${key}_json`] = JSON.stringify(value);
|
|
1239
|
-
}
|
|
1240
|
-
}
|
|
1241
|
-
}
|
|
1242
|
-
} catch {
|
|
1243
|
-
// If output isn't valid JSON, skip parsing but keep raw output
|
|
1244
|
-
}
|
|
1245
|
-
} catch { /* node output may not exist */ }
|
|
1246
|
-
}
|
|
1247
|
-
}
|
|
1107
|
+
const vars = await buildTemplateVars(teamDir, runsDir, runId, workflowFile, workflow);
|
|
1248
1108
|
const relPath = templateReplace(relPathRaw, vars);
|
|
1249
1109
|
const content = templateReplace(contentRaw, vars);
|
|
1250
1110
|
|
|
@@ -1260,75 +1120,8 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
|
|
|
1260
1120
|
await fs.writeFile(artifactPath, JSON.stringify({ ok: true, tool: toolName, args: toolArgs, result }, null, 2) + '\n', 'utf8');
|
|
1261
1121
|
|
|
1262
1122
|
} else {
|
|
1263
|
-
|
|
1264
|
-
const vars = {
|
|
1265
|
-
date: new Date().toISOString(),
|
|
1266
|
-
'run.id': runId,
|
|
1267
|
-
'run.timestamp': runId,
|
|
1268
|
-
'workflow.id': String(workflow.id ?? ''),
|
|
1269
|
-
'workflow.name': String(workflow.name ?? workflow.id ?? workflowFile),
|
|
1270
|
-
};
|
|
1123
|
+
const vars = await buildTemplateVars(teamDir, runsDir, runId, workflowFile, workflow);
|
|
1271
1124
|
|
|
1272
|
-
// Load node outputs and make them available as template variables
|
|
1273
|
-
const { run: runSnap } = await loadRunFile(teamDir, runsDir, task.runId);
|
|
1274
|
-
|
|
1275
|
-
// Expose triggerInput as template variables (for handoff-injected data)
|
|
1276
|
-
if (runSnap.triggerInput && typeof runSnap.triggerInput === 'object') {
|
|
1277
|
-
for (const [key, value] of Object.entries(runSnap.triggerInput)) {
|
|
1278
|
-
if (typeof value === 'string') {
|
|
1279
|
-
vars[`trigger.${key}`] = value;
|
|
1280
|
-
} else if (value !== null && value !== undefined) {
|
|
1281
|
-
vars[`trigger.${key}`] = JSON.stringify(value);
|
|
1282
|
-
}
|
|
1283
|
-
}
|
|
1284
|
-
}
|
|
1285
|
-
for (const nr of (runSnap.nodeResults ?? [])) {
|
|
1286
|
-
const nid = String((nr as Record<string, unknown>).nodeId ?? '');
|
|
1287
|
-
const nrOutPath = String((nr as Record<string, unknown>).nodeOutputPath ?? '');
|
|
1288
|
-
if (nid && nrOutPath) {
|
|
1289
|
-
try {
|
|
1290
|
-
const outAbs = path.resolve(teamDir, nrOutPath);
|
|
1291
|
-
const outputContent = await fs.readFile(outAbs, 'utf8');
|
|
1292
|
-
vars[`${nid}.output`] = outputContent;
|
|
1293
|
-
|
|
1294
|
-
// Parse JSON outputs and make fields accessible
|
|
1295
|
-
try {
|
|
1296
|
-
const parsed = JSON.parse(outputContent.trim());
|
|
1297
|
-
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
1298
|
-
for (const [key, value] of Object.entries(parsed)) {
|
|
1299
|
-
if (typeof value === 'string') {
|
|
1300
|
-
vars[`${nid}.${key}`] = value;
|
|
1301
|
-
|
|
1302
|
-
// Special handling for 'text' field - try to parse as nested JSON
|
|
1303
|
-
if (key === 'text') {
|
|
1304
|
-
try {
|
|
1305
|
-
const nestedParsed = JSON.parse(value);
|
|
1306
|
-
if (nestedParsed && typeof nestedParsed === 'object' && !Array.isArray(nestedParsed)) {
|
|
1307
|
-
for (const [nestedKey, nestedValue] of Object.entries(nestedParsed)) {
|
|
1308
|
-
if (typeof nestedValue === 'string') {
|
|
1309
|
-
vars[`${nid}.${nestedKey}`] = nestedValue;
|
|
1310
|
-
} else if (nestedValue !== null && nestedValue !== undefined) {
|
|
1311
|
-
vars[`${nid}.${nestedKey}_json`] = JSON.stringify(nestedValue);
|
|
1312
|
-
}
|
|
1313
|
-
}
|
|
1314
|
-
}
|
|
1315
|
-
} catch {
|
|
1316
|
-
// If nested parsing fails, just keep the text field as is
|
|
1317
|
-
}
|
|
1318
|
-
}
|
|
1319
|
-
} else if (value !== null && value !== undefined) {
|
|
1320
|
-
// For non-string values, provide JSON representation
|
|
1321
|
-
vars[`${nid}.${key}_json`] = JSON.stringify(value);
|
|
1322
|
-
}
|
|
1323
|
-
}
|
|
1324
|
-
}
|
|
1325
|
-
} catch {
|
|
1326
|
-
// If output isn't valid JSON, skip parsing but keep raw output
|
|
1327
|
-
}
|
|
1328
|
-
} catch { /* node output may not exist */ }
|
|
1329
|
-
}
|
|
1330
|
-
}
|
|
1331
|
-
|
|
1332
1125
|
// Apply template variable replacement to all string values in toolArgs
|
|
1333
1126
|
const processedToolArgs: Record<string, unknown> = {};
|
|
1334
1127
|
for (const [key, value] of Object.entries(toolArgs)) {
|
|
@@ -1357,13 +1150,20 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
|
|
|
1357
1150
|
// to most workflow worker sessions.
|
|
1358
1151
|
const command = String((processedToolArgs as Record<string, unknown>).command ?? '');
|
|
1359
1152
|
const workdir = String((processedToolArgs as Record<string, unknown>).workdir ?? teamDir);
|
|
1360
|
-
|
|
1153
|
+
// Timeout priority: args.timeout (seconds) > config.timeoutMs (ms) > 120s default
|
|
1154
|
+
const nodeConfig = asRecord((node as unknown as Record<string, unknown>)['config']);
|
|
1155
|
+
const argsTimeoutSec = Number((processedToolArgs as Record<string, unknown>).timeout) || 0;
|
|
1156
|
+
const configTimeoutMs = Number(nodeConfig['timeoutMs']) || 0;
|
|
1157
|
+
const timeoutSec = argsTimeoutSec || (configTimeoutMs > 0 ? Math.ceil(configTimeoutMs / 1000) : 120);
|
|
1361
1158
|
const result = await api.runtime.system.runCommandWithTimeout(
|
|
1362
1159
|
['bash', '-c', command],
|
|
1363
1160
|
{ timeoutMs: timeoutSec * 1000, cwd: workdir },
|
|
1364
1161
|
);
|
|
1365
1162
|
if (result.code !== 0) {
|
|
1366
|
-
|
|
1163
|
+
const stderr = String(result.stderr ?? '').trim();
|
|
1164
|
+
const stdout = String(result.stdout ?? '').trim();
|
|
1165
|
+
const combined = [stderr, stdout].filter(Boolean).join('\n---stdout---\n');
|
|
1166
|
+
throw new Error(`exec failed (code=${result.code}):\n${combined}`);
|
|
1367
1167
|
}
|
|
1368
1168
|
toolRes = { stdout: result.stdout, stderr: result.stderr, code: result.code };
|
|
1369
1169
|
} else {
|
|
@@ -1887,6 +1687,7 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
|
|
|
1887
1687
|
} catch { // intentional: best-effort claim release
|
|
1888
1688
|
// ignore
|
|
1889
1689
|
}
|
|
1690
|
+
if (countedTowardLimit) executedCount++;
|
|
1890
1691
|
}
|
|
1891
1692
|
|
|
1892
1693
|
}
|