@michaelrwalker/stagehand 0.5.3 → 0.6.1
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 +8 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/script.d.ts +40 -20
- package/dist/script.d.ts.map +1 -1
- package/dist/script.js +4 -1
- package/dist/script.js.map +1 -1
- package/dist/types.d.ts +54 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/ui/ansi.d.ts +12 -2
- package/dist/ui/ansi.d.ts.map +1 -1
- package/dist/ui/ansi.js +14 -2
- package/dist/ui/ansi.js.map +1 -1
- package/dist/ui/frame.d.ts +22 -2
- package/dist/ui/frame.d.ts.map +1 -1
- package/dist/ui/frame.js +38 -15
- package/dist/ui/frame.js.map +1 -1
- package/dist/ui/renderer.d.ts +6 -4
- package/dist/ui/renderer.d.ts.map +1 -1
- package/dist/ui/renderer.js +17 -9
- package/dist/ui/renderer.js.map +1 -1
- package/package.json +10 -5
- package/skills/building-scripts/getting-started/SKILL.md +210 -0
- package/skills/building-scripts/structuring-phases-and-steps/SKILL.md +197 -0
- package/skills/building-scripts/typed-context-and-input/SKILL.md +188 -0
- package/skills/building-scripts/typed-context-and-input/references/context-type-utilities.md +121 -0
- package/skills/caching/caching-expensive-work/SKILL.md +285 -0
- package/skills/caching/caching-expensive-work/references/cache-api.md +278 -0
- package/skills/compensation-safety/adding-rollbacks/SKILL.md +400 -0
- package/skills/compensation-safety/cleaning-context/SKILL.md +165 -0
- package/skills/compensation-safety/conditional-steps/SKILL.md +199 -0
- package/skills/errors-results/handling-results-and-errors/SKILL.md +281 -0
- package/skills/observability/log-placement-and-rendering/SKILL.md +167 -0
- package/skills/observability/reporting-progress/SKILL.md +203 -0
- package/skills/reuse/reusable-routines-and-mounts/SKILL.md +243 -0
- package/skills/reuse/splitting-steps-across-files/SKILL.md +226 -0
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: 'adding-rollbacks'
|
|
3
|
+
description: >
|
|
4
|
+
Covers writing a step's `rollback`, declaring `rollbackKeys` (what it narrows the rollback's `ctx`
|
|
5
|
+
to and permanently reserves from `clean`), `ScriptOptions.rollback` scope (`"all"` | `"phase"` |
|
|
6
|
+
`"none"`), the rollback context object (`input`/`ctx`/`output`/`error`/`signal`/`phase`/`step`),
|
|
7
|
+
`RunResult.rollbacks`, and cancellation via `handleSignals` / `AbortSignal` / `isAbort`. Load this
|
|
8
|
+
for "add a compensation to a mutation step," "why can't my rollback see this key," "scope how far
|
|
9
|
+
a failure unwinds," or "cancel a running script from outside."
|
|
10
|
+
metadata:
|
|
11
|
+
type: 'core'
|
|
12
|
+
library: 'stagehand'
|
|
13
|
+
library_version: '0.5.3'
|
|
14
|
+
sources:
|
|
15
|
+
- 'miaklwalker/stagehand:docs/guides/rollbacks.md'
|
|
16
|
+
- 'miaklwalker/stagehand:docs/reference/errors.md'
|
|
17
|
+
- 'miaklwalker/stagehand:src/errors.ts'
|
|
18
|
+
- 'miaklwalker/stagehand:src/script.ts'
|
|
19
|
+
- 'miaklwalker/stagehand:test/rollback-keys.test.ts'
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
# Stagehand — Adding Rollbacks
|
|
23
|
+
|
|
24
|
+
When a step fails, every step that already **succeeded** is compensated in reverse order — last
|
|
25
|
+
completed, first undone. The step that actually failed is never compensated: it never completed, so
|
|
26
|
+
there's nothing to undo. A rollback gets no context by default; it has to name every key it needs
|
|
27
|
+
through `rollbackKeys`, which both narrows and permanently reserves them.
|
|
28
|
+
|
|
29
|
+
## Setup
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
import { Script } from "@michaelrwalker/stagehand";
|
|
33
|
+
|
|
34
|
+
async function reserve(accountId: string, amount: number): Promise<string> {
|
|
35
|
+
return `res_${accountId}_${amount}`;
|
|
36
|
+
}
|
|
37
|
+
async function release(accountId: string, reservationId: string): Promise<void> {
|
|
38
|
+
console.log(`released ${reservationId} for ${accountId}`);
|
|
39
|
+
}
|
|
40
|
+
async function chargeCard(accountId: string, amount: number): Promise<void> {
|
|
41
|
+
throw new Error("payment gateway timeout");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const result = await new Script<{ accountId: string; amount: number }>({ name: "charge" })
|
|
45
|
+
.addStep({ name: "seed", handler: ({ input }) => ({ ...input }) })
|
|
46
|
+
.addStep({
|
|
47
|
+
name: "reserve funds",
|
|
48
|
+
handler: async ({ ctx }) => ({ reservationId: await reserve(ctx.accountId, ctx.amount) }),
|
|
49
|
+
rollbackKeys: ["accountId", "reservationId"],
|
|
50
|
+
rollback: async ({ ctx }) => release(ctx.accountId, ctx.reservationId),
|
|
51
|
+
})
|
|
52
|
+
.addStep({
|
|
53
|
+
name: "charge card",
|
|
54
|
+
handler: async ({ ctx }) => {
|
|
55
|
+
await chargeCard(ctx.accountId, ctx.amount);
|
|
56
|
+
return {};
|
|
57
|
+
},
|
|
58
|
+
})
|
|
59
|
+
.run({ accountId: "acct_1", amount: 500 });
|
|
60
|
+
|
|
61
|
+
if (!result.ok) {
|
|
62
|
+
console.error("run failed:", result.error);
|
|
63
|
+
for (const r of result.rollbacks) if (!r.ok) console.error("rollback failed:", r.step, r.error);
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Core Patterns
|
|
68
|
+
|
|
69
|
+
### Only completed steps unwind, in reverse order
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
import { Script } from "@michaelrwalker/stagehand";
|
|
73
|
+
|
|
74
|
+
const order: string[] = [];
|
|
75
|
+
|
|
76
|
+
await new Script({ name: "unwind-order" })
|
|
77
|
+
.addStep({ name: "one", handler: () => ({}), rollback: () => { order.push("one"); } })
|
|
78
|
+
.addStep({ name: "two", handler: () => ({}), rollback: () => { order.push("two"); } })
|
|
79
|
+
.addStep({
|
|
80
|
+
name: "three",
|
|
81
|
+
handler: () => { throw new Error("boom"); },
|
|
82
|
+
rollback: () => { order.push("three"); },
|
|
83
|
+
})
|
|
84
|
+
.run();
|
|
85
|
+
|
|
86
|
+
// order === ["two", "one"] — "three" never completed, so its own rollback never runs
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Scoping the unwind with `rollback: "phase"`
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
import { Script } from "@michaelrwalker/stagehand";
|
|
93
|
+
|
|
94
|
+
const deploy = new Script({ name: "deploy", rollback: "phase" })
|
|
95
|
+
.addPhase("Prepare")
|
|
96
|
+
.addStep({ name: "provision", handler: async () => ({ serverId: "s1" }), rollback: async () => {} })
|
|
97
|
+
.addPhase("Release")
|
|
98
|
+
.addStep({ name: "swap traffic", handler: async () => ({ swapped: true }), rollback: async () => {} })
|
|
99
|
+
.addStep({ name: "notify", handler: async () => { throw new Error("notify failed"); } });
|
|
100
|
+
|
|
101
|
+
// "notify" fails inside "Release" — only "swap traffic"'s rollback fires;
|
|
102
|
+
// "provision" (in "Prepare") is left alone because rollback: "phase" only unwinds the failing phase
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`ScriptOptions.rollback` is `"all"` (default, unwinds the whole script), `"phase"` (unwinds only the
|
|
106
|
+
failing phase), or `"none"` (no compensation runs at all).
|
|
107
|
+
|
|
108
|
+
### Narrowing what a rollback can see with `rollbackKeys`
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
.addStep({
|
|
112
|
+
name: "reserve funds",
|
|
113
|
+
handler: async ({ ctx }) => ({ reservationId: await reserve(ctx.accountId, ctx.amount) }),
|
|
114
|
+
rollbackKeys: ["accountId", "reservationId"],
|
|
115
|
+
rollback: async ({ ctx }) => {
|
|
116
|
+
// ctx is exactly { accountId: string; reservationId: string } — nothing else
|
|
117
|
+
await release(ctx.accountId, ctx.reservationId);
|
|
118
|
+
},
|
|
119
|
+
})
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
`rollbackKeys` only accepts keys that exist on the step's incoming context or its own output — anything
|
|
123
|
+
else is a compile error. A rollback's `output` argument always carries the full handler return value
|
|
124
|
+
regardless of `rollbackKeys` or later `clean` calls; only `ctx` is narrowed.
|
|
125
|
+
|
|
126
|
+
### Cancelling a running script from outside
|
|
127
|
+
|
|
128
|
+
```ts
|
|
129
|
+
import { Script } from "@michaelrwalker/stagehand";
|
|
130
|
+
|
|
131
|
+
const job = new Script({ name: "long-job" })
|
|
132
|
+
.addStep({
|
|
133
|
+
name: "fetch",
|
|
134
|
+
handler: async ({ signal }) => {
|
|
135
|
+
const response = await fetch("https://api.example.com/data", { signal });
|
|
136
|
+
return { data: await response.json() };
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const controller = new AbortController();
|
|
141
|
+
setTimeout(() => controller.abort(), 5_000);
|
|
142
|
+
|
|
143
|
+
const result = await job.run(undefined, { signal: controller.signal });
|
|
144
|
+
|
|
145
|
+
if (!result.ok && result.status === "aborted") {
|
|
146
|
+
console.log("cancelled:", result.error);
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
`ScriptOptions.handleSignals` (default `true`) makes Ctrl-C and `SIGTERM` do the same thing; a second
|
|
151
|
+
signal after the first abandons compensation too. Forward a handler's own `signal` into any real I/O so
|
|
152
|
+
a cancelled step actually stops.
|
|
153
|
+
|
|
154
|
+
## Common Mistakes
|
|
155
|
+
|
|
156
|
+
### MEDIUM Relying on rollback: "phase" without ever calling addPhase
|
|
157
|
+
|
|
158
|
+
Wrong:
|
|
159
|
+
```ts
|
|
160
|
+
new Script({ name: "t", rollback: "phase" })
|
|
161
|
+
.addStep(stepA).addStep(stepB).addStep(stepC);
|
|
162
|
+
// rollback: "phase" here behaves exactly like "all" — one phase, "Main"
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Correct:
|
|
166
|
+
```ts
|
|
167
|
+
new Script({ name: "t", rollback: "phase" })
|
|
168
|
+
.addPhase("Prepare").addStep(stepA)
|
|
169
|
+
.addPhase("Commit").addStep(stepB).addStep(stepC);
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
`addStep` creates an implicit `"Main"` phase the first time it's called if `addPhase` was never used, so with everything in one phase there is nothing for `"phase"` scoping to exclude — it unwinds identically to `"all"`.
|
|
173
|
+
|
|
174
|
+
Source: src/script.ts currentPhase() implicit "Main"; docs/guides/phases-and-steps.md
|
|
175
|
+
|
|
176
|
+
### MEDIUM Writing a rollback for the step that itself failed
|
|
177
|
+
|
|
178
|
+
Wrong:
|
|
179
|
+
```ts
|
|
180
|
+
.addStep({
|
|
181
|
+
name: "charge card",
|
|
182
|
+
handler: () => { throw new Error("declined"); },
|
|
183
|
+
rollback: () => refund(), // never runs — this step never succeeded
|
|
184
|
+
})
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Correct:
|
|
188
|
+
```ts
|
|
189
|
+
.addStep({
|
|
190
|
+
name: "reserve funds",
|
|
191
|
+
handler: async ({ ctx }) => ({ reservationId: await reserve(ctx.accountId) }),
|
|
192
|
+
rollbackKeys: ["accountId"],
|
|
193
|
+
rollback: async ({ ctx, output }) => release(ctx.accountId, output.reservationId),
|
|
194
|
+
})
|
|
195
|
+
.addStep({ name: "charge card", handler: () => { throw new Error("declined"); } })
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
The step that throws never completes, so only earlier, already-succeeded steps are unwound — a `rollback` field on the failing step itself is dead code.
|
|
199
|
+
|
|
200
|
+
Source: docs/guides/rollbacks.md
|
|
201
|
+
|
|
202
|
+
### CRITICAL Expecting a rollback to see the full context without declaring rollbackKeys
|
|
203
|
+
|
|
204
|
+
Wrong:
|
|
205
|
+
```ts
|
|
206
|
+
.addStep({
|
|
207
|
+
name: "reserve funds",
|
|
208
|
+
handler: async ({ ctx }) => ({ reservationId: await reserve(ctx.accountId) }),
|
|
209
|
+
rollback: async ({ ctx }) => release(ctx.accountId, ctx.reservationId),
|
|
210
|
+
// ^ both are `undefined` — ctx is {}
|
|
211
|
+
})
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
Correct:
|
|
215
|
+
```ts
|
|
216
|
+
.addStep({
|
|
217
|
+
name: "reserve funds",
|
|
218
|
+
handler: async ({ ctx }) => ({ reservationId: await reserve(ctx.accountId) }),
|
|
219
|
+
rollbackKeys: ["accountId", "reservationId"],
|
|
220
|
+
rollback: async ({ ctx }) => release(ctx.accountId, ctx.reservationId),
|
|
221
|
+
})
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
A rollback gets `{}` by default — every key it touches has to be named in `rollbackKeys`, which both narrows `ctx` and reserves those keys against `clean`.
|
|
225
|
+
|
|
226
|
+
Source: docs/guides/rollbacks.md; test/rollback-keys.test.ts
|
|
227
|
+
|
|
228
|
+
### MEDIUM Assuming a throwing rollback replaces or masks result.error
|
|
229
|
+
|
|
230
|
+
Wrong:
|
|
231
|
+
```ts
|
|
232
|
+
const result = await script.run(input);
|
|
233
|
+
if (!result.ok) console.error(result.error); // assumed to be the rollback's error if one failed
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Correct:
|
|
237
|
+
```ts
|
|
238
|
+
const result = await script.run(input);
|
|
239
|
+
if (!result.ok) {
|
|
240
|
+
console.error("run failed:", result.error);
|
|
241
|
+
for (const r of result.rollbacks) if (!r.ok) console.error("rollback failed:", r.step, r.error);
|
|
242
|
+
}
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
A failing rollback is recorded as `{ ok: false, error }` inside `result.rollbacks`; `result.error` always stays the original failure that triggered the unwind, and the remaining rollbacks still run.
|
|
246
|
+
|
|
247
|
+
Source: docs/guides/rollbacks.md; docs/reference/errors.md (RollbackFailedError)
|
|
248
|
+
|
|
249
|
+
### MEDIUM Any Error named "AbortError" reads as a script cancellation
|
|
250
|
+
|
|
251
|
+
Wrong:
|
|
252
|
+
```ts
|
|
253
|
+
handler: async ({ signal }) => {
|
|
254
|
+
const localController = new AbortController();
|
|
255
|
+
setTimeout(() => localController.abort(), 200); // unrelated to `signal`
|
|
256
|
+
return fetch(url, { signal: localController.signal }); // throws AbortError → retry silently skipped
|
|
257
|
+
},
|
|
258
|
+
retry: { attempts: 3 },
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
Correct:
|
|
262
|
+
```ts
|
|
263
|
+
handler: async ({ signal }) => fetch(url, { signal }), // forward the step's own signal
|
|
264
|
+
retry: { attempts: 3, retryIf: (error) => !isAbort(error) },
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
Wrong:
|
|
268
|
+
```ts
|
|
269
|
+
if (!result.ok && isAbort(result.error)) {
|
|
270
|
+
console.log("user pressed Ctrl-C"); // could actually be an unrelated internal AbortError
|
|
271
|
+
}
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
Correct:
|
|
275
|
+
```ts
|
|
276
|
+
if (!result.ok && isAbort(result.error) && result.status === "aborted") {
|
|
277
|
+
console.log("cancelled");
|
|
278
|
+
}
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
`isAbort(error)` returns `true` for a `ScriptAbortedError` or any `Error` whose `name` is exactly `"AbortError"` — not just one tied to the script's own signal — so a hand-rolled `AbortController` inside a handler silently skips `retry`, and checking `isAbort(result.error)` alone can misreport an unrelated internal abort as a real cancellation.
|
|
282
|
+
|
|
283
|
+
Source: src/errors.ts isAbort(); src/script.ts executeStep() (isAbort check precedes the retry-budget check); docs/guides/rollbacks.md ("Signals and cancellation")
|
|
284
|
+
|
|
285
|
+
### MEDIUM Writing a rollback that reaches beyond its own step's side effects
|
|
286
|
+
|
|
287
|
+
Wrong:
|
|
288
|
+
```ts
|
|
289
|
+
.addStep({
|
|
290
|
+
name: "reserve funds",
|
|
291
|
+
handler: async ({ ctx }) => ({ reservationId: await reserve(ctx.accountId) }),
|
|
292
|
+
rollback: async ({ ctx }) => {
|
|
293
|
+
await release(ctx.reservationId);
|
|
294
|
+
await notifyUser(ctx.accountId); // "charge card"'s concern, not this step's
|
|
295
|
+
},
|
|
296
|
+
})
|
|
297
|
+
.addStep({ name: "charge card", handler: chargeCard, rollback: async () => notifyUser(ctx.accountId) })
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
Correct:
|
|
301
|
+
```ts
|
|
302
|
+
.addStep({
|
|
303
|
+
name: "reserve funds",
|
|
304
|
+
handler: async ({ ctx }) => ({ reservationId: await reserve(ctx.accountId) }),
|
|
305
|
+
rollbackKeys: ["reservationId"],
|
|
306
|
+
rollback: async ({ ctx }) => release(ctx.reservationId), // only what this step did
|
|
307
|
+
})
|
|
308
|
+
.addStep({
|
|
309
|
+
name: "charge card",
|
|
310
|
+
handler: chargeCard,
|
|
311
|
+
rollback: async () => notifyUser(), // its own concern, compensated independently
|
|
312
|
+
})
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
Each completed step's own rollback already fires for its own side effects during the reverse unwind, so a rollback that also cleans up another step's work either double-undoes it or does so with less context than the responsible step actually had.
|
|
316
|
+
|
|
317
|
+
Source: maintainer interview ("rollback steps should be written when you understand everything a step must do to properly rollback... scoped to the specific step it is supposed to rollback")
|
|
318
|
+
|
|
319
|
+
### CRITICAL Cleaning a key reserved by rollbackKeys, even from the step that reserved it
|
|
320
|
+
|
|
321
|
+
Wrong:
|
|
322
|
+
```ts
|
|
323
|
+
.addStep({ name: "one", handler: () => {}, rollbackKeys: ["a"], rollback: async () => {} })
|
|
324
|
+
.addStep({ name: "two", handler: () => {}, clean: ["a"] });
|
|
325
|
+
// StepDefinitionError: reserved by rollbackKeys
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
Correct:
|
|
329
|
+
```ts
|
|
330
|
+
.addStep({ name: "one", handler: () => {}, rollbackKeys: ["a"], rollback: async () => {} })
|
|
331
|
+
.addStep({ name: "two", handler: () => {} }) // leave "a" alone; it stays reserved
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
Once a key is reserved by any step's `rollbackKeys`, no step — including that one — may list it in `clean`, for the rest of the script, across phase boundaries; `addStep` throws `StepDefinitionError` at build time.
|
|
335
|
+
|
|
336
|
+
Source: src/script.ts addStep() conflicts check; docs/guides/cleaning-context.md; test/rollback-keys.test.ts
|
|
337
|
+
|
|
338
|
+
### MEDIUM Giving a routine its own ScriptOptions and expecting them to hold once mounted
|
|
339
|
+
|
|
340
|
+
Wrong:
|
|
341
|
+
```ts
|
|
342
|
+
// routines/import.ts
|
|
343
|
+
const pipeline = new Script({ name: "import", rollback: "none" })
|
|
344
|
+
.addPhase("Fetch").addStep({ name: "pull", handler, rollback: undoPull });
|
|
345
|
+
// mounted into a host with rollback: "all" — the host's "all" wins; "pull"'s rollback CAN fire
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
Correct:
|
|
349
|
+
```ts
|
|
350
|
+
// a mountable fragment's own ScriptOptions are dead weight once mounted — fine to leave them off,
|
|
351
|
+
// and expect whichever script mounts it to own rollback/logPlacement/silent for the whole run
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
A mounted `Script`'s own `ScriptOptions` are discarded in favor of the host's; only its phases and steps come across.
|
|
355
|
+
|
|
356
|
+
Source: docs/guides/reusable-scripts.md ("A mounted Script's own ScriptOptions... are ignored in favor of the host's")
|
|
357
|
+
|
|
358
|
+
### MEDIUM Catching RollbackFailedError around run()
|
|
359
|
+
|
|
360
|
+
Wrong:
|
|
361
|
+
```ts
|
|
362
|
+
try {
|
|
363
|
+
await script.run(input);
|
|
364
|
+
} catch (e) {
|
|
365
|
+
if (e instanceof RollbackFailedError) { /* never matches */ }
|
|
366
|
+
}
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
Correct:
|
|
370
|
+
```ts
|
|
371
|
+
const result = await script.run(input);
|
|
372
|
+
if (!result.ok) {
|
|
373
|
+
const failedRollbacks = result.rollbacks.filter((r) => !r.ok);
|
|
374
|
+
}
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
`RollbackFailedError` is exported for a caller's own reporting, but the library itself never throws it — a failing rollback is instead recorded as `{ ok: false, error }` inside `result.rollbacks`.
|
|
378
|
+
|
|
379
|
+
Source: docs/reference/errors.md ("a failing rollback does not propagate as this error type through RunResult")
|
|
380
|
+
|
|
381
|
+
### HIGH Tension: Caching speed vs. compensation guarantees
|
|
382
|
+
|
|
383
|
+
Cached work never actually runs on a hit, so its rollback can never fire during a later unwind. Agents optimizing for this skill's goal tend to add `cache: store` to an already-rollback-protected phase as a drive-by performance or rate-limit tweak because they don't account for caching-expensive-work's constraint that a cache hit skips the handler entirely, quietly removing that phase from the saga's compensation guarantee.
|
|
384
|
+
|
|
385
|
+
See also: skills/caching/caching-expensive-work/SKILL.md § Common Mistakes
|
|
386
|
+
|
|
387
|
+
### HIGH Tension: Simple first draft vs. rollbackKeys reservation permanence
|
|
388
|
+
|
|
389
|
+
The simplest script has no `rollbackKeys` and no `clean` conflicts. Agents optimizing for this skill's goal tend to add a `rollback` to "harden" an already-working script and get blindsided by `StepDefinitionError` from unrelated `clean` calls elsewhere because they don't account for the fact that any `rollbackKeys` the new rollback declares permanently locks those keys from `clean` everywhere else in the script, for the rest of its life.
|
|
390
|
+
|
|
391
|
+
See also: skills/building-scripts/getting-started/SKILL.md § Common Mistakes, skills/compensation-safety/cleaning-context/SKILL.md § Common Mistakes
|
|
392
|
+
|
|
393
|
+
### HIGH Tension: Routine authoring instincts vs. host-controlled ScriptOptions
|
|
394
|
+
|
|
395
|
+
A routine is built as an ordinary `Script`, which makes it natural to set `rollback`/`logPlacement`/`silent` on it as if authoring a standalone safety policy. Agents optimizing for this skill's goal tend to set `rollback: "none"` on a routine's own `Script` to bake in "this fragment is always non-destructive" because they don't account for reusable-routines-and-mounts' constraint that every one of those options is discarded the moment the routine is mounted — only the host's own `ScriptOptions` ever apply.
|
|
396
|
+
|
|
397
|
+
See also: skills/reuse/reusable-routines-and-mounts/SKILL.md § Common Mistakes, skills/observability/log-placement-and-rendering/SKILL.md § Common Mistakes
|
|
398
|
+
|
|
399
|
+
See also: skills/compensation-safety/cleaning-context/SKILL.md — rollbackKeys and clean share one reservation mechanism; writing a rollback without knowing this leads to confusing StepDefinitionErrors elsewhere in the script.
|
|
400
|
+
See also: skills/caching/caching-expensive-work/SKILL.md — cached work never rolls back; anyone reasoning about compensation coverage needs to know which phases are cache-exempt from it.
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: 'cleaning-context'
|
|
3
|
+
description: >
|
|
4
|
+
Covers the `clean` field on `addStep` — dropping keys from both the runtime context and the type every later step sees — and its hard interaction with `rollbackKeys` reservation, which permanently blocks a key from `clean` for the rest of the script. Load this for "drop a secret from context once it's no longer needed," "why does clean reject this key," or any `StepDefinitionError` mentioning "reserved by rollbackKeys."
|
|
5
|
+
metadata:
|
|
6
|
+
type: 'core'
|
|
7
|
+
library: 'stagehand'
|
|
8
|
+
library_version: '0.5.3'
|
|
9
|
+
sources:
|
|
10
|
+
- 'miaklwalker/stagehand:docs/guides/cleaning-context.md'
|
|
11
|
+
- 'miaklwalker/stagehand:src/script.ts'
|
|
12
|
+
- 'miaklwalker/stagehand:test/rollback-keys.test.ts'
|
|
13
|
+
- 'miaklwalker/stagehand:test/clean.test.ts'
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
# Stagehand — Cleaning Context Keys
|
|
17
|
+
|
|
18
|
+
A handler's return value stays in the context for the rest of the script by default. Listing keys in a step's `clean` deletes them from the runtime context **and** removes them from the type every later step sees.
|
|
19
|
+
|
|
20
|
+
## Setup
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import { Script } from "@michaelrwalker/stagehand";
|
|
24
|
+
|
|
25
|
+
const result = await new Script<{ password: string; email: string }>({ name: "signup" })
|
|
26
|
+
.addStep({
|
|
27
|
+
name: "read input",
|
|
28
|
+
handler: ({ input }) => ({ ...input }),
|
|
29
|
+
})
|
|
30
|
+
.addStep({
|
|
31
|
+
name: "hash",
|
|
32
|
+
handler: async ({ ctx }) => ({ hash: await hashIt(ctx.password) }),
|
|
33
|
+
clean: ["password"], // not needed past this point
|
|
34
|
+
})
|
|
35
|
+
.addStep({
|
|
36
|
+
name: "persist",
|
|
37
|
+
handler: ({ ctx }) => {
|
|
38
|
+
ctx.hash; // string
|
|
39
|
+
ctx.email; // string
|
|
40
|
+
// ctx.password; // would be a compile error — cleaned away
|
|
41
|
+
return {};
|
|
42
|
+
},
|
|
43
|
+
})
|
|
44
|
+
.run({ password: "hunter2", email: "a@b.com" });
|
|
45
|
+
|
|
46
|
+
async function hashIt(s: string) {
|
|
47
|
+
return s;
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Core Patterns
|
|
52
|
+
|
|
53
|
+
### Drop a key immediately after the step that consumes it
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
.addStep({
|
|
57
|
+
name: "derive",
|
|
58
|
+
handler: ({ ctx }) => ({ temp: ctx.a * 10, b: ctx.a + 1 }),
|
|
59
|
+
clean: ["a"], // "a" is gone; "temp" and "b" remain
|
|
60
|
+
})
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
A step can clean any key from its *incoming* context while adding new keys of its own in the same handler.
|
|
64
|
+
|
|
65
|
+
### Clean under a skipped step or a cache hit
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
new Script<{ secret: string }>({ name: "t" })
|
|
69
|
+
.addStep({ name: "seed", handler: ({ input }) => ({ secret: input.secret }) })
|
|
70
|
+
.addStep({
|
|
71
|
+
name: "optional",
|
|
72
|
+
when: () => false,
|
|
73
|
+
handler: () => ({ used: true }),
|
|
74
|
+
clean: ["secret"],
|
|
75
|
+
})
|
|
76
|
+
.run({ secret: "hush" });
|
|
77
|
+
// result.ctx is {} — "secret" is gone even though "optional" never ran
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`clean` describes the declared shape of the context, not the work performed, so it applies whether the step actually ran, was skipped by `when`, or was served from cache.
|
|
81
|
+
|
|
82
|
+
### A step's own `output` survives clean, for its rollback
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
.addStep({
|
|
86
|
+
name: "one",
|
|
87
|
+
handler: () => ({ token: "tok_1", scratch: "temp" }),
|
|
88
|
+
rollbackKeys: ["a"],
|
|
89
|
+
rollback: ({ ctx, output }) => {
|
|
90
|
+
// output is still { token: "tok_1", scratch: "temp" } in full,
|
|
91
|
+
// regardless of what a later step cleans from the live context
|
|
92
|
+
},
|
|
93
|
+
})
|
|
94
|
+
.addStep({ name: "drop it", handler: () => ({}), clean: ["scratch"] })
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Common Mistakes
|
|
98
|
+
|
|
99
|
+
### MEDIUM Trying to clean a key the step itself just produced
|
|
100
|
+
|
|
101
|
+
Wrong:
|
|
102
|
+
```ts
|
|
103
|
+
.addStep({
|
|
104
|
+
name: "s",
|
|
105
|
+
handler: ({ ctx }) => ({ temp: ctx.a * 10 }),
|
|
106
|
+
clean: ["temp"], // compile error — "temp" is this step's output, not its input
|
|
107
|
+
})
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Correct:
|
|
111
|
+
```ts
|
|
112
|
+
.addStep({ name: "s", handler: ({ ctx }) => ({ temp: ctx.a * 10 }) })
|
|
113
|
+
.addStep({ name: "next", handler: () => ({}), clean: ["temp"] })
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`clean` is checked against the context as it stands entering the step, before the handler's own output is merged, so a step's own output does not exist yet at the point `clean` is evaluated — it's a compile error to list it.
|
|
117
|
+
|
|
118
|
+
Source: docs/guides/cleaning-context.md
|
|
119
|
+
|
|
120
|
+
### CRITICAL Cleaning a key reserved by rollbackKeys, even from the reserving step
|
|
121
|
+
|
|
122
|
+
Wrong:
|
|
123
|
+
```ts
|
|
124
|
+
.addStep({ name: "one", handler: () => {}, rollbackKeys: ["a"], rollback: async () => {} })
|
|
125
|
+
.addStep({ name: "two", handler: () => {}, clean: ["a"] });
|
|
126
|
+
// StepDefinitionError: reserved by rollbackKeys
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Correct:
|
|
130
|
+
```ts
|
|
131
|
+
.addStep({ name: "one", handler: () => {}, rollbackKeys: ["a"], rollback: async () => {} })
|
|
132
|
+
.addStep({ name: "two", handler: () => {} }); // leave "a" alone; it stays reserved
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Once a key is reserved by any step's `rollbackKeys`, no step — including that one — may list it in `clean`, for the rest of the script, across phase boundaries; `addStep` throws `StepDefinitionError` at build time, before anything runs.
|
|
136
|
+
|
|
137
|
+
Source: src/script.ts addStep() conflicts check; docs/guides/cleaning-context.md; test/rollback-keys.test.ts
|
|
138
|
+
|
|
139
|
+
### MEDIUM Assuming a skipped step (when: false) does not run its clean
|
|
140
|
+
|
|
141
|
+
Wrong:
|
|
142
|
+
```ts
|
|
143
|
+
// developer expects `secret` to survive because "optional" never ran
|
|
144
|
+
.addStep({ name: "seed", handler: ({ input }) => ({ secret: input.secret }) })
|
|
145
|
+
.addStep({ name: "optional", when: () => false, handler: () => ({}), clean: ["secret"] })
|
|
146
|
+
.addStep({ name: "later", handler: ({ ctx }) => { ctx.secret; /* compile error — gone */ } });
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Correct:
|
|
150
|
+
```ts
|
|
151
|
+
// if "secret" should only be dropped when "optional" actually ran, clean elsewhere
|
|
152
|
+
.addStep({ name: "seed", handler: ({ input }) => ({ secret: input.secret }) })
|
|
153
|
+
.addStep({ name: "optional", when: () => false, handler: () => ({}) })
|
|
154
|
+
.addStep({ name: "use it then drop it", handler: ({ ctx }) => ({}), clean: ["secret"] });
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
`clean` is part of the declared shape of the context, not of the work performed, so it applies even when the step is skipped by `when` — runtime and types never disagree about what a skipped step removed.
|
|
158
|
+
|
|
159
|
+
Source: docs/guides/cleaning-context.md; src/script.ts phaseLoop skip branch (drop(ctx, item.def.clean))
|
|
160
|
+
|
|
161
|
+
### HIGH Tension: Simple first draft vs. rollbackKeys reservation permanence
|
|
162
|
+
|
|
163
|
+
Adding a rollback to a working script pulls in a permanent constraint that a plain script never had to deal with. Agents optimizing for this skill's goal (a clean, minimal context) tend to leave existing `clean` calls untouched when a rollback is added elsewhere, then hit an unrelated `StepDefinitionError` because they don't account for `rollbackKeys` permanently reserving that key across the whole script, including earlier or later phases.
|
|
164
|
+
|
|
165
|
+
See also: skills/compensation-safety/adding-rollbacks/SKILL.md § Common Mistakes
|