@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,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)
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# Context type utilities
|
|
2
|
+
|
|
3
|
+
These are the type-level helpers Stagehand uses internally to compute `Ctx`,
|
|
4
|
+
`RollbackContext`, and `WithStepFor` chains. All of them run automatically as
|
|
5
|
+
part of `addStep`, `defineInput`, and `stepFor` — a script author almost never
|
|
6
|
+
imports or writes them directly. They're documented here for the rare case of
|
|
7
|
+
debugging a confusing inferred type, or hand-writing a step definition outside
|
|
8
|
+
`addStep` (e.g. a shared library file) where the composed context needs to be
|
|
9
|
+
spelled out explicitly.
|
|
10
|
+
|
|
11
|
+
## `Prettify<T>`
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
export type Prettify<T> = { [K in keyof T]: T[K] } & {};
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Flattens an intersection type (`Omit<Ctx, K> & Out`) into a single object type
|
|
18
|
+
so editor hovers show a real shape (`{ a: string; b: number }`) instead of a
|
|
19
|
+
chain of `&` operators. Applied automatically everywhere a context type is
|
|
20
|
+
constructed — `result.ctx`, a step's `ctx` parameter, `RollbackData` — so
|
|
21
|
+
there's nothing to opt into. Reach for it directly only when hand-writing a
|
|
22
|
+
type alias for a shared step's context and the hover in your editor is showing
|
|
23
|
+
an unreadable intersection.
|
|
24
|
+
|
|
25
|
+
## `Merge<Ctx, Out>`
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
export type Merge<Ctx, Out> = [Out] extends [void]
|
|
29
|
+
? Ctx
|
|
30
|
+
: [Out] extends [undefined]
|
|
31
|
+
? Ctx
|
|
32
|
+
: Prettify<Omit<Ctx, keyof Out> & Out>;
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Computes the context after a step contributes `Out`: later keys win over
|
|
36
|
+
earlier ones of the same name (`Omit<Ctx, keyof Out> & Out`), and a handler
|
|
37
|
+
that returns `void` or `undefined` leaves `Ctx` completely untouched. This is
|
|
38
|
+
the type-level mirror of the runtime `Object.assign`-based merge in
|
|
39
|
+
`src/script.ts`. It is the core of how every `addStep` call widens `Ctx`, and
|
|
40
|
+
also underlies `WithStepFor` (see below). Almost never written by hand —
|
|
41
|
+
inference through `addStep` produces it automatically.
|
|
42
|
+
|
|
43
|
+
## `Cleaned<Ctx, Keys>`
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
export type Cleaned<Ctx, Keys extends PropertyKey> = Prettify<Omit<Ctx, Keys>>;
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
What a step's `clean` field leaves behind for every later step: `Keys` are
|
|
50
|
+
dropped from both the runtime context and the type. This is what makes a
|
|
51
|
+
cleaned key a genuine compile error to reach for afterward, not just a
|
|
52
|
+
runtime `undefined`. Computed automatically wherever `clean` is used; there's
|
|
53
|
+
no reason to reference `Cleaned` directly unless writing tooling that
|
|
54
|
+
inspects a script's declared clean keys.
|
|
55
|
+
|
|
56
|
+
## `RollbackData<Ctx, Keys>`
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
export type RollbackData<Ctx, Keys extends readonly PropertyKey[]> = Pick<
|
|
60
|
+
Ctx,
|
|
61
|
+
Extract<Keys[number], keyof Ctx>
|
|
62
|
+
>;
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The slice of context a `rollback` function actually receives: exactly the
|
|
66
|
+
keys named in that step's `rollbackKeys`, and nothing else. With no keys
|
|
67
|
+
declared, this resolves to `{}` — a rollback has to explicitly ask for what
|
|
68
|
+
it needs via `rollbackKeys`; it cannot see the full `Ctx`. `addStep` uses this
|
|
69
|
+
(wrapped in `Prettify`) to type the `ctx` parameter of `RollbackContext`. You
|
|
70
|
+
would only reference `RollbackData` directly when writing a rollback handler
|
|
71
|
+
outside `addStep` and need to spell out its parameter type by hand.
|
|
72
|
+
|
|
73
|
+
## `WithStepFor<Step, Rest>`
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
export type WithStepFor<
|
|
77
|
+
Step extends { handler: (context: never) => unknown },
|
|
78
|
+
Rest extends object = {},
|
|
79
|
+
> = Step extends { handler: (context: never) => infer Out }
|
|
80
|
+
? Merge<Rest, Awaited<Out>>
|
|
81
|
+
: never;
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Computes the context a *later* step sees, given an earlier step (declared
|
|
85
|
+
with `stepFor`) and whatever else is already there (`Rest`). This is the one
|
|
86
|
+
utility from this file developers reach for directly on a regular basis — it
|
|
87
|
+
is the mechanism behind [Splitting Steps Across Files](../../../reuse/splitting-steps-across-files/SKILL.md):
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
export const logIntoDb = stepFor<Input>()({
|
|
91
|
+
name: "log into db",
|
|
92
|
+
handler: async () => ({ conn: await connect() }),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
export const loadRules = stepFor<
|
|
96
|
+
Input,
|
|
97
|
+
WithStepFor<typeof logIntoDb, { conditions: Array<{ name: string }> }>
|
|
98
|
+
>()({
|
|
99
|
+
name: "load rules",
|
|
100
|
+
// ctx.conn — from the step; ctx.conditions — from the rest
|
|
101
|
+
handler: ({ ctx }) => ({ rules: ctx.conn.query(ctx.conditions) }),
|
|
102
|
+
});
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
It nests for chains of steps, without a separate variadic form:
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
WithStepFor<typeof second, WithStepFor<typeof first, { conditions: string[] }>>
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Read inside out: start with `{ conditions: string[] }`, add what `first`
|
|
112
|
+
returns, then what `second` returns. That's also the precedence — on a name
|
|
113
|
+
collision the outermost step wins, matching the way a later step's return
|
|
114
|
+
value shadows an earlier key at runtime. Order the nesting to match the order
|
|
115
|
+
the steps actually run in, or the type and the runtime disagree.
|
|
116
|
+
|
|
117
|
+
`WithStepFor` only reads the step's *output* — its own `Ctx` requirement
|
|
118
|
+
against `Rest` is not checked here; that check still happens when the step
|
|
119
|
+
definition is passed to `addStep` on a real script. A step returning nothing
|
|
120
|
+
leaves `Rest` untouched, and an `async` handler's return is awaited first
|
|
121
|
+
(`Awaited<Out>`), so both fall out without a special case.
|