@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,199 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: 'conditional-steps'
|
|
3
|
+
description: >
|
|
4
|
+
Covers `when` on `addStep` and `addPhase` for skipping work, the `"skipped"` StepStatus,
|
|
5
|
+
and how a skip interacts with `clean`, `rollback`, and a mounted routine's own resolved
|
|
6
|
+
input. Load this for "run this step only in production," "skip a whole phase based on
|
|
7
|
+
input," or when a rollback isn't firing and the step turns out to have been skipped.
|
|
8
|
+
metadata:
|
|
9
|
+
type: 'core'
|
|
10
|
+
library: 'stagehand'
|
|
11
|
+
library_version: '0.5.3'
|
|
12
|
+
sources:
|
|
13
|
+
- 'miaklwalker/stagehand:docs/guides/phases-and-steps.md'
|
|
14
|
+
- 'miaklwalker/stagehand:docs/guides/reusable-scripts.md'
|
|
15
|
+
- 'miaklwalker/stagehand:src/script.ts'
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
# Stagehand — Conditional Steps and Phases
|
|
19
|
+
|
|
20
|
+
`when` on `addStep` or `addPhase` is a function of `{ input, ctx }` (may be async) that gates whether the step, or every step in the phase, runs at all. A falsy result marks the step `"skipped"` — it never runs its handler and never runs its `rollback`.
|
|
21
|
+
|
|
22
|
+
## Setup
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
import { Script } from "@michaelrwalker/stagehand";
|
|
26
|
+
|
|
27
|
+
interface Input {
|
|
28
|
+
environment: "development" | "production";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const script = new Script<Input>({ name: "deploy" })
|
|
32
|
+
.addPhase("Release")
|
|
33
|
+
.addStep({
|
|
34
|
+
name: "notify slack",
|
|
35
|
+
when: ({ input }) => input.environment === "production",
|
|
36
|
+
handler: async () => {
|
|
37
|
+
// only runs when input.environment === "production"
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
await script.run({ environment: "production" });
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Core Patterns
|
|
45
|
+
|
|
46
|
+
### Gate a single step on input or ctx
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
new Script<{ environment: "development" | "production" }>({ name: "deploy" })
|
|
50
|
+
.addPhase("Release")
|
|
51
|
+
.addStep({ name: "resolve commit", handler: async () => ({ sha: "abc123" }) })
|
|
52
|
+
.addStep({
|
|
53
|
+
name: "notify slack",
|
|
54
|
+
when: ({ input, ctx }) => input.environment === "production" && ctx.sha.length > 0,
|
|
55
|
+
handler: async () => {
|
|
56
|
+
/* notify */
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`when` sees the same `{ input, ctx }` shape a handler does, so it can read anything an earlier step already put in context.
|
|
62
|
+
|
|
63
|
+
### Gate an entire phase
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
new Script<{ environment: "development" | "production" }>({ name: "deploy" })
|
|
67
|
+
.addPhase("Validation")
|
|
68
|
+
.addStep({ name: "check permissions", handler: async () => ({}) })
|
|
69
|
+
.addPhase("Release", {
|
|
70
|
+
when: ({ input }) => input.environment === "production",
|
|
71
|
+
})
|
|
72
|
+
.addStep({ name: "notify slack", handler: async () => {} })
|
|
73
|
+
.addStep({ name: "publish", handler: async () => {} });
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
A falsy phase-level `when` marks every step in `"Release"` `"skipped"` in one shot, before any of those steps' own `when` (if they had one) is ever evaluated.
|
|
77
|
+
|
|
78
|
+
### Async `when`
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
.addStep({
|
|
82
|
+
name: "run migration",
|
|
83
|
+
when: async ({ ctx }) => await isMigrationPending(ctx.dbUrl),
|
|
84
|
+
handler: async () => runMigration(),
|
|
85
|
+
})
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`when` may return a promise; the script awaits it before deciding whether to run or skip the step (or, at phase level, the whole phase).
|
|
89
|
+
|
|
90
|
+
## Common Mistakes
|
|
91
|
+
|
|
92
|
+
### MEDIUM Skipped step's `clean` still runs
|
|
93
|
+
|
|
94
|
+
Wrong:
|
|
95
|
+
```ts
|
|
96
|
+
// developer expects `secret` to survive because "optional" never ran
|
|
97
|
+
new Script<{ secret: string }>({ name: "t" })
|
|
98
|
+
.addStep({ name: "seed", handler: ({ input }) => ({ secret: input.secret }) })
|
|
99
|
+
.addStep({ name: "optional", when: () => false, handler: () => ({}), clean: ["secret"] })
|
|
100
|
+
.addStep({ name: "later", handler: ({ ctx }) => {
|
|
101
|
+
ctx.secret; // compile error — gone
|
|
102
|
+
return {};
|
|
103
|
+
} });
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Correct:
|
|
107
|
+
```ts
|
|
108
|
+
// if "secret" should only be dropped when "optional" actually ran, clean elsewhere
|
|
109
|
+
new Script<{ secret: string }>({ name: "t" })
|
|
110
|
+
.addStep({ name: "seed", handler: ({ input }) => ({ secret: input.secret }) })
|
|
111
|
+
.addStep({ name: "optional", when: () => false, handler: () => ({}) })
|
|
112
|
+
.addStep({ name: "use it then drop it", handler: ({ ctx }) => ({}), clean: ["secret"] });
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
`clean` is part of a step's declared shape, not of the work it performs, so `drop(ctx, item.def.clean)` runs in the skip branch exactly as it does after a normal run — runtime and types never disagree about what a skipped step removed.
|
|
116
|
+
|
|
117
|
+
Source: docs/guides/cleaning-context.md; src/script.ts phaseLoop skip branch (`drop(ctx, item.def.clean)`)
|
|
118
|
+
|
|
119
|
+
### LOW Expecting a skipped step's rollback to fire
|
|
120
|
+
|
|
121
|
+
Wrong:
|
|
122
|
+
```ts
|
|
123
|
+
.addStep({
|
|
124
|
+
name: "notify slack",
|
|
125
|
+
when: ({ input }) => input.environment === "production",
|
|
126
|
+
handler: async () => notify(),
|
|
127
|
+
rollback: async () => undoNotify(), // assumed to fire if a later step fails and this was "run"
|
|
128
|
+
})
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Correct:
|
|
132
|
+
```ts
|
|
133
|
+
// rely on `when` purely as a gate; don't design a rollback around a skip firing it
|
|
134
|
+
.addStep({
|
|
135
|
+
name: "notify slack",
|
|
136
|
+
when: ({ input }) => input.environment === "production",
|
|
137
|
+
handler: async () => notify(),
|
|
138
|
+
rollback: async () => undoNotify(), // correct: only fires if this step actually ran and succeeded
|
|
139
|
+
})
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
A step marked `"skipped"` never enters the `completed` list the unwind walks, so its `rollback` never runs — there is nothing to compensate for.
|
|
143
|
+
|
|
144
|
+
Source: docs/guides/phases-and-steps.md
|
|
145
|
+
|
|
146
|
+
### HIGH Step-level `when` can't re-enable a step inside a skipped phase
|
|
147
|
+
|
|
148
|
+
Wrong:
|
|
149
|
+
```ts
|
|
150
|
+
new Script<{ environment: string }>({ name: "t" })
|
|
151
|
+
.addPhase("Release", { when: ({ input }) => input.environment === "production" })
|
|
152
|
+
.addStep({ name: "always run this", when: () => true, handler: async () => ({}) });
|
|
153
|
+
// still skipped — the phase gate short-circuits before step.when is ever checked
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Correct:
|
|
157
|
+
```ts
|
|
158
|
+
// move a step that must always run outside the conditional phase
|
|
159
|
+
new Script<{ environment: string }>({ name: "t" })
|
|
160
|
+
.addPhase("Always")
|
|
161
|
+
.addStep({ name: "always run this", handler: async () => ({}) })
|
|
162
|
+
.addPhase("Release", { when: ({ input }) => input.environment === "production" })
|
|
163
|
+
.addStep({ name: "notify slack", handler: async () => ({}) });
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Phase-level `when` is evaluated once per phase; when it resolves falsy, every step in that phase is marked skipped in a loop that never touches `item.def.when` at all.
|
|
167
|
+
|
|
168
|
+
Source: src/script.ts run() phaseLoop (`phase.options.when` checked before any `step.when`)
|
|
169
|
+
|
|
170
|
+
### MEDIUM `when` inside a mounted routine sees the mount's input, not the host's
|
|
171
|
+
|
|
172
|
+
Wrong:
|
|
173
|
+
```ts
|
|
174
|
+
// inside routines/channel.ts, built with routineFor<{ channel: string }>()
|
|
175
|
+
.addStep({ name: "gate", when: ({ input }) => input.hostOnlyFlag, handler: async () => ({}) })
|
|
176
|
+
// TypeScript already prevents referencing hostOnlyFlag if routineFor's In doesn't include it —
|
|
177
|
+
// but this is exactly the trap when In is loosely typed or the mapper over-forwards fields
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Correct:
|
|
181
|
+
```ts
|
|
182
|
+
import { routineFor } from "@michaelrwalker/stagehand";
|
|
183
|
+
|
|
184
|
+
export const pullChannel = routineFor<{ channel: string; since: string }>()(
|
|
185
|
+
"pull channel",
|
|
186
|
+
(script) =>
|
|
187
|
+
script.addStep({
|
|
188
|
+
name: "gate",
|
|
189
|
+
when: ({ input }) => input.channel === "amazon",
|
|
190
|
+
handler: async () => ({}),
|
|
191
|
+
}),
|
|
192
|
+
);
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
A mount's input is resolved once, from a fixed value or an `{ input, ctx } => SubIn` mapper passed to `use()`, and every `when` inside that fragment — phase- or step-level — is evaluated against that resolved input, never the host script's own `input`.
|
|
196
|
+
|
|
197
|
+
Source: docs/guides/reusable-scripts.md ("Giving a mount its own input"); src/script.ts `use()`/`run()` mount input resolution
|
|
198
|
+
|
|
199
|
+
See also: skills/reuse/reusable-routines-and-mounts/SKILL.md — `when` inside a mounted fragment evaluates against the mount's resolved input, not the host's — easy to miss without reading both.
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: 'handling-results-and-errors'
|
|
3
|
+
description: >
|
|
4
|
+
Covers the RunResult discriminated union returned by run(), every exported error class
|
|
5
|
+
(StepFailedError, StepTimeoutError, ScriptAbortedError, RollbackFailedError, CacheShapeError,
|
|
6
|
+
SchemaValidationError), the isAbort(error) helper, and the throwOnError script option. Load this
|
|
7
|
+
for "branch correctly on a failed run," "tell a Ctrl-C cancellation apart from a real failure,"
|
|
8
|
+
or "get the phase/step name a failure happened at."
|
|
9
|
+
metadata:
|
|
10
|
+
type: 'core'
|
|
11
|
+
library: 'stagehand'
|
|
12
|
+
library_version: '0.5.3'
|
|
13
|
+
sources:
|
|
14
|
+
- 'miaklwalker/stagehand:docs/guides/rollbacks.md'
|
|
15
|
+
- 'miaklwalker/stagehand:docs/guides/script-options-and-result.md'
|
|
16
|
+
- 'miaklwalker/stagehand:docs/reference/errors.md'
|
|
17
|
+
- 'miaklwalker/stagehand:src/errors.ts'
|
|
18
|
+
- 'miaklwalker/stagehand:src/script.ts'
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
# Stagehand — Handling Results and Errors
|
|
22
|
+
|
|
23
|
+
`run()` never throws for an ordinary step failure — it resolves to a discriminated union on `ok`. A handful of exported error classes exist for callers building their own reporting, but most of them never propagate through `run()` itself; knowing which ones do (and which don't) is the whole game.
|
|
24
|
+
|
|
25
|
+
## Setup
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
import { Script, isAbort } from "@michaelrwalker/stagehand";
|
|
29
|
+
|
|
30
|
+
const deploy = new Script<{ env: string }>({ name: "deploy" })
|
|
31
|
+
.addStep({
|
|
32
|
+
name: "build",
|
|
33
|
+
handler: async ({ ctx }) => ({ releaseId: "r-123" }),
|
|
34
|
+
})
|
|
35
|
+
.addStep({
|
|
36
|
+
name: "publish",
|
|
37
|
+
handler: async ({ ctx }) => {
|
|
38
|
+
if (ctx.releaseId === undefined) throw new Error("no release to publish");
|
|
39
|
+
return { published: true };
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const result = await deploy.run({ env: "production" });
|
|
44
|
+
|
|
45
|
+
if (result.ok) {
|
|
46
|
+
console.log("deployed", result.ctx.releaseId);
|
|
47
|
+
} else if (isAbort(result.error) && result.status === "aborted") {
|
|
48
|
+
console.log("cancelled");
|
|
49
|
+
} else {
|
|
50
|
+
console.error(`failed at ${result.failedAt?.phase} › ${result.failedAt?.step}`, result.error);
|
|
51
|
+
process.exitCode = 1;
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Core Patterns
|
|
56
|
+
|
|
57
|
+
### Branch on the full RunResult shape
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
const result = await deploy.run(input);
|
|
61
|
+
|
|
62
|
+
if (result.ok) {
|
|
63
|
+
// result.status === "success"; result.ctx is fully typed
|
|
64
|
+
result.ctx.releaseId;
|
|
65
|
+
} else {
|
|
66
|
+
// result.status is "failed" | "aborted"
|
|
67
|
+
console.error(`failed at ${result.failedAt?.phase} › ${result.failedAt?.step}`);
|
|
68
|
+
console.error(result.error); // the original thrown value, unwrapped
|
|
69
|
+
console.error(result.ctx); // Partial<Ctx> — whatever was produced before failure
|
|
70
|
+
for (const r of result.rollbacks) {
|
|
71
|
+
console.error(`rollback for "${r.step}": ${r.ok ? "ok" : "failed"}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`result.ctx` on failure is not reverted by compensation — a rollback undoes external side effects, it does not erase what was already recorded in `ctx`.
|
|
77
|
+
|
|
78
|
+
### Tell cancellation apart from an ordinary failure
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
const result = await deploy.run(input, { signal: controller.signal });
|
|
82
|
+
|
|
83
|
+
if (!result.ok) {
|
|
84
|
+
if (isAbort(result.error) && result.status === "aborted") {
|
|
85
|
+
console.log("canceled from outside (Ctrl-C or an external signal)");
|
|
86
|
+
} else {
|
|
87
|
+
console.error("handler failure:", result.error);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`isAbort(error)` returns `true` for a `ScriptAbortedError` or any `Error` named `"AbortError"` — checking `result.status === "aborted"` alongside it confirms the abort was the script's own, not an unrelated `AbortError` a handler happened to throw.
|
|
93
|
+
|
|
94
|
+
### Use `throwOnError` when the script is one stage of a larger flow
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
import { SchemaValidationError } from "@michaelrwalker/stagehand";
|
|
98
|
+
|
|
99
|
+
const stage = new Script<{ env: string }>({ name: "stage", throwOnError: true })
|
|
100
|
+
.addStep({ name: "one", handler: () => ({ done: true }) });
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
const result = await stage.run({ env: "production" });
|
|
104
|
+
console.log(result.ctx.done); // reached only on success — a failure rejects instead
|
|
105
|
+
} catch (error) {
|
|
106
|
+
if (error instanceof SchemaValidationError) console.error(error.issues);
|
|
107
|
+
else throw error; // the original error a step threw, or a ScriptAbortedError
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Compensation still runs to completion before the rejection; `throwOnError` only changes how the outcome is reported.
|
|
112
|
+
|
|
113
|
+
## Common Mistakes
|
|
114
|
+
|
|
115
|
+
### CRITICAL wrapping run() in try/catch expecting it to throw
|
|
116
|
+
|
|
117
|
+
Wrong:
|
|
118
|
+
```ts
|
|
119
|
+
try {
|
|
120
|
+
const result = await deploy.run(input);
|
|
121
|
+
console.log("deployed", result.ctx.releaseId);
|
|
122
|
+
} catch (e) {
|
|
123
|
+
console.error(e);
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Correct:
|
|
128
|
+
```ts
|
|
129
|
+
const result = await deploy.run(input);
|
|
130
|
+
if (!result.ok) {
|
|
131
|
+
console.error(result.error);
|
|
132
|
+
process.exitCode = 1;
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
console.log("deployed", result.ctx.releaseId);
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
`run()` resolves to `{ ok: false, ... }` by default; nothing throws unless `throwOnError` is set, so the `catch` block never fires and a failed run is silently treated as if nothing happened.
|
|
139
|
+
|
|
140
|
+
Source: docs/guides/script-options-and-result.md; src/script.ts `run()`
|
|
141
|
+
|
|
142
|
+
### HIGH assuming a bad `defineInput` schema shows up as `{ ok: false }`
|
|
143
|
+
|
|
144
|
+
Wrong:
|
|
145
|
+
```ts
|
|
146
|
+
const result = await deploy.run(rawInput);
|
|
147
|
+
if (!result.ok) { /* never reached on bad input — it throws instead */ }
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Correct:
|
|
151
|
+
```ts
|
|
152
|
+
try {
|
|
153
|
+
const result = await deploy.run(rawInput);
|
|
154
|
+
} catch (error) {
|
|
155
|
+
if (error instanceof SchemaValidationError) console.error(error.issues);
|
|
156
|
+
else throw error;
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
`SchemaValidationError` is always thrown directly by `run()`, before any phase executes, and is unaffected by `throwOnError` — there is no partial result to produce since nothing ran yet.
|
|
161
|
+
|
|
162
|
+
Source: docs/guides/typed-context.md; docs/reference/errors.md
|
|
163
|
+
|
|
164
|
+
### MEDIUM assuming a failing rollback replaces `result.error`
|
|
165
|
+
|
|
166
|
+
Wrong:
|
|
167
|
+
```ts
|
|
168
|
+
const result = await script.run(input);
|
|
169
|
+
if (!result.ok) console.error(result.error); // assumed to be the rollback's error if one failed
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Correct:
|
|
173
|
+
```ts
|
|
174
|
+
const result = await script.run(input);
|
|
175
|
+
if (!result.ok) {
|
|
176
|
+
console.error("run failed:", result.error);
|
|
177
|
+
for (const r of result.rollbacks) if (!r.ok) console.error("rollback failed:", r.step, r.error);
|
|
178
|
+
}
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
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.
|
|
182
|
+
|
|
183
|
+
Source: docs/guides/rollbacks.md; docs/reference/errors.md (`RollbackFailedError`)
|
|
184
|
+
|
|
185
|
+
### MEDIUM trusting `AbortError`'s name to mean the script itself was cancelled
|
|
186
|
+
|
|
187
|
+
Wrong (inside a handler, defeats retry):
|
|
188
|
+
```ts
|
|
189
|
+
handler: async ({ signal }) => {
|
|
190
|
+
const localController = new AbortController();
|
|
191
|
+
setTimeout(() => localController.abort(), 200); // unrelated to `signal`
|
|
192
|
+
return fetch(url, { signal: localController.signal }); // throws AbortError → retry silently skipped
|
|
193
|
+
},
|
|
194
|
+
retry: { attempts: 3 },
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
```ts
|
|
198
|
+
handler: async ({ signal }) => fetch(url, { signal }), // forward the step's own signal
|
|
199
|
+
retry: { attempts: 3, retryIf: (error) => !isAbort(error) },
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Wrong (after `run()`, misreads an unrelated AbortError as user cancellation):
|
|
203
|
+
```ts
|
|
204
|
+
if (!result.ok && isAbort(result.error)) {
|
|
205
|
+
console.log("user pressed Ctrl-C"); // could actually be an unrelated internal AbortError
|
|
206
|
+
}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Correct:
|
|
210
|
+
```ts
|
|
211
|
+
if (!result.ok && isAbort(result.error) && result.status === "aborted") {
|
|
212
|
+
console.log("cancelled");
|
|
213
|
+
}
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
`isAbort()` returns `true` for a `ScriptAbortedError` *or* any `Error` whose `name` is exactly `"AbortError"` — not just ones tied to the script's own signal. Inside `executeStep`'s retry loop, that check runs before the retry-budget check, so a handler's own unrelated `AbortController` (e.g. a hand-rolled per-call timeout) silently skips `retry`/`retryIf` entirely; after `run()` returns, the same permissiveness means `isAbort(result.error)` alone doesn't prove the script itself was aborted — pair it with `result.status === "aborted"`.
|
|
217
|
+
|
|
218
|
+
Source: src/errors.ts `isAbort()`; src/script.ts `executeStep()` (isAbort check precedes the retry-budget check); docs/guides/rollbacks.md ("Signals and cancellation")
|
|
219
|
+
|
|
220
|
+
### HIGH checking `result.error instanceof StepFailedError`
|
|
221
|
+
|
|
222
|
+
Wrong:
|
|
223
|
+
```ts
|
|
224
|
+
if (!result.ok && result.error instanceof StepFailedError) {
|
|
225
|
+
console.error(result.error.step, result.error.attempts); // never true — error is the raw throw
|
|
226
|
+
}
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Correct:
|
|
230
|
+
```ts
|
|
231
|
+
if (!result.ok) {
|
|
232
|
+
console.error(result.failedAt?.phase, result.failedAt?.step, result.error);
|
|
233
|
+
}
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
`result.error` on a failed run is the original value the handler threw, not automatically wrapped in `StepFailedError` — that class exists for callers building their own reporting elsewhere; the phase/step is available on `result.failedAt` instead.
|
|
237
|
+
|
|
238
|
+
Source: docs/reference/errors.md ("result.error on a failed run is the original thrown value, not automatically wrapped in this type")
|
|
239
|
+
|
|
240
|
+
### MEDIUM assuming `throwOnError` skips compensation and fails fast
|
|
241
|
+
|
|
242
|
+
Wrong:
|
|
243
|
+
```ts
|
|
244
|
+
// assumed: throwOnError means "don't bother rolling back, just blow up immediately"
|
|
245
|
+
new Script({ name: "t", throwOnError: true })
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
Correct:
|
|
249
|
+
```ts
|
|
250
|
+
// rollbacks still run to completion; throwOnError only affects how the caller is told about the outcome
|
|
251
|
+
new Script({ name: "t", throwOnError: true }) // reach for this when the script is one stage inside a larger error-handling flow
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
`throwOnError` only changes how the outcome is reported — a failing run rejects with the original error instead of resolving `{ ok: false }`, but compensation still runs first, in full, before the rejection.
|
|
255
|
+
|
|
256
|
+
Source: docs/guides/script-options-and-result.md ("Compensation still runs before the rejection")
|
|
257
|
+
|
|
258
|
+
### MEDIUM catching `RollbackFailedError` from `run()` or its result
|
|
259
|
+
|
|
260
|
+
Wrong:
|
|
261
|
+
```ts
|
|
262
|
+
try {
|
|
263
|
+
await script.run(input);
|
|
264
|
+
} catch (e) {
|
|
265
|
+
if (e instanceof RollbackFailedError) { /* never matches */ }
|
|
266
|
+
}
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
Correct:
|
|
270
|
+
```ts
|
|
271
|
+
const result = await script.run(input);
|
|
272
|
+
if (!result.ok) {
|
|
273
|
+
const failedRollbacks = result.rollbacks.filter((r) => !r.ok);
|
|
274
|
+
}
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
`RollbackFailedError` is exported for a caller's own reporting, but the library itself never throws it or puts it in `RunResult` — a failing rollback is instead recorded as `{ ok: false, error }` inside `result.rollbacks`.
|
|
278
|
+
|
|
279
|
+
Source: docs/reference/errors.md ("a failing rollback does not propagate as this error type through RunResult")
|
|
280
|
+
|
|
281
|
+
See also: skills/compensation-safety/adding-rollbacks/SKILL.md — isAbort and the retry loop's abort check directly govern whether — and how fast — a rollback's unwind gets triggered.
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: 'log-placement-and-rendering'
|
|
3
|
+
description: >
|
|
4
|
+
logPlacement modes (scrollback/step/bottom) and how the live TTY frame differs from the plain/CI renderer, including which behaviors exist only in one of the two. Load this for "nest a rollback's log under the step it's undoing," "force plain output for CI," or "match my own console output to the script's styling."
|
|
5
|
+
metadata:
|
|
6
|
+
type: 'core'
|
|
7
|
+
library: 'stagehand'
|
|
8
|
+
library_version: '0.5.3'
|
|
9
|
+
sources:
|
|
10
|
+
- 'miaklwalker/stagehand:docs/guides/handler-surface.md'
|
|
11
|
+
- 'miaklwalker/stagehand:docs/guides/terminal-rendering.md'
|
|
12
|
+
- 'miaklwalker/stagehand:test/log-placement.test.ts'
|
|
13
|
+
- 'miaklwalker/stagehand:test/note.test.ts'
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
# Stagehand — Log Placement and Terminal Rendering
|
|
17
|
+
|
|
18
|
+
`log`/`info`/`warn`/`error`/`success` all write a line; `logPlacement` (a `ScriptOptions` field) controls where that line lands in the live TTY frame. Which renderer is even active — live, in-place frame vs. plain sequential output — is chosen automatically per run, and only the live one honors `logPlacement` at all.
|
|
19
|
+
|
|
20
|
+
## Setup
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import { Script } from "@michaelrwalker/stagehand";
|
|
24
|
+
|
|
25
|
+
const script = new Script({ name: "checkout", logPlacement: "step" }).addStep({
|
|
26
|
+
name: "reserve inventory",
|
|
27
|
+
handler: async ({ log }) => {
|
|
28
|
+
log("checked warehouse A");
|
|
29
|
+
return { reservationId: "res_1" };
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
await script.run();
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Core Patterns
|
|
37
|
+
|
|
38
|
+
### Nest a rollback's log under the step it's undoing
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
import { Script } from "@michaelrwalker/stagehand";
|
|
42
|
+
|
|
43
|
+
await new Script({ name: "checkout", logPlacement: "step" })
|
|
44
|
+
.addStep({
|
|
45
|
+
name: "reserve inventory",
|
|
46
|
+
handler: () => ({ reservationId: "res_1" }),
|
|
47
|
+
rollback: ({ log }) => {
|
|
48
|
+
log("released reservation res_1");
|
|
49
|
+
},
|
|
50
|
+
})
|
|
51
|
+
.addStep({
|
|
52
|
+
name: "boom",
|
|
53
|
+
handler: () => {
|
|
54
|
+
throw new Error("boom");
|
|
55
|
+
},
|
|
56
|
+
})
|
|
57
|
+
.run();
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
With `logPlacement: "step"`, a rollback's `log()` calls render nested under the step being undone instead of scrolling past at the top of the frame, keeping compensation output next to the failure that caused it.
|
|
61
|
+
|
|
62
|
+
### Force plain, deterministic output for CI
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
import { Script } from "@michaelrwalker/stagehand";
|
|
66
|
+
|
|
67
|
+
new Script({ name: "deploy", plain: process.env.CI === "true" });
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`plain: true` forces the sequential, non-repainting renderer even on a real TTY; `plain: false` forces the live one even when piped. Leaving `plain` unset (the default) auto-detects: live only when `process.stdout.isTTY` is true and color is enabled (color is off under `NO_COLOR` or `TERM=dumb`, on under `FORCE_COLOR`, otherwise on exactly when stdout is a TTY).
|
|
71
|
+
|
|
72
|
+
### Roll a live tail of recent lines instead of nesting per step
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
import { Script } from "@michaelrwalker/stagehand";
|
|
76
|
+
|
|
77
|
+
new Script({ name: "sync", logPlacement: "bottom" }).addStep({
|
|
78
|
+
name: "sync records",
|
|
79
|
+
handler: async ({ log }) => {
|
|
80
|
+
log("synced batch 1");
|
|
81
|
+
log("synced batch 2");
|
|
82
|
+
return { batches: 2 };
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`"bottom"` renders a single rolling tail of the most recent lines below the whole phase tree (not per step), which is cheaper to scan when many steps are logging concurrently. Like `"step"`, it keeps only the most recent handful of entries.
|
|
88
|
+
|
|
89
|
+
### Match custom console output to the script's own styling
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
import { formatDuration, isColorEnabled, palette, setColorEnabled, symbols } from "@michaelrwalker/stagehand";
|
|
93
|
+
|
|
94
|
+
console.log(`${symbols.success} deploy finished in ${formatDuration(4084)}`);
|
|
95
|
+
console.log(palette.warning("cache stale, rebuilding"));
|
|
96
|
+
|
|
97
|
+
setColorEnabled(false); // override auto-detection directly, rather than via NO_COLOR/FORCE_COLOR
|
|
98
|
+
isColorEnabled(); // false
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
`symbols` degrades automatically on terminals without solid Unicode support (`✔` becomes `√`, and so on) and `palette` exposes the same 256-color stylers (`accent`, `success`, `warning`, `error`, `info`, `muted`, `faint`, `bold`, `dim`) the built-in renderer uses.
|
|
102
|
+
|
|
103
|
+
## Common Mistakes
|
|
104
|
+
|
|
105
|
+
### MEDIUM Treating step/bottom placement as a complete run record
|
|
106
|
+
|
|
107
|
+
Wrong:
|
|
108
|
+
```ts
|
|
109
|
+
new Script({ name: "audit", logPlacement: "bottom" });
|
|
110
|
+
// assumed every log() call is retrievable after the run for compliance review
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Correct:
|
|
114
|
+
```ts
|
|
115
|
+
new Script({ name: "audit", logPlacement: "scrollback" });
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Both `"step"` and `"bottom"` keep only the most recent handful of lines per step or per run — only `"scrollback"` (the default) is guaranteed to retain every line, in order, for the whole run.
|
|
119
|
+
|
|
120
|
+
Source: docs/guides/handler-surface.md; docs/guides/terminal-rendering.md
|
|
121
|
+
|
|
122
|
+
### LOW Assuming logPlacement behaves the same in CI as it did locally
|
|
123
|
+
|
|
124
|
+
Wrong:
|
|
125
|
+
```ts
|
|
126
|
+
// developer tunes `logPlacement: "step"` by eye in a local terminal,
|
|
127
|
+
// then is confused that CI logs show every line in plain top-level order instead
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Correct:
|
|
131
|
+
```ts
|
|
132
|
+
// treat logPlacement as a live-TTY-only concern; CI output is always flat and complete
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Only the live renderer honors `logPlacement` — the plain/CI renderer (chosen automatically off a TTY, under `NO_COLOR`, or `TERM=dumb`) is already sequential and prints every line immediately in order regardless of the setting.
|
|
136
|
+
|
|
137
|
+
Source: docs/guides/terminal-rendering.md; docs/guides/handler-surface.md
|
|
138
|
+
|
|
139
|
+
### LOW Hardcoding Stagehand's glyphs into a script's own console output
|
|
140
|
+
|
|
141
|
+
Wrong:
|
|
142
|
+
```ts
|
|
143
|
+
console.log(`✔ done`); // renders as mojibake on the same terminals Stagehand degrades for
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Correct:
|
|
147
|
+
```ts
|
|
148
|
+
import { symbols, palette } from "@michaelrwalker/stagehand";
|
|
149
|
+
|
|
150
|
+
console.log(`${symbols.success} done`);
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Stagehand degrades its own symbols automatically on terminals without solid Unicode support, but that degradation is internal to the renderer and does not apply to anything a script prints itself unless it goes through the exported `symbols`/`palette` helpers.
|
|
154
|
+
|
|
155
|
+
Source: docs/guides/terminal-rendering.md
|
|
156
|
+
|
|
157
|
+
### HIGH Tension: Rich live observability vs. complete log record
|
|
158
|
+
|
|
159
|
+
`logPlacement: "step"`/`"bottom"` produce the most pleasant live-TTY experience by capping what they retain, while `"scrollback"` is the only placement that is both complete and the default, and one choice applies to the whole script. Agents optimizing for this skill's goal tend to pick a locally-nice-looking placement like `"step"` as a script-wide default because they don't account for the need — covered by progress reporting — for a guaranteed-complete CI/audit trail from the same run.
|
|
160
|
+
|
|
161
|
+
See also: skills/observability/reporting-progress/SKILL.md § Common Mistakes
|
|
162
|
+
|
|
163
|
+
### HIGH Tension: Routine authoring instincts vs. host-controlled ScriptOptions
|
|
164
|
+
|
|
165
|
+
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, but every one of those is discarded the moment it is mounted since only the host's own `ScriptOptions` ever apply. Agents optimizing for this skill's goal tend to bake `logPlacement` choices into a reusable sub-script and assume they travel with it because they don't account for mounting's constraint that a routine's own `ScriptOptions` are overridden entirely by the host script.
|
|
166
|
+
|
|
167
|
+
See also: skills/reuse/reusable-routines-and-mounts/SKILL.md § Common Mistakes, skills/compensation-safety/adding-rollbacks/SKILL.md § Common Mistakes
|