@michaelrwalker/stagehand 0.5.3 → 0.6.0
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/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,210 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: 'getting-started'
|
|
3
|
+
description: >
|
|
4
|
+
Build a complete Stagehand script end to end — Script constructor, addPhase/addStep, a handler
|
|
5
|
+
that widens the typed context, a rollback, and reading the RunResult. Load this first for any
|
|
6
|
+
"write my first script" or "turn this async function chain into Stagehand" request.
|
|
7
|
+
metadata:
|
|
8
|
+
type: 'lifecycle'
|
|
9
|
+
library: 'stagehand'
|
|
10
|
+
library_version: '0.5.3'
|
|
11
|
+
sources:
|
|
12
|
+
- 'miaklwalker/stagehand:README.md'
|
|
13
|
+
- 'miaklwalker/stagehand:docs/quick-start.md'
|
|
14
|
+
- 'miaklwalker/stagehand:docs/guides/script-options-and-result.md'
|
|
15
|
+
- 'miaklwalker/stagehand:docs/guides/cleaning-context.md'
|
|
16
|
+
- 'miaklwalker/stagehand:src/script.ts'
|
|
17
|
+
- 'miaklwalker/stagehand:src/types.ts'
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
# Stagehand — Getting Started
|
|
21
|
+
|
|
22
|
+
A script is a `Script<Input>` built by chaining `addPhase`/`addStep`. Each step's handler reads
|
|
23
|
+
`input` and the typed `ctx` accumulated so far, and whatever it returns is merged into `ctx` for
|
|
24
|
+
every later step. `run()` never throws by default — it resolves to a discriminated `RunResult`.
|
|
25
|
+
|
|
26
|
+
## Setup
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
import { Script } from "@michaelrwalker/stagehand";
|
|
30
|
+
|
|
31
|
+
interface Input {
|
|
32
|
+
service: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const deploy = new Script<Input>({ name: "deploy" })
|
|
36
|
+
.addPhase("Validation")
|
|
37
|
+
.addStep({
|
|
38
|
+
name: "resolve commit",
|
|
39
|
+
handler: async ({ input, status }) => {
|
|
40
|
+
status("querying git");
|
|
41
|
+
return { sha: await resolveSha(input.service) };
|
|
42
|
+
},
|
|
43
|
+
})
|
|
44
|
+
.addPhase("Release")
|
|
45
|
+
.addStep({
|
|
46
|
+
name: "upload artifact",
|
|
47
|
+
handler: async ({ ctx }) => {
|
|
48
|
+
// ctx.sha is typed here — produced by the previous step
|
|
49
|
+
return { uploadId: await upload(ctx.sha) };
|
|
50
|
+
},
|
|
51
|
+
rollbackKeys: ["sha"],
|
|
52
|
+
rollback: async ({ ctx, output }) => {
|
|
53
|
+
await cdn.delete(output.uploadId, ctx.sha);
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const result = await deploy.run({ service: "api" });
|
|
58
|
+
|
|
59
|
+
if (result.ok) {
|
|
60
|
+
console.log(`released ${result.ctx.uploadId} from ${result.ctx.sha}`);
|
|
61
|
+
} else {
|
|
62
|
+
console.error(`failed at ${result.failedAt?.phase} › ${result.failedAt?.step}`);
|
|
63
|
+
console.error(result.error);
|
|
64
|
+
process.exitCode = 1;
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Core Patterns
|
|
69
|
+
|
|
70
|
+
### Grouping steps into phases
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
new Script<{ id: string }>({ name: "provision" })
|
|
74
|
+
.addPhase("Fetch")
|
|
75
|
+
.addStep({
|
|
76
|
+
name: "load user",
|
|
77
|
+
handler: async ({ input }) => ({ user: await db.user(input.id) }),
|
|
78
|
+
})
|
|
79
|
+
.addPhase("Provision")
|
|
80
|
+
.addStep({
|
|
81
|
+
name: "create tenant",
|
|
82
|
+
handler: async ({ ctx }) => ({ tenantId: await api.create(ctx.user.org) }),
|
|
83
|
+
});
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`addPhase` opens a new phase; every `addStep` that follows lands in it until the next `addPhase`.
|
|
87
|
+
|
|
88
|
+
### Context accumulates purely from what a handler returns
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
new Script<{ id: string }>()
|
|
92
|
+
.addStep({ handler: () => ({ token: "x" }) }) // Ctx = { token: string }
|
|
93
|
+
.addStep({ handler: ({ ctx }) => ({ n: ctx.token.length }) });
|
|
94
|
+
// Ctx = { token: string; n: number }
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
No generic is written by hand — each handler's return value widens `Ctx` for every step after it,
|
|
98
|
+
and a handler that returns nothing leaves it unchanged.
|
|
99
|
+
|
|
100
|
+
### Compensating a mutation with rollback and rollbackKeys
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
new Script<{ accountId: string; amount: number }>({ name: "charge" })
|
|
104
|
+
.addStep({ name: "read input", handler: ({ input }) => ({ ...input }) })
|
|
105
|
+
.addStep({
|
|
106
|
+
name: "reserve funds",
|
|
107
|
+
handler: async ({ ctx }) => ({ reservationId: await reserve(ctx.accountId, ctx.amount) }),
|
|
108
|
+
rollbackKeys: ["accountId", "reservationId"],
|
|
109
|
+
rollback: async ({ ctx }) => {
|
|
110
|
+
// ctx is { accountId: string; reservationId: string } — and nothing else
|
|
111
|
+
await release(ctx.accountId, ctx.reservationId);
|
|
112
|
+
},
|
|
113
|
+
})
|
|
114
|
+
.addStep({
|
|
115
|
+
name: "charge card",
|
|
116
|
+
handler: () => {
|
|
117
|
+
throw new Error("payment gateway timeout");
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Only steps that already succeeded get compensated, in reverse order, when a later step throws;
|
|
123
|
+
`rollbackKeys` narrows what the rollback's `ctx` contains to exactly those keys.
|
|
124
|
+
|
|
125
|
+
### Naming a script with the bare-string shorthand
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
const validate = new Script("validate") // shorthand for { name: "validate" }
|
|
129
|
+
.addStep({ name: "check input", handler: ({ input }) => ({ ok: true }) });
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Reach for the object form (`{ name, description, rollback, throwOnError, ... }`) once you need
|
|
133
|
+
anything beyond the name.
|
|
134
|
+
|
|
135
|
+
## Common Mistakes
|
|
136
|
+
|
|
137
|
+
### CRITICAL Wrapping run() in try/catch expecting it to throw
|
|
138
|
+
|
|
139
|
+
Wrong:
|
|
140
|
+
```ts
|
|
141
|
+
try {
|
|
142
|
+
const result = await deploy.run(input);
|
|
143
|
+
console.log("deployed", result.ctx.releaseId);
|
|
144
|
+
} catch (e) {
|
|
145
|
+
console.error(e);
|
|
146
|
+
}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Correct:
|
|
150
|
+
```ts
|
|
151
|
+
const result = await deploy.run(input);
|
|
152
|
+
if (!result.ok) {
|
|
153
|
+
console.error(result.error);
|
|
154
|
+
process.exitCode = 1;
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
console.log("deployed", result.ctx.releaseId);
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
`run()` resolves to `{ ok: false, ... }` by default; nothing throws unless `throwOnError` is set, so the try/catch never fires and a failed run is silently treated as if it succeeded.
|
|
161
|
+
|
|
162
|
+
Source: docs/guides/script-options-and-result.md; src/script.ts run()
|
|
163
|
+
|
|
164
|
+
### MEDIUM Mutating ctx directly instead of returning new keys
|
|
165
|
+
|
|
166
|
+
Wrong:
|
|
167
|
+
```ts
|
|
168
|
+
handler: ({ ctx }) => {
|
|
169
|
+
ctx.total = ctx.total + 1; // "works" locally, breaks caching/rollback
|
|
170
|
+
};
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Correct:
|
|
174
|
+
```ts
|
|
175
|
+
handler: ({ ctx }) => ({ total: ctx.total + 1 });
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
`ctx` is the same live object every handler shares, but only a handler's *return value* is folded into a cached phase's delta and into a rollback's `rollbackKeys` pick, so a directly-mutated key never gets cached and is invisible to any rollback that asks for it.
|
|
179
|
+
|
|
180
|
+
Source: src/script.ts (const ctx = {} as Ctx & Record<string, unknown>; merge() only applied to handler output); docs/guides/cleaning-context.md
|
|
181
|
+
|
|
182
|
+
### HIGH Returning a bare value from a handler instead of an object
|
|
183
|
+
|
|
184
|
+
Wrong:
|
|
185
|
+
```ts
|
|
186
|
+
handler: () => {
|
|
187
|
+
const value = "me";
|
|
188
|
+
return value; // compile error — Out must be object | void
|
|
189
|
+
};
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Correct:
|
|
193
|
+
```ts
|
|
194
|
+
handler: () => {
|
|
195
|
+
const value = "me";
|
|
196
|
+
return { name: value };
|
|
197
|
+
};
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
A handler must resolve to `object | void` because whatever it returns is merged into `ctx` by key; TypeScript rejects a bare return at compile time (`Out extends object | void`), but reaching for `return value` out of habit still costs an extra iteration.
|
|
201
|
+
|
|
202
|
+
Source: src/types.ts StepDef.handler / src/script.ts addStep<Out extends object | void>(...)
|
|
203
|
+
|
|
204
|
+
### HIGH Tension: Simple first draft vs. rollbackKeys reservation permanence
|
|
205
|
+
|
|
206
|
+
The simplest script has no `rollbackKeys` and no `clean` conflicts, but adding a rollback later to harden a working script permanently reserves whatever keys it names via `rollbackKeys`. Agents optimizing for this skill's goal tend to bolt a rollback onto a working script without revisiting existing `clean` calls elsewhere because they don't account for cleaning-context's key-reservation rule, which then throws `StepDefinitionError` at build time and reads as an unrelated regression.
|
|
207
|
+
|
|
208
|
+
See also: skills/compensation-safety/adding-rollbacks/SKILL.md, skills/compensation-safety/cleaning-context/SKILL.md § Common Mistakes
|
|
209
|
+
|
|
210
|
+
See also: skills/errors-results/handling-results-and-errors/SKILL.md — the very first script already needs correct RunResult handling; run() resolving instead of throwing is the single most consequential thing to get right early.
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: 'structuring-phases-and-steps'
|
|
3
|
+
description: >
|
|
4
|
+
addPhase/addStep mechanics (flat and callback form), the implicit "Main" phase created on the first addStep, step and phase naming/uniqueness rules and DuplicateNameError, per-step retry/timeoutMs, and outline() for inspecting a script's declared structure without running it. Load this for "group these steps into phases," "retry a flaky step," "add a timeout to a step," or "inspect a script's structure without running it."
|
|
5
|
+
metadata:
|
|
6
|
+
type: 'core'
|
|
7
|
+
library: 'stagehand'
|
|
8
|
+
library_version: '0.5.3'
|
|
9
|
+
sources:
|
|
10
|
+
- 'miaklwalker/stagehand:docs/guides/phases-and-steps.md'
|
|
11
|
+
- 'miaklwalker/stagehand:docs/guides/script-options-and-result.md'
|
|
12
|
+
- 'miaklwalker/stagehand:docs/guides/reusable-scripts.md'
|
|
13
|
+
- 'miaklwalker/stagehand:docs/reference/errors.md'
|
|
14
|
+
- 'miaklwalker/stagehand:docs/reference/script.md'
|
|
15
|
+
- 'miaklwalker/stagehand:src/script.ts'
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
# Stagehand — Structuring Phases and Steps
|
|
19
|
+
|
|
20
|
+
A script is built by chaining `addPhase` and `addStep` on a `Script` instance; each call returns a re-typed `Script`, so the chain itself is the script's outline. Phase boundaries are not cosmetic — they scope rollback (`rollback: "phase"`) and identify cache slots (`"phase"` for a cached phase, `"phase::step"` for a cached step).
|
|
21
|
+
|
|
22
|
+
## Setup
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
import { Script } from "@michaelrwalker/stagehand";
|
|
26
|
+
|
|
27
|
+
interface Input {
|
|
28
|
+
service: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const deploy = new Script<Input>({ name: "deploy" })
|
|
32
|
+
.addPhase("Validation")
|
|
33
|
+
.addStep({
|
|
34
|
+
name: "resolve commit",
|
|
35
|
+
handler: async ({ input }) => ({ sha: await Promise.resolve(`${input.service}-sha`) }),
|
|
36
|
+
})
|
|
37
|
+
.addPhase("Release")
|
|
38
|
+
.addStep({
|
|
39
|
+
name: "shift traffic",
|
|
40
|
+
handler: async ({ ctx }) => ({ released: ctx.sha }),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const result = await deploy.run({ service: "checkout" });
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Core Patterns
|
|
47
|
+
|
|
48
|
+
### Group steps into phases (flat and callback form)
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
new Script<{ n: number }>({ name: "t" })
|
|
52
|
+
.addPhase("A", (script) =>
|
|
53
|
+
script
|
|
54
|
+
.addStep({ name: "double", handler: ({ input }) => ({ doubled: input.n * 2 }) })
|
|
55
|
+
.addStep({ name: "label", handler: ({ ctx }) => ({ label: `n=${ctx.doubled}` }) }),
|
|
56
|
+
)
|
|
57
|
+
.addPhase("B")
|
|
58
|
+
.addStep({ name: "use", handler: ({ ctx }) => ({ echo: ctx.label }) });
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The callback form groups a phase's steps visually while preserving the same type flow as the flat form; the two are interchangeable.
|
|
62
|
+
|
|
63
|
+
### Retry with backoff and veto specific errors
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
.addStep({
|
|
67
|
+
name: "publish",
|
|
68
|
+
retry: {
|
|
69
|
+
attempts: 4,
|
|
70
|
+
delayMs: (attempt) => attempt * 200,
|
|
71
|
+
retryIf: (error) => !/^4\d\d/.test((error as Error).message),
|
|
72
|
+
},
|
|
73
|
+
handler: async ({ attempt, warn }) => {
|
|
74
|
+
if (attempt > 1) warn(`retrying (attempt ${attempt})`);
|
|
75
|
+
await publish();
|
|
76
|
+
},
|
|
77
|
+
})
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`attempts` is the total number of tries including the first; `delayMs` runs between failed attempts, and `retryIf` can veto a retry for an error that will never succeed by retrying (a 401, for instance).
|
|
81
|
+
|
|
82
|
+
### Time out a step
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
.addStep({
|
|
86
|
+
name: "publish",
|
|
87
|
+
timeoutMs: 30_000,
|
|
88
|
+
handler: async ({ signal }) => publishWithSignal(signal),
|
|
89
|
+
})
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`timeoutMs` aborts the step's `signal` once elapsed, surfacing as `StepTimeoutError`; a timeout counts as a failed attempt, subject to the same `retryIf` if `retry` is also set.
|
|
93
|
+
|
|
94
|
+
### Inspect a script's structure without running it
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
deploy.outline();
|
|
98
|
+
// [
|
|
99
|
+
// { phase: "Validation", steps: ["resolve commit"] },
|
|
100
|
+
// { phase: "Release", steps: ["shift traffic"] },
|
|
101
|
+
// ]
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`outline()` reports every declared phase and its step names, in order, without executing any handler.
|
|
105
|
+
|
|
106
|
+
## Common Mistakes
|
|
107
|
+
|
|
108
|
+
### MEDIUM Reusing a step name within the same phase
|
|
109
|
+
|
|
110
|
+
Wrong:
|
|
111
|
+
```ts
|
|
112
|
+
.addStep({ name: "validate", handler: checkA })
|
|
113
|
+
.addStep({ name: "validate", handler: checkB }) // DuplicateNameError
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Correct:
|
|
117
|
+
```ts
|
|
118
|
+
.addStep({ name: "validate input", handler: checkA })
|
|
119
|
+
.addStep({ name: "validate permissions", handler: checkB })
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
A step name must be unique within its own phase, since a step's cache slot is `"phase::step"`; the same name in two different phases is fine, but a duplicate inside one phase throws `DuplicateNameError` at build time, before `run()` is ever called.
|
|
123
|
+
|
|
124
|
+
Source: src/script.ts addStep() DuplicateNameError check; docs/reference/errors.md
|
|
125
|
+
|
|
126
|
+
### MEDIUM Never calling addPhase, then relying on rollback: "phase"
|
|
127
|
+
|
|
128
|
+
Wrong:
|
|
129
|
+
```ts
|
|
130
|
+
new Script({ name: "t", rollback: "phase" })
|
|
131
|
+
.addStep(stepA).addStep(stepB).addStep(stepC);
|
|
132
|
+
// rollback: "phase" here behaves exactly like "all" — one phase, "Main"
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Correct:
|
|
136
|
+
```ts
|
|
137
|
+
new Script({ name: "t", rollback: "phase" })
|
|
138
|
+
.addPhase("Prepare").addStep(stepA)
|
|
139
|
+
.addPhase("Commit").addStep(stepB).addStep(stepC);
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
`addStep` creates an implicit `"Main"` phase the first time it is called if `addPhase` was never used, so with every step in that one phase, `rollback: "phase"` and `rollback: "all"` unwind identically.
|
|
143
|
+
|
|
144
|
+
Source: src/script.ts currentPhase() implicit "Main"; docs/guides/phases-and-steps.md
|
|
145
|
+
|
|
146
|
+
### HIGH Chaining .addStep() directly after .use() without opening a new phase
|
|
147
|
+
|
|
148
|
+
Wrong:
|
|
149
|
+
```ts
|
|
150
|
+
new Script({ name: "t" })
|
|
151
|
+
.use(pullChannel)
|
|
152
|
+
.addStep({ name: "sneaky", handler: () => ({}) });
|
|
153
|
+
// StepDefinitionError: Cannot add a step after use()
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Correct:
|
|
157
|
+
```ts
|
|
158
|
+
new Script({ name: "t" })
|
|
159
|
+
.use(pullChannel)
|
|
160
|
+
.addPhase("Report")
|
|
161
|
+
.addStep({ name: "summarize", handler: ({ ctx }) => ({}) });
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
A routine owns whole phases, so the last phase spliced in by `use()` belongs to the fragment, and a bare `addStep` right after it is refused at build time even though the fluent chain reads naturally.
|
|
165
|
+
|
|
166
|
+
Source: src/script.ts currentPhase() StepDefinitionError; docs/guides/reusable-scripts.md
|
|
167
|
+
|
|
168
|
+
### MEDIUM Wrapping the whole script in one giant step or phase instead of decomposing the work
|
|
169
|
+
|
|
170
|
+
Wrong:
|
|
171
|
+
```ts
|
|
172
|
+
new Script<Input>({ name: "deploy" })
|
|
173
|
+
.addStep({
|
|
174
|
+
name: "deploy",
|
|
175
|
+
handler: async ({ input }) => {
|
|
176
|
+
const sha = await resolveSha(input.ref);
|
|
177
|
+
const artifact = await compile();
|
|
178
|
+
const uploadId = await upload(artifact);
|
|
179
|
+
await shiftTraffic(uploadId);
|
|
180
|
+
return { uploadId };
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Correct:
|
|
186
|
+
```ts
|
|
187
|
+
new Script<Input>({ name: "deploy" })
|
|
188
|
+
.addPhase("Validation").addStep({ name: "resolve commit", handler: async ({ input }) => ({ sha: await resolveSha(input.ref) }) })
|
|
189
|
+
.addPhase("Build").addStep({ name: "compile bundle", handler: async () => ({ artifact: await compile() }) })
|
|
190
|
+
.addPhase("Release")
|
|
191
|
+
.addStep({ name: "upload artifact", handler: async ({ ctx }) => ({ uploadId: await upload(ctx.artifact) }), rollbackKeys: ["uploadId"], rollback: async ({ output }) => cleanup(output.uploadId) })
|
|
192
|
+
.addStep({ name: "shift traffic", handler: async ({ ctx }) => { await shiftTraffic(ctx.uploadId); return {}; } });
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
The value of phases and steps — incremental typed context, phase-scoped rollback, per-step retry/caching/observability — only shows up when the work is actually split along its natural boundaries; one opaque step gets none of it: no per-stage rollback, no per-stage cache, and a phase tree that renders as a single spinner instead of legible progress.
|
|
196
|
+
|
|
197
|
+
Source: maintainer interview ("not breaking things into distinct enough stages... writing a whole script around the framework instead of using the framework to write the whole script")
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: 'typed-context-and-input'
|
|
3
|
+
description: >
|
|
4
|
+
Covers how a Script's Ctx type parameter accumulates purely from addStep handler
|
|
5
|
+
return types (no hand-written generics), the input-vs-ctx distinction (input is
|
|
6
|
+
fixed for the run, ctx grows step by step), and defineInput for validating run()
|
|
7
|
+
input against any Standard Schema library (Zod, Valibot, ArkType, Effect Schema).
|
|
8
|
+
Load this for "validate my script's input," "why is this key missing from ctx,"
|
|
9
|
+
or "should I read input.x or ctx.x here."
|
|
10
|
+
metadata:
|
|
11
|
+
type: 'core'
|
|
12
|
+
library: 'stagehand'
|
|
13
|
+
library_version: '0.5.3'
|
|
14
|
+
sources:
|
|
15
|
+
- 'miaklwalker/stagehand:docs/guides/typed-context.md'
|
|
16
|
+
- 'miaklwalker/stagehand:docs/reference/errors.md'
|
|
17
|
+
- 'miaklwalker/stagehand:docs/reference/script.md'
|
|
18
|
+
- 'miaklwalker/stagehand:src/script.ts'
|
|
19
|
+
- 'miaklwalker/stagehand:src/types.ts'
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
# Stagehand — The Typed Context and Validated Input
|
|
23
|
+
|
|
24
|
+
`Ctx`, the second type parameter on `Script`, starts as `{}` and widens every
|
|
25
|
+
time `addStep` sees a handler that returns an object. Nothing about `Ctx` is
|
|
26
|
+
ever written by hand — it is inferred purely from handler return types.
|
|
27
|
+
|
|
28
|
+
## Setup
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
import { Script } from "@michaelrwalker/stagehand";
|
|
32
|
+
import { z } from "zod";
|
|
33
|
+
|
|
34
|
+
const deploy = new Script({ name: "deploy" })
|
|
35
|
+
.defineInput(z.object({ service: z.string() }))
|
|
36
|
+
.addStep({
|
|
37
|
+
name: "resolve commit",
|
|
38
|
+
handler: async ({ input }) => ({ sha: await git.head(input.service) }),
|
|
39
|
+
// ^ input.service: string
|
|
40
|
+
})
|
|
41
|
+
.addStep({
|
|
42
|
+
name: "build",
|
|
43
|
+
handler: ({ ctx }) => ({ artifact: `${ctx.sha}.tar.gz` }),
|
|
44
|
+
// ^ ctx.sha: string, from the previous step
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const result = await deploy.run({ service: "api" });
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Core Patterns
|
|
51
|
+
|
|
52
|
+
### Context widens automatically from return values
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
new Script({ name: "pipeline" })
|
|
56
|
+
.addStep({ name: "one", handler: () => ({ token: "x" }) })
|
|
57
|
+
// Ctx = { token: string }
|
|
58
|
+
.addStep({ name: "two", handler: ({ ctx }) => ({ n: ctx.token.length }) });
|
|
59
|
+
// Ctx = { token: string; n: number }
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Each `ctx` parameter is typed as everything every earlier step produced (minus
|
|
63
|
+
anything cleaned). A handler that returns nothing (`void`/`undefined`) leaves
|
|
64
|
+
`Ctx` unchanged; returning a key that already exists overwrites it, both at
|
|
65
|
+
the type level and at runtime, using `Object.assign` semantics.
|
|
66
|
+
|
|
67
|
+
### Reading `input` vs `ctx`
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
new Script({ name: "sync" })
|
|
71
|
+
.defineInput(z.object({ tenantId: z.string() }))
|
|
72
|
+
.addStep({
|
|
73
|
+
name: "fetch",
|
|
74
|
+
// input.tenantId is fixed for the whole run
|
|
75
|
+
// ctx starts empty here, since no earlier step has run
|
|
76
|
+
handler: async ({ input }) => ({ rows: await fetchRows(input.tenantId) }),
|
|
77
|
+
})
|
|
78
|
+
.addStep({
|
|
79
|
+
name: "summarize",
|
|
80
|
+
// ctx.rows is available now; input.tenantId still is too
|
|
81
|
+
handler: ({ input, ctx }) => ({
|
|
82
|
+
summary: `${input.tenantId}: ${ctx.rows.length} rows`,
|
|
83
|
+
}),
|
|
84
|
+
});
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`input` never changes shape mid-script (unless the step belongs to a mounted
|
|
88
|
+
routine with its own mapped input). `ctx` grows step by step — that is the
|
|
89
|
+
whole reason it carries the accumulating type parameter and `input` does not.
|
|
90
|
+
|
|
91
|
+
### Validating `run()` input with any Standard Schema library
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
import { z } from "zod";
|
|
95
|
+
import { SchemaValidationError } from "@michaelrwalker/stagehand";
|
|
96
|
+
|
|
97
|
+
const deploy = new Script({ name: "deploy" })
|
|
98
|
+
.defineInput(z.object({ service: z.string() }));
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
const result = await deploy.run({ service: "api" });
|
|
102
|
+
} catch (error) {
|
|
103
|
+
if (error instanceof SchemaValidationError) {
|
|
104
|
+
// error.issues: ReadonlyArray<StandardSchemaV1.Issue>
|
|
105
|
+
console.error(error.message); // "service: Required" etc., joined with "; "
|
|
106
|
+
} else {
|
|
107
|
+
throw error;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`defineInput` accepts any [Standard Schema](https://standardschema.dev)
|
|
113
|
+
validator (Zod, Valibot, ArkType, Effect Schema, or hand-rolled) and infers
|
|
114
|
+
`In` from its output type. Validation runs before any phase executes.
|
|
115
|
+
|
|
116
|
+
## Common Mistakes
|
|
117
|
+
|
|
118
|
+
### HIGH Mutating ctx directly instead of returning new keys
|
|
119
|
+
|
|
120
|
+
Wrong:
|
|
121
|
+
```ts
|
|
122
|
+
.addStep({
|
|
123
|
+
name: "increment",
|
|
124
|
+
handler: ({ ctx }) => {
|
|
125
|
+
ctx.total = ctx.total + 1; // "works" locally, breaks caching/rollback
|
|
126
|
+
},
|
|
127
|
+
})
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Correct:
|
|
131
|
+
```ts
|
|
132
|
+
.addStep({
|
|
133
|
+
name: "increment",
|
|
134
|
+
handler: ({ ctx }) => ({ total: ctx.total + 1 }),
|
|
135
|
+
})
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
`ctx` is the same live object every handler shares, so a stray assignment does not throw — but only a handler's *return value* is folded into a cached phase's delta and into a rollback's `rollbackKeys` pick, so a directly-mutated key is silently never cached and invisible to any rollback that asks for it.
|
|
139
|
+
|
|
140
|
+
Source: src/script.ts (merge() applied only to handler output); docs/guides/cleaning-context.md
|
|
141
|
+
|
|
142
|
+
### MEDIUM Calling defineInput after addStep/addPhase, expecting it to retype earlier steps
|
|
143
|
+
|
|
144
|
+
Wrong:
|
|
145
|
+
```ts
|
|
146
|
+
new Script({ name: "t" })
|
|
147
|
+
.addStep({ name: "one", handler: ({ input }) => ({}) }) // sees old In
|
|
148
|
+
.defineInput(z.object({ service: z.string() }));
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Correct:
|
|
152
|
+
```ts
|
|
153
|
+
new Script({ name: "t" })
|
|
154
|
+
.defineInput(z.object({ service: z.string() }))
|
|
155
|
+
.addStep({ name: "one", handler: ({ input }) => ({}) }); // input.service: string
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
`defineInput` returns a re-typed `Script`; it does not retroactively change steps already added, which still see the old `In` — it must be called first, immediately after construction.
|
|
159
|
+
|
|
160
|
+
Source: docs/guides/typed-context.md; src/script.ts defineInput()
|
|
161
|
+
|
|
162
|
+
### HIGH Assuming a failed defineInput validation shows up in RunResult
|
|
163
|
+
|
|
164
|
+
Wrong:
|
|
165
|
+
```ts
|
|
166
|
+
const result = await deploy.run(rawInput);
|
|
167
|
+
if (!result.ok) { /* never reached on bad input — it throws instead */ }
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Correct:
|
|
171
|
+
```ts
|
|
172
|
+
try {
|
|
173
|
+
const result = await deploy.run(rawInput);
|
|
174
|
+
} catch (error) {
|
|
175
|
+
if (error instanceof SchemaValidationError) console.error(error.issues);
|
|
176
|
+
else throw error;
|
|
177
|
+
}
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
`SchemaValidationError` is always thrown directly by `run()`, never returned as `{ ok: false }`, and is unaffected by `throwOnError` — there is no partial result to produce since no phase executed.
|
|
181
|
+
|
|
182
|
+
Source: docs/guides/typed-context.md; docs/reference/errors.md
|
|
183
|
+
|
|
184
|
+
See also: skills/compensation-safety/cleaning-context/SKILL.md — mutating ctx directly instead of returning it from a handler is the same "context is not just a plain mutable object" mental model both skills depend on.
|
|
185
|
+
|
|
186
|
+
## References
|
|
187
|
+
|
|
188
|
+
- [Context type utilities reference](references/context-type-utilities.md)
|