@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,203 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: 'reporting-progress'
|
|
3
|
+
description: >
|
|
4
|
+
Covers the handler UI surface — `status(text)` vs `note(text)`, `progress({ total, label })` and its `ProgressHandle` (`update`/`increment`/`setTotal`/`setLabel`/`done`), `task(label)`/`tasks([...] as const)` and `TaskHandle`/`TaskListHandle`, and the `log`/`info`/`warn`/`error`/`success` severity lines. Load this for "show a progress bar," "put a row count on the finished step line," "run a typed checklist inside a step," or any handler-level UI call that isn't about where the output physically renders.
|
|
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:src/context.ts'
|
|
12
|
+
- 'miaklwalker/stagehand:src/script.ts'
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
# Stagehand — Reporting Progress From a Step
|
|
16
|
+
|
|
17
|
+
Every handler receives `status`, `note`, `progress`, `task`, `tasks`, and the five log methods as part of its single context object. `rollback` gets a smaller subset — `log` (no `info`/`warn`/`error`/`success`), `status`, `note`, and `progress`, but not `task`/`tasks`.
|
|
18
|
+
|
|
19
|
+
## Setup
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import { Script } from "@michaelrwalker/stagehand";
|
|
23
|
+
|
|
24
|
+
const result = await new Script({ name: "sync" })
|
|
25
|
+
.addStep({
|
|
26
|
+
name: "fetch records",
|
|
27
|
+
handler: async ({ status, note, progress, log }) => {
|
|
28
|
+
status("connecting");
|
|
29
|
+
log("opened connection");
|
|
30
|
+
|
|
31
|
+
const total = 428;
|
|
32
|
+
const bar = progress({ total, label: "fetching" });
|
|
33
|
+
const rows: number[] = [];
|
|
34
|
+
for (let i = 0; i < total; i++) {
|
|
35
|
+
rows.push(i);
|
|
36
|
+
bar.update(i + 1);
|
|
37
|
+
}
|
|
38
|
+
bar.done();
|
|
39
|
+
|
|
40
|
+
note(`${rows.length} rows found`);
|
|
41
|
+
return { rows };
|
|
42
|
+
},
|
|
43
|
+
})
|
|
44
|
+
.run({});
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Core Patterns
|
|
48
|
+
|
|
49
|
+
### `status` for scratch space, `note` for what should survive on the finished line
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
.addStep({
|
|
53
|
+
name: "query database",
|
|
54
|
+
handler: async ({ status, note }) => {
|
|
55
|
+
status("scanning"); // ⠸ query database › scanning
|
|
56
|
+
const rows = await db.all(sql);
|
|
57
|
+
note(`${rows.length} rows found`);
|
|
58
|
+
return { rows };
|
|
59
|
+
},
|
|
60
|
+
})
|
|
61
|
+
// ✔ query database (400 rows found) 1.2s
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`status` is wiped the instant the step settles; `note` is written into the step's title and stays. Calling either again replaces the previous text — neither is cumulative — and `note("")` removes the annotation entirely.
|
|
65
|
+
|
|
66
|
+
### A progress bar with a mid-flight relabel
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
handler: async ({ progress }) => {
|
|
70
|
+
const bar = progress({ total: 428, label: "resolving" });
|
|
71
|
+
for (let i = 0; i <= 428; i += 17) {
|
|
72
|
+
bar.update(i, i < 200 ? "resolving" : "linking");
|
|
73
|
+
await doWork();
|
|
74
|
+
}
|
|
75
|
+
bar.done();
|
|
76
|
+
return { packages: 428 };
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`progress` returns a `ProgressHandle` with `update(value, label?)`, `increment(by = 1)`, `setTotal(total)`, `setLabel(label)`, `done()`, and readonly `.value`/`.total`; every value it accepts is clamped to `[0, total]`.
|
|
81
|
+
|
|
82
|
+
### A typed checklist with `tasks()`
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
handler: async ({ tasks }) => {
|
|
86
|
+
const list = tasks(["typecheck", "transform", "minify", "write manifest"] as const);
|
|
87
|
+
|
|
88
|
+
for (const key of ["typecheck", "transform", "minify", "write manifest"] as const) {
|
|
89
|
+
const item = list.get(key);
|
|
90
|
+
item.start();
|
|
91
|
+
await run(key);
|
|
92
|
+
item.succeed();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function run(step: string) {}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
`tasks(labels)` takes the label list as a `const` tuple, so `list.get(key)` is typed to exactly those labels — a single ad hoc item can also be created with `task(label)`, returning one `TaskHandle` (`start`/`label`/`succeed`/`fail`/`skip`, each taking an optional replacement text).
|
|
100
|
+
|
|
101
|
+
### Severity-tagged lines with `log`/`info`/`warn`/`error`/`success`
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
handler: async ({ log, info, warn, error, success }) => {
|
|
105
|
+
log("starting migration");
|
|
106
|
+
info("using batch size 500");
|
|
107
|
+
warn("retrying batch 3");
|
|
108
|
+
error("batch 7 failed, continuing");
|
|
109
|
+
success("migration complete");
|
|
110
|
+
return {};
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
All five just differ in icon/style; where the line lands is controlled by the script's `logPlacement` option, covered in the log-placement-and-rendering skill.
|
|
115
|
+
|
|
116
|
+
## Common Mistakes
|
|
117
|
+
|
|
118
|
+
### HIGH Using status() for a value meant to persist on the finished step line
|
|
119
|
+
|
|
120
|
+
Wrong:
|
|
121
|
+
```ts
|
|
122
|
+
handler: async ({ status }) => {
|
|
123
|
+
const rows = await db.all(sql);
|
|
124
|
+
status(`${rows.length} rows found`); // gone the moment this step finishes
|
|
125
|
+
return { rows };
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Correct:
|
|
130
|
+
```ts
|
|
131
|
+
handler: async ({ status, note }) => {
|
|
132
|
+
status("scanning");
|
|
133
|
+
const rows = await db.all(sql);
|
|
134
|
+
note(`${rows.length} rows found`); // stays on the finished line
|
|
135
|
+
return { rows };
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
`status` is scratch space for a step in flight — it is deleted (`delete item.state.statusText`) the instant the step settles, whether it succeeds or fails; only `note` survives onto the finished line.
|
|
140
|
+
|
|
141
|
+
Source: docs/guides/handler-surface.md; src/script.ts (delete item.state.statusText on success/failure)
|
|
142
|
+
|
|
143
|
+
### LOW Calling note() repeatedly expecting it to append, like a log
|
|
144
|
+
|
|
145
|
+
Wrong:
|
|
146
|
+
```ts
|
|
147
|
+
note("400 rows");
|
|
148
|
+
note("2 skipped"); // "400 rows" is gone — the title now reads "2 skipped"
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Correct:
|
|
152
|
+
```ts
|
|
153
|
+
note(`${rowCount} rows, ${skipped} skipped`);
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
`note` replaces whatever it previously set rather than accumulating; `note("")` clears it.
|
|
157
|
+
|
|
158
|
+
Source: docs/guides/handler-surface.md
|
|
159
|
+
|
|
160
|
+
### LOW Passing a plain string[] (not `as const`) to tasks()
|
|
161
|
+
|
|
162
|
+
Wrong:
|
|
163
|
+
```ts
|
|
164
|
+
const labels = ["typecheck", "transform", "minify"];
|
|
165
|
+
const list = tasks(labels);
|
|
166
|
+
list.get("tpyecheck"); // typo compiles — labels was string[], not a literal tuple
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Correct:
|
|
170
|
+
```ts
|
|
171
|
+
const list = tasks(["typecheck", "transform", "minify"] as const);
|
|
172
|
+
list.get("tpyecheck"); // compile error
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
`tasks(labels)` needs the label list as a const tuple for `list.get(key)` to be typed to exactly those labels; a widened `string[]` makes `get()` accept any string, including a typo, with no compile error.
|
|
176
|
+
|
|
177
|
+
Source: docs/guides/handler-surface.md
|
|
178
|
+
|
|
179
|
+
### LOW Passing a value outside [0, total] to progress and expecting an error or warning
|
|
180
|
+
|
|
181
|
+
Wrong:
|
|
182
|
+
```ts
|
|
183
|
+
const bar = progress({ total: 100 });
|
|
184
|
+
bar.update(bytesUploaded); // bytesUploaded is actually in KB — silently clamps to 100
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Correct:
|
|
188
|
+
```ts
|
|
189
|
+
const bar = progress({ total: totalBytes });
|
|
190
|
+
bar.update(bytesUploaded); // units matched to `total`
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
`update`/`increment`/`setTotal` all clamp silently to the valid range rather than throwing, so an off-by-one or unit mismatch in the caller's own math shows up only as a bar stuck at 0% or 100% with no diagnostic.
|
|
194
|
+
|
|
195
|
+
Source: src/context.ts createProgress() (clamp(...))
|
|
196
|
+
|
|
197
|
+
### HIGH Tension: Rich live observability vs. complete log record
|
|
198
|
+
|
|
199
|
+
`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 (a beautifully-nested live view of status/note/progress/tasks next to the step they belong to) tend to leave `logPlacement` at "step" or "bottom" for a script that also needs a guaranteed-complete CI/audit trail, because they don't account for those two placements silently discarding older lines beyond a handful.
|
|
200
|
+
|
|
201
|
+
See also: skills/observability/log-placement-and-rendering/SKILL.md § Common Mistakes
|
|
202
|
+
|
|
203
|
+
See also: skills/observability/log-placement-and-rendering/SKILL.md — where status/note/log actually render depends entirely on logPlacement and which renderer is active; the two skills describe one mechanism from two ends.
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: 'reusable-routines-and-mounts'
|
|
3
|
+
description: >
|
|
4
|
+
Covers routineFor<In, Ctx>() for declaring a mountable group of phases, and Script.use(routine | script, { as?, input? })
|
|
5
|
+
for splicing it into a host with optional renaming and per-mount input mapping. Load this for "share a fetch-and-normalize
|
|
6
|
+
pipeline across scripts," "mount the same routine twice for two tenants," or "feed a mounted fragment its own input."
|
|
7
|
+
metadata:
|
|
8
|
+
type: 'core'
|
|
9
|
+
library: 'stagehand'
|
|
10
|
+
library_version: '0.5.3'
|
|
11
|
+
sources:
|
|
12
|
+
- 'miaklwalker/stagehand:docs/guides/reusable-scripts.md'
|
|
13
|
+
- 'miaklwalker/stagehand:docs/guides/caching.md'
|
|
14
|
+
- 'miaklwalker/stagehand:docs/reference/errors.md'
|
|
15
|
+
- 'miaklwalker/stagehand:docs/reference/script.md'
|
|
16
|
+
- 'miaklwalker/stagehand:src/script.ts'
|
|
17
|
+
- 'miaklwalker/stagehand:test/routine.test.ts'
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
# Stagehand — Reusable Routines and Mounts
|
|
21
|
+
|
|
22
|
+
`routineFor` declares a group of phases away from any particular script; `use()` splices that routine (or a plain `Script`) into a host, in order, merging everything it produces into the host's typed context.
|
|
23
|
+
|
|
24
|
+
## Setup
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
// routines/channel.ts
|
|
28
|
+
import { routineFor } from "@michaelrwalker/stagehand";
|
|
29
|
+
|
|
30
|
+
export const fetchChannel = routineFor<{ channel: string }>()("fetch channel", (script) =>
|
|
31
|
+
script
|
|
32
|
+
.addPhase("Fetch")
|
|
33
|
+
.addStep({ name: "authenticate", handler: ({ input }) => ({ token: `t_${input.channel}` }) })
|
|
34
|
+
.addStep({ name: "pull orders", handler: ({ ctx }) => ({ orders: [{ id: `${ctx.token}_o1`, total: 10 }] }) })
|
|
35
|
+
.addPhase("Normalize")
|
|
36
|
+
.addStep({ name: "dedupe", handler: ({ ctx }) => ({ orderCount: ctx.orders.length }) }),
|
|
37
|
+
);
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
// somewhere-else.ts
|
|
42
|
+
import { Script } from "@michaelrwalker/stagehand";
|
|
43
|
+
import { fetchChannel } from "./routines/channel";
|
|
44
|
+
|
|
45
|
+
const report = await new Script<{ channel: string }>({ name: "report" })
|
|
46
|
+
.use(fetchChannel)
|
|
47
|
+
.addPhase("Report")
|
|
48
|
+
.addStep({ name: "aggregate", handler: ({ ctx }) => ({ total: ctx.orderCount }) })
|
|
49
|
+
.run({ channel: "amazon" });
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`routineFor<In, Ctx>()` takes the **minimum** the fragment needs: the input it reads (`In`) and the context it expects to already exist (`Ctx`). Both are checked at the `use()` call site — a host missing `channel` on its input, or missing an upstream key the routine needs in `ctx`, is a compile error there, not an `undefined` at run time.
|
|
53
|
+
|
|
54
|
+
## Core Patterns
|
|
55
|
+
|
|
56
|
+
### Mount the same routine twice for two tenants
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
new Script<{ since: string; amazonKey: string; shopifyKey: string }>({ name: "all channels" })
|
|
60
|
+
.use(fetchChannel, {
|
|
61
|
+
as: "Amazon",
|
|
62
|
+
input: ({ input }) => ({ channel: "amazon" }),
|
|
63
|
+
})
|
|
64
|
+
.use(fetchChannel, {
|
|
65
|
+
as: "Shopify",
|
|
66
|
+
input: ({ input }) => ({ channel: "shopify" }),
|
|
67
|
+
});
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`as` prefixes every phase name the mount brings in (`"Amazon / Fetch"`, `"Amazon / Normalize"`), keeping them unique so a second mount of the same routine doesn't collide.
|
|
71
|
+
|
|
72
|
+
### Mount a plain `Script`, still runnable on its own
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
const pipeline = new Script<{ channel: string }>({ name: "pull" })
|
|
76
|
+
.addPhase("Fetch")
|
|
77
|
+
.addStep({ name: "pull", handler: ({ input }) => ({ rows: [input.channel] }) });
|
|
78
|
+
|
|
79
|
+
await pipeline.run({ channel: "etsy" }); // runs standalone today
|
|
80
|
+
|
|
81
|
+
const mounted = await new Script<{ channel: string }>({ name: "report" })
|
|
82
|
+
.use(pipeline) // mounts into something bigger tomorrow
|
|
83
|
+
.addPhase("Report")
|
|
84
|
+
.addStep({ name: "count", handler: ({ ctx }) => ({ n: ctx.rows.length }) })
|
|
85
|
+
.run({ channel: "etsy" });
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`use()` accepts anything built with `routineFor` or an ordinary `Script` — the same module works alone or spliced into a host.
|
|
89
|
+
|
|
90
|
+
### Give a mount its own input, decoupled from the host's shape
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
export const pullChannel = routineFor<{ channel: string; apiKey: string }>()("pull channel", (script) =>
|
|
94
|
+
script.addPhase("Fetch").addStep({
|
|
95
|
+
name: "pull",
|
|
96
|
+
handler: ({ input }) => ({ pulled: `${input.channel}:${input.apiKey}` }),
|
|
97
|
+
}),
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
const result = await new Script<{ region: string }>({ name: "t" })
|
|
101
|
+
.use(pullChannel, {
|
|
102
|
+
input: ({ input }) => ({ channel: input.region, apiKey: "k" }),
|
|
103
|
+
})
|
|
104
|
+
.run({ region: "eu" });
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`input` takes a fixed value or an `{ input, ctx } => SubIn` mapper, may be async, and is resolved once per mount at the moment the mount is reached — the host no longer has to match the routine's input shape, only produce it.
|
|
108
|
+
|
|
109
|
+
## Common Mistakes
|
|
110
|
+
|
|
111
|
+
### HIGH Chaining addStep() directly after use() without a new phase
|
|
112
|
+
|
|
113
|
+
Wrong:
|
|
114
|
+
```ts
|
|
115
|
+
new Script({ name: "t" })
|
|
116
|
+
.use(pullChannel)
|
|
117
|
+
.addStep({ name: "sneaky", handler: () => ({}) });
|
|
118
|
+
// StepDefinitionError: Cannot add a step after use()
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Correct:
|
|
122
|
+
```ts
|
|
123
|
+
new Script({ name: "t" })
|
|
124
|
+
.use(pullChannel)
|
|
125
|
+
.addPhase("Report")
|
|
126
|
+
.addStep({ name: "summarize", handler: ({ ctx }) => ({}) });
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
A routine owns whole phases, so the last phase spliced in by `use()` belongs to the fragment — a bare `addStep` right after it throws `StepDefinitionError` at build time even though the fluent chain reads naturally.
|
|
130
|
+
|
|
131
|
+
Source: src/script.ts currentPhase() StepDefinitionError; docs/guides/reusable-scripts.md
|
|
132
|
+
|
|
133
|
+
### MEDIUM Assuming a mounted routine's when() sees the host's own input
|
|
134
|
+
|
|
135
|
+
Wrong:
|
|
136
|
+
```ts
|
|
137
|
+
// inside routines/channel.ts, built with routineFor<{ channel: string }>()
|
|
138
|
+
.addStep({ name: "gate", when: ({ input }) => input.hostOnlyFlag, handler: async () => {} })
|
|
139
|
+
// TypeScript already blocks referencing hostOnlyFlag if routineFor's In doesn't include it —
|
|
140
|
+
// but this is exactly the trap when In is loosely typed or the mapper over-forwards fields
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Correct:
|
|
144
|
+
```ts
|
|
145
|
+
export const pullChannel = routineFor<{ channel: string; since: string }>()(
|
|
146
|
+
"pull channel",
|
|
147
|
+
(script) => script.addStep({
|
|
148
|
+
name: "gate",
|
|
149
|
+
when: ({ input }) => input.channel === "amazon",
|
|
150
|
+
handler: async () => {},
|
|
151
|
+
}),
|
|
152
|
+
);
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
A mount's input is resolved once, when the mount is reached, from either a fixed value or an `{ input, ctx } => SubIn` mapper — every `when` inside that fragment (phase- or step-level) is evaluated against the mount's resolved input, never the host's own.
|
|
156
|
+
|
|
157
|
+
Source: docs/guides/reusable-scripts.md ("Giving a mount its own input"); src/script.ts use()/run() mount input resolution
|
|
158
|
+
|
|
159
|
+
### HIGH Mounting the same routine twice without as
|
|
160
|
+
|
|
161
|
+
Wrong:
|
|
162
|
+
```ts
|
|
163
|
+
new Script({ name: "t" })
|
|
164
|
+
.use(pullChannel)
|
|
165
|
+
.use(pullChannel); // DuplicateNameError: "Fetch" already defined
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Correct:
|
|
169
|
+
```ts
|
|
170
|
+
new Script({ name: "t" })
|
|
171
|
+
.use(pullChannel, { as: "Amazon", input: ({ input }) => ({ ...input, channel: "amazon" }) })
|
|
172
|
+
.use(pullChannel, { as: "Shopify", input: ({ input }) => ({ ...input, channel: "shopify" }) });
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Phase names must stay unique across the whole script; mounting the same routine a second time without a distinguishing `as` prefix throws `DuplicateNameError` at build time.
|
|
176
|
+
|
|
177
|
+
Source: docs/guides/reusable-scripts.md; docs/reference/errors.md (DuplicateNameError)
|
|
178
|
+
|
|
179
|
+
### MEDIUM Giving a routine its own ScriptOptions and expecting them to hold once mounted
|
|
180
|
+
|
|
181
|
+
Wrong:
|
|
182
|
+
```ts
|
|
183
|
+
// routines/import.ts
|
|
184
|
+
const pipeline = new Script({ name: "import", rollback: "none" })
|
|
185
|
+
.addPhase("Fetch")
|
|
186
|
+
.addStep({ name: "pull", handler, rollback: undoPull });
|
|
187
|
+
// mounted into a host with rollback: "all" — the host's "all" wins; "pull"'s rollback CAN fire
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Correct:
|
|
191
|
+
```ts
|
|
192
|
+
// a mountable fragment's own ScriptOptions are dead weight once mounted — fine to leave them off,
|
|
193
|
+
// and expect whichever script mounts it to own rollback/logPlacement/silent for the whole run
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
A mounted `Script`'s own `ScriptOptions` are ignored in favor of the host's; only its phases and steps come across. This is by design, not a bug — a standalone `Script` is meant to be runnable on its own today and mountable into something bigger tomorrow, and the host has to govern the whole run.
|
|
197
|
+
|
|
198
|
+
Source: docs/guides/reusable-scripts.md ("A mounted Script's own ScriptOptions... are ignored in favor of the host's")
|
|
199
|
+
|
|
200
|
+
### LOW Renaming a mount with as and forgetting to update cache calls
|
|
201
|
+
|
|
202
|
+
Wrong:
|
|
203
|
+
```ts
|
|
204
|
+
.use(pullChannel, { as: "Amazon" })
|
|
205
|
+
// elsewhere: cache.clear("Fetch") — now a compile error, slot is "Amazon / Fetch"
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Correct:
|
|
209
|
+
```ts
|
|
210
|
+
.use(pullChannel, { as: "Amazon" })
|
|
211
|
+
// elsewhere: cache.clear("Amazon / Fetch")
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
`as` renames a mounted fragment's cache slots along with its phases, so every `context.cache` call written against the unprefixed name goes red at compile time and needs the `"As / PhaseName"` form instead.
|
|
215
|
+
|
|
216
|
+
Source: docs/guides/reusable-scripts.md ("Mounting twice"); docs/guides/caching.md ("Reaching the cache from a step")
|
|
217
|
+
|
|
218
|
+
### MEDIUM Assuming a mount's input mapper re-evaluates per step
|
|
219
|
+
|
|
220
|
+
Wrong:
|
|
221
|
+
```ts
|
|
222
|
+
.use(pullChannel, { input: ({ input }) => ({ apiKey: getFreshToken() }) })
|
|
223
|
+
// assumed: getFreshToken() called again for each step in the fragment — it is called exactly once
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Correct:
|
|
227
|
+
```ts
|
|
228
|
+
// if freshness matters per step, resolve it inside the fragment's own step handlers
|
|
229
|
+
// instead of the mount mapper
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Input is resolved once per mount, at the moment the mount is reached in execution order, and every phase/step of that fragment — including `when` and `rollback` — sees that single resolved value, not a fresh evaluation each time.
|
|
233
|
+
|
|
234
|
+
Source: docs/guides/reusable-scripts.md ("resolved once per mount, at the moment the mount is reached")
|
|
235
|
+
|
|
236
|
+
### HIGH Tension: Routine authoring instincts vs. host-controlled ScriptOptions
|
|
237
|
+
|
|
238
|
+
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. Agents optimizing for a reusable, "always non-destructive" fragment tend to bake `rollback: "none"` into the sub-script because they don't account for the host's `ScriptOptions` overriding it entirely; that requirement has to be documented for whoever mounts the routine instead.
|
|
239
|
+
|
|
240
|
+
See also: skills/compensation-safety/adding-rollbacks/SKILL.md § Common Mistakes
|
|
241
|
+
See also: skills/observability/log-placement-and-rendering/SKILL.md § Common Mistakes
|
|
242
|
+
|
|
243
|
+
See also: skills/building-scripts/structuring-phases-and-steps/SKILL.md — a routine owns whole phases; addStep directly after use() is refused, which only makes sense once phase ownership is understood.
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: 'splitting-steps-across-files'
|
|
3
|
+
description: >
|
|
4
|
+
stepFor<In, Ctx>() binds the input and context a step declared outside any script expects, so the step can be exported from its own module and passed to addStep; WithStepFor<Step, Rest> computes a chain's Ctx from an earlier step's output instead of restating it by hand. Load this for "move a step into its own module," "share a step across two scripts," or "build a reusable steps file for a recurring workflow."
|
|
5
|
+
metadata:
|
|
6
|
+
type: 'core'
|
|
7
|
+
library: 'stagehand'
|
|
8
|
+
library_version: '0.5.3'
|
|
9
|
+
sources:
|
|
10
|
+
- 'miaklwalker/stagehand:docs/guides/splitting-steps.md'
|
|
11
|
+
- 'miaklwalker/stagehand:src/script.ts'
|
|
12
|
+
- 'miaklwalker/stagehand:test/with-step-for.test.ts'
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
# Stagehand — Splitting Steps Across Files
|
|
16
|
+
|
|
17
|
+
A step defined inline with `addStep` infers its `In` and `Ctx` from the script it's attached to. A step declared in its own module has no script to infer from, so `stepFor<In, Ctx>()` supplies that binding explicitly while keeping full inference on the handler's return type.
|
|
18
|
+
|
|
19
|
+
## Setup
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
// steps/tenancy.ts
|
|
23
|
+
import { stepFor } from "@michaelrwalker/stagehand";
|
|
24
|
+
|
|
25
|
+
interface Input {
|
|
26
|
+
accountId: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const loadAccount = stepFor<Input>()({
|
|
30
|
+
name: "load account",
|
|
31
|
+
handler: async ({ input }) => ({ account: await fetchAccount(input.accountId) }),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export const createTenant = stepFor<Input, { account: Account }>()({
|
|
35
|
+
name: "create tenant",
|
|
36
|
+
handler: async ({ ctx }) => ({ tenantId: await api.create(ctx.account) }),
|
|
37
|
+
rollbackKeys: ["tenantId"],
|
|
38
|
+
rollback: async ({ ctx }) => api.destroy(ctx.tenantId),
|
|
39
|
+
});
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
// scripts/signup.ts
|
|
44
|
+
import { Script } from "@michaelrwalker/stagehand";
|
|
45
|
+
import { loadAccount, createTenant } from "../steps/tenancy.js";
|
|
46
|
+
|
|
47
|
+
const signup = new Script<{ accountId: string }>({ name: "signup" })
|
|
48
|
+
.addPhase("Account")
|
|
49
|
+
.addStep(loadAccount)
|
|
50
|
+
.addPhase("Provision")
|
|
51
|
+
.addStep(createTenant);
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Core Patterns
|
|
55
|
+
|
|
56
|
+
### Chaining `stepFor` steps with `WithStepFor` instead of restating `Ctx`
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
import { stepFor, type WithStepFor } from "@michaelrwalker/stagehand";
|
|
60
|
+
|
|
61
|
+
interface Input {
|
|
62
|
+
channel: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const logIntoDb = stepFor<Input>()({
|
|
66
|
+
name: "log into db",
|
|
67
|
+
handler: async () => ({ conn: await connect() }),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
export const loadRules = stepFor<
|
|
71
|
+
Input,
|
|
72
|
+
WithStepFor<typeof logIntoDb, { conditions: Array<{ name: string }> }>
|
|
73
|
+
>()({
|
|
74
|
+
name: "load rules",
|
|
75
|
+
// ctx.conn comes from logIntoDb's output, ctx.conditions from Rest
|
|
76
|
+
handler: ({ ctx }) => ({ rules: ctx.conn.query(ctx.conditions) }),
|
|
77
|
+
});
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`WithStepFor<Step, Rest>` merges `Step`'s output over `Rest` (which defaults to `{}`), so the second step's `Ctx` doesn't have to be hand-typed against the first step's return shape.
|
|
81
|
+
|
|
82
|
+
### Nesting `WithStepFor` for a chain three or more steps deep
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
type Ctx = WithStepFor<typeof third, WithStepFor<typeof second, WithStepFor<typeof first, { seed: boolean }>>>;
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Read inside out: start with `{ seed: boolean }`, add what `first` returns, then `second`, then `third` — the same order the steps actually run in, and on a name collision the outermost (`third`) wins, matching how a later step's return shadows an earlier key at runtime.
|
|
89
|
+
|
|
90
|
+
### A shared step library reused across two unrelated scripts
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
// steps/crm.ts — written once, small and generic
|
|
94
|
+
import { stepFor } from "@michaelrwalker/stagehand";
|
|
95
|
+
|
|
96
|
+
export const fetchContacts = stepFor<{ emails: string[] }>()({
|
|
97
|
+
name: "fetch contacts",
|
|
98
|
+
handler: async ({ input }) => ({ contacts: await crm.fetch(input.emails) }),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
export const dedupeContacts = stepFor<{ emails: string[] }, { contacts: Contact[] }>()({
|
|
102
|
+
name: "dedupe contacts",
|
|
103
|
+
handler: ({ ctx }) => ({ contacts: dedupe(ctx.contacts) }),
|
|
104
|
+
});
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
// scripts/sync-a.ts
|
|
109
|
+
import { Script } from "@michaelrwalker/stagehand";
|
|
110
|
+
import { fetchContacts, dedupeContacts } from "../steps/crm.js";
|
|
111
|
+
|
|
112
|
+
new Script<{ emails: string[] }>({ name: "sync-a" })
|
|
113
|
+
.addPhase("Contacts")
|
|
114
|
+
.addStep(fetchContacts)
|
|
115
|
+
.addStep(dedupeContacts);
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
// scripts/sync-b.ts — same steps, same import, no logic re-typed
|
|
120
|
+
import { Script } from "@michaelrwalker/stagehand";
|
|
121
|
+
import { fetchContacts, dedupeContacts } from "../steps/crm.js";
|
|
122
|
+
|
|
123
|
+
new Script<{ emails: string[]; region: string }>({ name: "sync-b" })
|
|
124
|
+
.addPhase("Contacts")
|
|
125
|
+
.addStep(fetchContacts)
|
|
126
|
+
.addStep(dedupeContacts)
|
|
127
|
+
.addPhase("Region")
|
|
128
|
+
.addStep({
|
|
129
|
+
name: "tag region",
|
|
130
|
+
handler: async ({ input, ctx }) => ({ tagged: ctx.contacts.map((c) => ({ ...c, region: input.region })) }),
|
|
131
|
+
});
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Each script mixes shared and inline steps freely; `addStep` checks each shared step's `Ctx` requirement against whatever the host script has produced so far, at the mount site.
|
|
135
|
+
|
|
136
|
+
### Reusing a step with a rollback across two scripts
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
// Script one: normal signup
|
|
140
|
+
new Script<{ accountId: string }>({ name: "signup" })
|
|
141
|
+
.addPhase("Account")
|
|
142
|
+
.addStep(loadAccount)
|
|
143
|
+
.addPhase("Provision")
|
|
144
|
+
.addStep(createTenant); // rollback compensates here if a later step fails
|
|
145
|
+
|
|
146
|
+
// Script two: written later, reusing the same step and its rollback
|
|
147
|
+
new Script<{ accountId: string; fromRegion: string }>({ name: "migrate" })
|
|
148
|
+
.addPhase("Prepare")
|
|
149
|
+
.addStep(loadAccount)
|
|
150
|
+
.addPhase("Provision")
|
|
151
|
+
.addStep(createTenant); // same import, same compensation logic
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
`createTenant`'s rollback is identical wherever it's mounted because it's the exact same function reference each time.
|
|
155
|
+
|
|
156
|
+
## Common Mistakes
|
|
157
|
+
|
|
158
|
+
### HIGH Nesting WithStepFor in the reverse of execution order
|
|
159
|
+
|
|
160
|
+
Wrong:
|
|
161
|
+
```ts
|
|
162
|
+
// "second" actually runs after "first" in the real script, but is nested as if it ran before it
|
|
163
|
+
type Ctx = WithStepFor<typeof first, WithStepFor<typeof second, { conditions: string[] }>>;
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Correct:
|
|
167
|
+
```ts
|
|
168
|
+
// nest in the order the steps actually run — innermost first
|
|
169
|
+
type Ctx = WithStepFor<typeof second, WithStepFor<typeof first, { conditions: string[] }>>;
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
On a name collision the outermost `WithStepFor` wins, matching how a later step's return value shadows an earlier key at runtime — but nothing checks that the nesting order matches the order the steps are actually added to a script, so a reversed nest silently produces a `Ctx` type that disagrees with what the script really does at runtime.
|
|
173
|
+
|
|
174
|
+
Source: docs/guides/splitting-steps.md ("nothing checks that the steps are added to a script in the order the nesting implies")
|
|
175
|
+
|
|
176
|
+
### LOW Assuming a reused stepFor step gets independent state per script
|
|
177
|
+
|
|
178
|
+
Wrong:
|
|
179
|
+
```ts
|
|
180
|
+
// steps/tenancy.ts
|
|
181
|
+
let callCount = 0;
|
|
182
|
+
export const createTenant = stepFor<Input, { account: Account }>()({
|
|
183
|
+
name: "create tenant",
|
|
184
|
+
handler: async () => { callCount++; return { tenantId: await api.create() }; },
|
|
185
|
+
});
|
|
186
|
+
// callCount keeps incrementing across every script that imports createTenant
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Correct:
|
|
190
|
+
```ts
|
|
191
|
+
// keep per-run state inside ctx/output, not module-level closures, unless sharing is intended
|
|
192
|
+
export const createTenant = stepFor<Input, { account: Account }>()({
|
|
193
|
+
name: "create tenant",
|
|
194
|
+
handler: async ({ attempt }) => ({ tenantId: await api.create(), attempt }),
|
|
195
|
+
});
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
A step imported into two scripts is the exact same function/object reference in both, so any module-scoped mutable state its handler closes over is shared across every script that mounts it, not reset per mount.
|
|
199
|
+
|
|
200
|
+
Source: docs/guides/splitting-steps.md ("Reusing one step in two scripts" — same function reference each time)
|
|
201
|
+
|
|
202
|
+
### MEDIUM Writing a bespoke inline step instead of extracting a shared one
|
|
203
|
+
|
|
204
|
+
Wrong:
|
|
205
|
+
```ts
|
|
206
|
+
// scripts/sync-a.ts
|
|
207
|
+
.addStep({ name: "fetch contacts", handler: async ({ input }) => ({ contacts: await crm.fetch(input.emails) }) })
|
|
208
|
+
|
|
209
|
+
// scripts/sync-b.ts — same logic, retyped, now two places to fix a bug
|
|
210
|
+
.addStep({ name: "fetch contacts", handler: async ({ input }) => ({ contacts: await crm.fetchContacts(input.emails) }) })
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Correct:
|
|
214
|
+
```ts
|
|
215
|
+
// steps/crm.ts — written once, with a small, generic input
|
|
216
|
+
export const fetchContacts = stepFor<{ emails: string[] }>()({
|
|
217
|
+
name: "fetch contacts",
|
|
218
|
+
handler: async ({ input }) => ({ contacts: await crm.fetch(input.emails) }),
|
|
219
|
+
});
|
|
220
|
+
// any script that needs it:
|
|
221
|
+
.addStep(fetchContacts)
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
For a workflow that recurs across scripts, a step written once with a small, generic input composes into every future script at zero type-safety cost (`addStep` still checks it against whatever mounts it), while a fresh inline closure per script forfeits that — the same logic, and any bug in it, has to be rewritten and re-verified in every script separately.
|
|
225
|
+
|
|
226
|
+
Source: maintainer interview ("keep a file of common steps, with simple inputs so you can easily compose scripts... this will save developers a ton of time and costs nothing as far as type safety is concerned")
|