@michaelrwalker/stagehand 0.8.0 → 0.10.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 +74 -7
- package/dist/cache.d.ts +11 -2
- package/dist/cache.d.ts.map +1 -1
- package/dist/cache.js +37 -5
- package/dist/cache.js.map +1 -1
- 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 +27 -1
- package/dist/script.d.ts.map +1 -1
- package/dist/script.js +58 -9
- package/dist/script.js.map +1 -1
- package/dist/state.d.ts +7 -1
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js.map +1 -1
- package/dist/types.d.ts +54 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/ui/frame.d.ts.map +1 -1
- package/dist/ui/frame.js +7 -3
- package/dist/ui/frame.js.map +1 -1
- package/dist/ui/renderer.d.ts +2 -0
- package/dist/ui/renderer.d.ts.map +1 -1
- package/dist/ui/renderer.js +11 -1
- package/dist/ui/renderer.js.map +1 -1
- package/package.json +1 -1
- package/skills/building-scripts/branching-steps/SKILL.md +144 -0
- package/skills/building-scripts/command-line-flags/SKILL.md +115 -0
- package/skills/building-scripts/getting-started/SKILL.md +1 -1
- package/skills/building-scripts/prompting-the-user/SKILL.md +91 -0
- package/skills/building-scripts/structuring-phases-and-steps/SKILL.md +5 -1
- package/skills/building-scripts/typed-context-and-input/SKILL.md +1 -1
- package/skills/caching/caching-expensive-work/SKILL.md +66 -8
- package/skills/caching/caching-expensive-work/references/cache-api.md +17 -7
- package/skills/compensation-safety/adding-rollbacks/SKILL.md +3 -1
- package/skills/compensation-safety/cleaning-context/SKILL.md +1 -1
- package/skills/compensation-safety/conditional-steps/SKILL.md +19 -3
- package/skills/errors-results/handling-results-and-errors/SKILL.md +19 -4
- package/skills/observability/log-placement-and-rendering/SKILL.md +1 -1
- package/skills/observability/reporting-progress/SKILL.md +3 -1
- package/skills/reuse/reusable-routines-and-mounts/SKILL.md +3 -3
- package/skills/reuse/splitting-steps-across-files/SKILL.md +1 -1
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: 'branching-steps'
|
|
3
|
+
description: >
|
|
4
|
+
Covers `addBranch` — a step that runs `onTrue` or `onFalse` depending on `condition`, with both
|
|
5
|
+
arms required to return the same shape so `ctx` stays accurately typed whichever one ran. Load this
|
|
6
|
+
for "an override flag changes how a value is produced but later steps need it either way," "do X or
|
|
7
|
+
Y and keep the context typed," or when `when` skipped a step and a later step crashed on a missing
|
|
8
|
+
`ctx` key.
|
|
9
|
+
metadata:
|
|
10
|
+
type: 'core'
|
|
11
|
+
library: 'stagehand'
|
|
12
|
+
library_version: '0.8.0'
|
|
13
|
+
sources:
|
|
14
|
+
- 'miaklwalker/stagehand:docs/guides/phases-and-steps.md'
|
|
15
|
+
- 'miaklwalker/stagehand:docs/reference/script.md'
|
|
16
|
+
- 'miaklwalker/stagehand:src/script.ts'
|
|
17
|
+
- 'miaklwalker/stagehand:test/branch.test.ts'
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
# Stagehand — Branching Steps
|
|
21
|
+
|
|
22
|
+
`addBranch` is a step with an if/else inside it. `condition` picks the arm, `onTrue` / `onFalse` do the work, and both return the same shape — so every later step sees one `ctx` type, no matter which arm ran. Use it instead of `when` whenever later steps depend on what the step produces.
|
|
23
|
+
|
|
24
|
+
## Setup
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import { Script } from "@michaelrwalker/stagehand";
|
|
28
|
+
|
|
29
|
+
const result = await new Script<{ service: string }>({ name: "deploy" })
|
|
30
|
+
.defineFlag({ name: "sha" })
|
|
31
|
+
.addPhase("Resolve")
|
|
32
|
+
.addBranch({
|
|
33
|
+
name: "resolve commit",
|
|
34
|
+
condition: ({ flags }) => Boolean(flags.sha),
|
|
35
|
+
onTrue: ({ flags }) => ({ sha: flags.sha as string }),
|
|
36
|
+
onFalse: async ({ input }) => ({ sha: await git.head(input.service) }),
|
|
37
|
+
})
|
|
38
|
+
.addStep({
|
|
39
|
+
name: "build",
|
|
40
|
+
handler: ({ ctx }) => ({ image: `app:${ctx.sha}` }), // ctx.sha: string
|
|
41
|
+
})
|
|
42
|
+
.run({ service: "checkout-api" }, { argv: ["--sha", "abc1234"] });
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Core Patterns
|
|
46
|
+
|
|
47
|
+
### Everything a handler gets, in all three functions
|
|
48
|
+
|
|
49
|
+
`condition`, `onTrue` and `onFalse` each receive the normal step context — `input`, `ctx`, `flags`, `status`, `note`, `log`, `prompt`, `signal`. `condition` may be async and runs once per attempt; only the chosen arm executes.
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
.addBranch({
|
|
53
|
+
name: "pick target",
|
|
54
|
+
condition: async ({ ctx }) => await isReachable(ctx.host),
|
|
55
|
+
onTrue: ({ ctx }) => ({ target: ctx.host }),
|
|
56
|
+
onFalse: async ({ prompt }) => ({ target: await prompt.text({ message: "fallback host?", default: "localhost" }) }),
|
|
57
|
+
})
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Retry and timeout cover the whole branch
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
.addBranch({
|
|
64
|
+
name: "resolve commit",
|
|
65
|
+
retry: { attempts: 3, delayMs: 250 },
|
|
66
|
+
timeoutMs: 10_000,
|
|
67
|
+
condition: ({ flags }) => Boolean(flags.sha),
|
|
68
|
+
onTrue: ({ flags }) => ({ sha: flags.sha as string }),
|
|
69
|
+
onFalse: async ({ input }) => ({ sha: await git.head(input.service) }),
|
|
70
|
+
})
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
A retry re-evaluates `condition` and re-runs the arm. Those two options, plus `name` and `description`, are the whole surface.
|
|
74
|
+
|
|
75
|
+
### Caching a branch: cache the phase
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
.addPhase("Resolve", { cache: store })
|
|
79
|
+
.addBranch({ name: "resolve commit", /* ... */ })
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
A branch has no `cache` of its own. A cached phase stores the merged output of every step in it, branch included.
|
|
83
|
+
|
|
84
|
+
## Common Mistakes
|
|
85
|
+
|
|
86
|
+
### HIGH Using `when` where later steps need the output
|
|
87
|
+
|
|
88
|
+
Wrong:
|
|
89
|
+
```ts
|
|
90
|
+
.addStep({ name: "head sha", when: ({ flags }) => !flags.sha, handler: async () => ({ sha: await git.head() }) })
|
|
91
|
+
.addStep({ name: "build", handler: ({ ctx }) => ctx.sha.slice(0, 7) }) // compiles; crashes with --sha set
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Correct:
|
|
95
|
+
```ts
|
|
96
|
+
.addBranch({
|
|
97
|
+
name: "resolve commit",
|
|
98
|
+
condition: ({ flags }) => Boolean(flags.sha),
|
|
99
|
+
onTrue: ({ flags }) => ({ sha: flags.sha as string }),
|
|
100
|
+
onFalse: async () => ({ sha: await git.head() }),
|
|
101
|
+
})
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`when` skips the step but `ctx` is still typed as though it ran. A branch always produces the keys.
|
|
105
|
+
|
|
106
|
+
### MEDIUM Arms returning different shapes
|
|
107
|
+
|
|
108
|
+
Wrong:
|
|
109
|
+
```ts
|
|
110
|
+
onTrue: () => ({ sha: "abc" }),
|
|
111
|
+
onFalse: () => ({ commit: "abc" }), // compile error on onFalse
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Correct:
|
|
115
|
+
```ts
|
|
116
|
+
onTrue: () => ({ sha: "abc" }),
|
|
117
|
+
onFalse: () => ({ sha: "abc" }),
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
`onTrue`'s return type fixes `Out`; `onFalse` must be assignable to it. If one path has no value, return an explicit default (`{ sha: undefined }` on both arms, or `sha: string | undefined` in `onTrue`) rather than omitting the key.
|
|
121
|
+
|
|
122
|
+
### MEDIUM Expecting `rollback`, `cache`, `clean` or `when` on a branch
|
|
123
|
+
|
|
124
|
+
Wrong:
|
|
125
|
+
```ts
|
|
126
|
+
.addBranch({ name: "b", condition, onTrue, onFalse, rollback: async () => {} }) // not accepted
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Correct:
|
|
130
|
+
```ts
|
|
131
|
+
// If the work needs compensation, make it a plain addStep with its own rollback
|
|
132
|
+
// and branch inside the handler; cache the enclosing phase; clean in a later step.
|
|
133
|
+
.addStep({
|
|
134
|
+
name: "provision",
|
|
135
|
+
handler: async ({ input }) => ({ id: input.existingId ?? (await create()) }),
|
|
136
|
+
rollback: async ({ output }) => destroy(output.id),
|
|
137
|
+
})
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
A branch is intentionally minimal: no compensation, no step-level cache, no `clean`, no `when`.
|
|
141
|
+
|
|
142
|
+
Source: docs/guides/phases-and-steps.md; src/types.ts `BranchDef`
|
|
143
|
+
|
|
144
|
+
See also: skills/compensation-safety/conditional-steps/SKILL.md — `when` is for work that may simply not happen; `addBranch` is for work that always happens in one of two ways.
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: 'command-line-flags'
|
|
3
|
+
description: >
|
|
4
|
+
Covers `defineFlag` and `context.flags` — typed string and boolean flags parsed from argv once before
|
|
5
|
+
any phase runs, `long`/`short` aliases, defaults, `UnknownFlagError` / `MissingFlagValueError`,
|
|
6
|
+
`run(input, { argv })` for tests, and mapping a flag onto a mounted routine's input. Load this for
|
|
7
|
+
"add a --force or --env flag," "gate a step on a CLI flag," or "test a script with flags."
|
|
8
|
+
metadata:
|
|
9
|
+
type: 'core'
|
|
10
|
+
library: 'stagehand'
|
|
11
|
+
library_version: '0.8.0'
|
|
12
|
+
sources:
|
|
13
|
+
- 'miaklwalker/stagehand:docs/guides/flags.md'
|
|
14
|
+
- 'miaklwalker/stagehand:docs/reference/script.md'
|
|
15
|
+
- 'miaklwalker/stagehand:src/flags.ts'
|
|
16
|
+
- 'miaklwalker/stagehand:src/script.ts'
|
|
17
|
+
- 'miaklwalker/stagehand:test/flags.test.ts'
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
# Stagehand — Command-Line Flags
|
|
21
|
+
|
|
22
|
+
`defineFlag` declares a flag. `run()` parses `process.argv` once, before any phase, and every step, `when`, `stale` and rollback receives the result as `flags`, typed from the declarations.
|
|
23
|
+
|
|
24
|
+
## Setup
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import { Script } from "@michaelrwalker/stagehand";
|
|
28
|
+
|
|
29
|
+
await new Script({ name: "deploy" })
|
|
30
|
+
.defineFlag({ name: "environment", long: "env", short: "e", default: "staging" })
|
|
31
|
+
.defineFlag({ name: "force", short: "f", boolean: true })
|
|
32
|
+
.addStep({
|
|
33
|
+
name: "ship",
|
|
34
|
+
handler: ({ flags }) => {
|
|
35
|
+
// flags.environment: string flags.force: boolean
|
|
36
|
+
return { env: flags.environment };
|
|
37
|
+
},
|
|
38
|
+
})
|
|
39
|
+
.run();
|
|
40
|
+
// myScript --env production -f
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Core Patterns
|
|
44
|
+
|
|
45
|
+
### String vs boolean
|
|
46
|
+
|
|
47
|
+
A string flag with a `default` is `string`; without one it is `string | undefined`. A boolean flag (`boolean: true`) is presence-based, never consumes the next token, and resolves to `default ?? false`. `--flag=value` and `--flag value` both work; `--force=false` is honoured.
|
|
48
|
+
|
|
49
|
+
### Gate work on a flag
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
.addPhase("Publish", { when: ({ flags }) => flags.production })
|
|
53
|
+
.addStep({ name: "notify", when: ({ flags }) => !flags.quiet, handler: async () => {} })
|
|
54
|
+
.addPhase("Build", { cache: { store, stale: ({ flags }) => flags.fresh } })
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
When the flag changes *how* a value is produced rather than *whether* a step runs, use `addBranch` (see skills/building-scripts/branching-steps/SKILL.md).
|
|
58
|
+
|
|
59
|
+
### Test with `argv`
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
const result = await script.run({ service: "api" }, { argv: ["--env", "production"] });
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`argv` replaces `process.argv.slice(2)`; use it in tests and when the script is one subcommand of a bigger CLI.
|
|
66
|
+
|
|
67
|
+
### Hand a flag to a routine
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
new Script({ name: "sync" })
|
|
71
|
+
.defineFlag({ name: "environment", long: "env", default: "staging" })
|
|
72
|
+
.use(pullChannel, { input: ({ flags }) => ({ env: flags.environment }) })
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Common Mistakes
|
|
76
|
+
|
|
77
|
+
### HIGH Declaring flags on a routine
|
|
78
|
+
|
|
79
|
+
Wrong:
|
|
80
|
+
```ts
|
|
81
|
+
routineFor<{ env: string }>()("pull", (script) => script.defineFlag({ name: "env" })) // flags are top-level only
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Correct:
|
|
85
|
+
```ts
|
|
86
|
+
// declare on the script you run; pass it down through use()'s input mapper
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`defineFlag` belongs to the entry script. A mounted script's flags are not merged into the host, and a routine's `flags` are typed loosely (`Record<string, unknown>`).
|
|
90
|
+
|
|
91
|
+
### MEDIUM Expecting `run()` to return `{ ok: false }` for a bad flag
|
|
92
|
+
|
|
93
|
+
Wrong:
|
|
94
|
+
```ts
|
|
95
|
+
const result = await script.run(undefined, { argv: ["--nope"] });
|
|
96
|
+
if (!result.ok) { /* never reached */ }
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Correct:
|
|
100
|
+
```ts
|
|
101
|
+
try { await script.run(undefined, { argv }); }
|
|
102
|
+
catch (error) { if (error instanceof UnknownFlagError) console.error(error.flag); }
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`UnknownFlagError` and `MissingFlagValueError` are thrown directly, before any phase, and ignore `throwOnError`.
|
|
106
|
+
|
|
107
|
+
### MEDIUM Assuming undeclared flags are always rejected
|
|
108
|
+
|
|
109
|
+
A script with **no** `defineFlag` call ignores argv entirely; the unknown-flag check starts at the first declaration. Adding a first flag can therefore break a script that read `process.argv` by hand.
|
|
110
|
+
|
|
111
|
+
### LOW Duplicate names
|
|
112
|
+
|
|
113
|
+
`defineFlag` throws `DuplicateNameError` (`kind: "flag"`) if `name`, the resolved long form, or `short` is already used.
|
|
114
|
+
|
|
115
|
+
Source: docs/guides/flags.md; src/flags.ts
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: 'prompting-the-user'
|
|
3
|
+
description: >
|
|
4
|
+
Covers `context.prompt` — `text`, `confirm`, `select` and `multiselect` asked mid-step, the
|
|
5
|
+
non-interactive `default` fallback, `PromptUnavailableError`, and Ctrl-C cancellation via
|
|
6
|
+
`PromptCancelledError`. Load this for "ask for confirmation before a destructive step," "let the
|
|
7
|
+
user pick an environment," or "make an interactive script runnable in CI."
|
|
8
|
+
metadata:
|
|
9
|
+
type: 'core'
|
|
10
|
+
library: 'stagehand'
|
|
11
|
+
library_version: '0.8.0'
|
|
12
|
+
sources:
|
|
13
|
+
- 'miaklwalker/stagehand:docs/guides/prompts.md'
|
|
14
|
+
- 'miaklwalker/stagehand:docs/reference/errors.md'
|
|
15
|
+
- 'miaklwalker/stagehand:src/ui/prompt.ts'
|
|
16
|
+
- 'miaklwalker/stagehand:test/prompt.test.ts'
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
# Stagehand — Prompting the User
|
|
20
|
+
|
|
21
|
+
`prompt` is on a step handler's context. Calling it suspends the live frame, draws the question, and repaints once answered. It is hand-rolled on the renderer's ANSI primitives, so there is no extra dependency.
|
|
22
|
+
|
|
23
|
+
## Setup
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
.addStep({
|
|
27
|
+
name: "confirm production",
|
|
28
|
+
handler: async ({ flags, prompt }) => {
|
|
29
|
+
if (flags.environment === "production" && !flags.force) {
|
|
30
|
+
const ok = await prompt.confirm({ message: "Deploy to production?", default: false });
|
|
31
|
+
if (!ok) throw new Error("cancelled by user");
|
|
32
|
+
}
|
|
33
|
+
return {};
|
|
34
|
+
},
|
|
35
|
+
})
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Core Patterns
|
|
39
|
+
|
|
40
|
+
| method | resolves to |
|
|
41
|
+
| --- | --- |
|
|
42
|
+
| `prompt.text({ message, default?, placeholder?, validate?, mask? })` | `string` |
|
|
43
|
+
| `prompt.confirm({ message, default? })` | `boolean` |
|
|
44
|
+
| `prompt.select({ message, choices, default? })` | the chosen `value` |
|
|
45
|
+
| `prompt.multiselect({ message, choices, default?, min?, max? })` | `value[]` |
|
|
46
|
+
|
|
47
|
+
`choices` are `{ label, value, hint? }`. `validate` returns `true` or an error string, may be async. `mask: true` echoes `•`.
|
|
48
|
+
|
|
49
|
+
### Always give a `default` if the script may run unattended
|
|
50
|
+
|
|
51
|
+
With no TTY (CI, a pipe) nothing is drawn: a `default` is returned immediately, and a missing one throws `PromptUnavailableError`.
|
|
52
|
+
|
|
53
|
+
### Prefer a flag, prompt only as the fallback
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
const environment = flags.environment ?? (await prompt.select({ message: "environment?", choices, default: "staging" }));
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Common Mistakes
|
|
60
|
+
|
|
61
|
+
### HIGH Prompting without a `default` in a script CI runs
|
|
62
|
+
|
|
63
|
+
Wrong:
|
|
64
|
+
```ts
|
|
65
|
+
const tag = await prompt.text({ message: "release tag?" }); // PromptUnavailableError under CI
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Correct:
|
|
69
|
+
```ts
|
|
70
|
+
const tag = await prompt.text({ message: "release tag?", default: "latest" });
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### MEDIUM Treating Ctrl-C at a prompt as a handler failure
|
|
74
|
+
|
|
75
|
+
Wrong:
|
|
76
|
+
```ts
|
|
77
|
+
if (!result.ok) report(result.error); // reports a cancellation as a bug
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Correct:
|
|
81
|
+
```ts
|
|
82
|
+
if (!result.ok && isAbort(result.error)) { /* result.status === "aborted"; rollback policy applied */ }
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`PromptCancelledError` is recognised by `isAbort`, so the run unwinds exactly like a SIGINT.
|
|
86
|
+
|
|
87
|
+
### MEDIUM Prompting from a rollback
|
|
88
|
+
|
|
89
|
+
`prompt` exists only on `StepContext`. A rollback runs during an unwind and cannot stop to ask; decide inside the step and keep what the rollback needs in `rollbackKeys`.
|
|
90
|
+
|
|
91
|
+
Source: docs/guides/prompts.md; src/ui/prompt.ts
|
|
@@ -5,7 +5,7 @@ description: >
|
|
|
5
5
|
metadata:
|
|
6
6
|
type: 'core'
|
|
7
7
|
library: 'stagehand'
|
|
8
|
-
library_version: '0.
|
|
8
|
+
library_version: '0.8.0'
|
|
9
9
|
sources:
|
|
10
10
|
- 'miaklwalker/stagehand:docs/guides/phases-and-steps.md'
|
|
11
11
|
- 'miaklwalker/stagehand:docs/guides/script-options-and-result.md'
|
|
@@ -60,6 +60,10 @@ new Script<{ n: number }>({ name: "t" })
|
|
|
60
60
|
|
|
61
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
62
|
|
|
63
|
+
### Two ways to do one step: `addBranch`
|
|
64
|
+
|
|
65
|
+
`addBranch({ name, condition, onTrue, onFalse })` runs one of two arms that return the same shape, so the context stays typed either way. It takes `retry` and `timeoutMs` like any step, but no `rollback`, `cache` or `clean`. See skills/building-scripts/branching-steps/SKILL.md.
|
|
66
|
+
|
|
63
67
|
### Retry with backoff and veto specific errors
|
|
64
68
|
|
|
65
69
|
```ts
|
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: 'caching-expensive-work'
|
|
3
3
|
description: >
|
|
4
|
-
fileStore/memoryStore, per-slot stale/schema options, per-run CacheMode
|
|
4
|
+
fileStore/memoryStore, per-slot key/stale/schema options, per-run CacheMode
|
|
5
5
|
("on"/"off"/"refresh"/"read-only"), and reaching a cached slot with
|
|
6
6
|
context.cache (read/write/clear/ageOf) from a later step or rollback. Load
|
|
7
7
|
this for "skip re-running an expensive phase," "invalidate a cache entry
|
|
8
8
|
from a later step," "correct a cached value without resetting its TTL," or
|
|
9
|
-
"guard a cache entry against an old script shape
|
|
9
|
+
"guard a cache entry against an old script shape," or "rerun a step only
|
|
10
|
+
when its input changed since last time."
|
|
10
11
|
metadata:
|
|
11
12
|
type: 'core'
|
|
12
13
|
library: 'stagehand'
|
|
13
|
-
library_version: '0.
|
|
14
|
+
library_version: '0.8.0'
|
|
14
15
|
sources:
|
|
15
16
|
- 'miaklwalker/stagehand:docs/guides/caching.md'
|
|
16
17
|
- 'miaklwalker/stagehand:docs/reference/cache.md'
|
|
@@ -23,9 +24,9 @@ sources:
|
|
|
23
24
|
# Stagehand — Caching Expensive Work
|
|
24
25
|
|
|
25
26
|
A phase or step can point at a store and stop repeating its work on later
|
|
26
|
-
runs.
|
|
27
|
-
|
|
28
|
-
`stale`
|
|
27
|
+
runs. A slot's name is derived from the phase's or step's own name, and it
|
|
28
|
+
holds one entry. Whether that entry can be reused is up to `key` (was it
|
|
29
|
+
computed for the same thing?) and `stale` (is it still fresh?).
|
|
29
30
|
|
|
30
31
|
## Setup
|
|
31
32
|
|
|
@@ -71,16 +72,32 @@ const BuildSchema = z.object({ artifact: z.string() });
|
|
|
71
72
|
name: "compile",
|
|
72
73
|
cache: {
|
|
73
74
|
store: cache,
|
|
74
|
-
stale: ({ value, ctx, ageMs }) => ageMs > 3_600_000,
|
|
75
|
+
stale: ({ value, ctx, flags, ageMs }) => flags.fresh || ageMs > 3_600_000,
|
|
75
76
|
schema: BuildSchema,
|
|
76
77
|
},
|
|
77
78
|
handler: async ({ input }) => ({ artifact: `dist/${input.ref}.tgz` }),
|
|
78
79
|
})
|
|
79
80
|
```
|
|
80
81
|
|
|
81
|
-
`stale` and `schema` both see the live context as of that step; a stored
|
|
82
|
+
`stale` and `schema` both see the live context as of that step (`stale` also gets `flags`, so a `--fresh` flag can force a miss); a stored
|
|
82
83
|
value that fails `schema` is treated as a miss, not an error.
|
|
83
84
|
|
|
85
|
+
### Rerunning only when the input changed, with `key`
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
.addStep({
|
|
89
|
+
name: "price",
|
|
90
|
+
cache: { store: cache, key: ({ ctx }) => [...ctx.skus].sort().join(",") },
|
|
91
|
+
handler: async ({ ctx }) => ({ prices: await priceAll(ctx.skus) }),
|
|
92
|
+
})
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`key` returns a string, stored with the entry and compared with `===` next
|
|
96
|
+
run. Same key hits; a different key — or an entry written before `key` was
|
|
97
|
+
added — reruns and overwrites, reported as `cacheMiss: "key changed"` on the
|
|
98
|
+
`StepReport`. There is one entry, not one per key. The function owns
|
|
99
|
+
equality: sort when order doesn't matter, serialise objects deliberately.
|
|
100
|
+
|
|
84
101
|
### Controlling caching per run without touching the script
|
|
85
102
|
|
|
86
103
|
```ts
|
|
@@ -238,6 +255,47 @@ A store that throws — an unreadable file, a full disk, a buggy `stale` predica
|
|
|
238
255
|
|
|
239
256
|
Source: docs/guides/caching.md ("A store that throws... logs a warning and does the work")
|
|
240
257
|
|
|
258
|
+
### HIGH Encoding a cache key through `stale` and a returned field
|
|
259
|
+
|
|
260
|
+
Wrong:
|
|
261
|
+
```ts
|
|
262
|
+
.addStep({
|
|
263
|
+
name: "price",
|
|
264
|
+
cache: { store, stale: ({ value, ctx }) => (value as { sku: string }).sku !== ctx.sku },
|
|
265
|
+
handler: async ({ ctx }) => ({ sku: ctx.sku, price: await price(ctx.sku) }), // sku leaks into ctx
|
|
266
|
+
})
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
Correct:
|
|
270
|
+
```ts
|
|
271
|
+
.addStep({
|
|
272
|
+
name: "price",
|
|
273
|
+
cache: { store, key: ({ ctx }) => ctx.sku },
|
|
274
|
+
handler: async ({ ctx }) => ({ price: await price(ctx.sku) }),
|
|
275
|
+
})
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
`key` stores the fingerprint beside the entry instead of inside the value, so the handler's output — and the context type downstream — carries only what later steps need.
|
|
279
|
+
|
|
280
|
+
Source: README.md ("Caching"); src/cache.ts readCache()
|
|
281
|
+
|
|
282
|
+
### MEDIUM Returning a non-canonical key
|
|
283
|
+
|
|
284
|
+
Wrong:
|
|
285
|
+
```ts
|
|
286
|
+
cache: { store, key: ({ ctx }) => ctx.skus.join(",") } // API order varies run to run
|
|
287
|
+
cache: { store, key: ({ ctx }) => JSON.stringify(ctx.filter) } // object key order varies too
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
Correct:
|
|
291
|
+
```ts
|
|
292
|
+
cache: { store, key: ({ ctx }) => [...ctx.skus].sort().join(",") }
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
Keys are compared with `===` and the library never normalises them, so two orderings of the same data are two keys — a needless miss every time the order shifts.
|
|
296
|
+
|
|
297
|
+
Source: README.md ("Caching")
|
|
298
|
+
|
|
241
299
|
### MEDIUM Patching a value with `cache.write` and losing its age
|
|
242
300
|
|
|
243
301
|
Wrong:
|
|
@@ -83,11 +83,12 @@ const stepSlot = (phase: string, step: string): string => `${phase}::${step}`;
|
|
|
83
83
|
interface CachedEntry {
|
|
84
84
|
value: unknown;
|
|
85
85
|
savedAt: number; // Date.now() at the moment it was written
|
|
86
|
+
key?: string; // what `key` returned on the run that wrote it, if declared
|
|
86
87
|
}
|
|
87
88
|
```
|
|
88
89
|
|
|
89
90
|
The exact shape a `CacheStore` reads and writes — one stored result plus its
|
|
90
|
-
write timestamp.
|
|
91
|
+
write timestamp, and the key it was computed for when the slot declares one.
|
|
91
92
|
|
|
92
93
|
## `CacheSource<In, Ctx, Value>`
|
|
93
94
|
|
|
@@ -107,12 +108,21 @@ expands it to `{ store: source }`; anything else is passed through as
|
|
|
107
108
|
```ts
|
|
108
109
|
interface CacheOptions<In = unknown, Ctx = unknown, Value = unknown> {
|
|
109
110
|
store: CacheStore;
|
|
111
|
+
key?: (context: KeyContext<In, Ctx>) => Awaitable<string>;
|
|
110
112
|
stale?: (context: StaleContext<In, Ctx>) => Awaitable<boolean>;
|
|
111
113
|
schema?: StandardSchemaV1<unknown, Value>;
|
|
112
114
|
}
|
|
113
115
|
```
|
|
114
116
|
|
|
115
|
-
- `store` is required; `stale` and `schema` are
|
|
117
|
+
- `store` is required; `key`, `stale` and `schema` are all optional.
|
|
118
|
+
- `key` is evaluated once, against `{ input, ctx, flags }` as they stand
|
|
119
|
+
entering the phase or step, before the read. Its string is compared with
|
|
120
|
+
`===` against the stored entry's `key`; a mismatch (including an entry with
|
|
121
|
+
no key) is a miss with reason `"key changed"`, and the write that follows
|
|
122
|
+
stores the new key. There is still one entry per slot — it is not a memo
|
|
123
|
+
table. A `key` that throws or returns a non-string logs a warning, runs the
|
|
124
|
+
work, and neither reads nor writes the slot. `run(…, { cache: "off" })`
|
|
125
|
+
never calls it.
|
|
116
126
|
- `stale` returning `true` treats a stored entry as a miss — the work runs
|
|
117
127
|
again and the entry is overwritten on success.
|
|
118
128
|
- `schema`, when given, validates the stored value on every read via
|
|
@@ -157,18 +167,18 @@ Passed as `run(input, { cache })`. Default is `"on"`.
|
|
|
157
167
|
Implementation detail from `src/cache.ts`:
|
|
158
168
|
|
|
159
169
|
```ts
|
|
160
|
-
export async function readCache(cache, slot, input, ctx, mode) {
|
|
170
|
+
export async function readCache(cache, slot, key, input, ctx, mode, flags) {
|
|
161
171
|
if (mode === "off" || mode === "refresh") return MISS;
|
|
162
|
-
// ...store.read, schema validate, stale check...
|
|
172
|
+
// ...store.read, key compare, schema validate, stale check...
|
|
163
173
|
}
|
|
164
174
|
|
|
165
|
-
export async function writeCache(cache, slot, value, mode) {
|
|
175
|
+
export async function writeCache(cache, slot, value, key, mode) {
|
|
166
176
|
if (mode === "off" || mode === "read-only") return;
|
|
167
|
-
await cache.store.write(slot, { value, savedAt: Date.now() });
|
|
177
|
+
await cache.store.write(slot, { value, savedAt: Date.now(), key });
|
|
168
178
|
}
|
|
169
179
|
```
|
|
170
180
|
|
|
171
|
-
A store or `stale`/`schema` call that throws is caught elsewhere in
|
|
181
|
+
A store or `key`/`stale`/`schema` call that throws is caught elsewhere in
|
|
172
182
|
`script.ts` and treated as a miss with a logged warning — caching never
|
|
173
183
|
decides whether the run itself passes or fails.
|
|
174
184
|
|
|
@@ -10,7 +10,7 @@ description: >
|
|
|
10
10
|
metadata:
|
|
11
11
|
type: 'core'
|
|
12
12
|
library: 'stagehand'
|
|
13
|
-
library_version: '0.
|
|
13
|
+
library_version: '0.8.0'
|
|
14
14
|
sources:
|
|
15
15
|
- 'miaklwalker/stagehand:docs/guides/rollbacks.md'
|
|
16
16
|
- 'miaklwalker/stagehand:docs/reference/errors.md'
|
|
@@ -26,6 +26,8 @@ completed, first undone. The step that actually failed is never compensated: it
|
|
|
26
26
|
there's nothing to undo. A rollback gets no context by default; it has to name every key it needs
|
|
27
27
|
through `rollbackKeys`, which both narrows and permanently reserves them.
|
|
28
28
|
|
|
29
|
+
Rollback timing, retries and scope are specified exactly in docs/guides/error-handling.md (summary: skills/errors-results/handling-results-and-errors/SKILL.md, "The failure contract"). Notably, retries do not trigger rollback and the failed step is never rolled back.
|
|
30
|
+
|
|
29
31
|
## Setup
|
|
30
32
|
|
|
31
33
|
```ts
|
|
@@ -8,16 +8,17 @@ description: >
|
|
|
8
8
|
metadata:
|
|
9
9
|
type: 'core'
|
|
10
10
|
library: 'stagehand'
|
|
11
|
-
library_version: '0.
|
|
11
|
+
library_version: '0.8.0'
|
|
12
12
|
sources:
|
|
13
13
|
- 'miaklwalker/stagehand:docs/guides/phases-and-steps.md'
|
|
14
14
|
- 'miaklwalker/stagehand:docs/guides/reusable-scripts.md'
|
|
15
|
+
- 'miaklwalker/stagehand:docs/guides/flags.md'
|
|
15
16
|
- 'miaklwalker/stagehand:src/script.ts'
|
|
16
17
|
---
|
|
17
18
|
|
|
18
19
|
# Stagehand — Conditional Steps and Phases
|
|
19
20
|
|
|
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
|
+
`when` on `addStep` or `addPhase` is a function of `{ input, ctx, flags }` (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
|
|
|
22
23
|
## Setup
|
|
23
24
|
|
|
@@ -58,7 +59,7 @@ new Script<{ environment: "development" | "production" }>({ name: "deploy" })
|
|
|
58
59
|
});
|
|
59
60
|
```
|
|
60
61
|
|
|
61
|
-
`when` sees the same `{ input, ctx }`
|
|
62
|
+
`when` sees the same `{ input, ctx, flags }` a handler does, so it can read anything an earlier step already put in context, and any `defineFlag` value (`when: ({ flags }) => !flags.quiet`).
|
|
62
63
|
|
|
63
64
|
### Gate an entire phase
|
|
64
65
|
|
|
@@ -87,6 +88,21 @@ A falsy phase-level `when` marks every step in `"Release"` `"skipped"` in one sh
|
|
|
87
88
|
|
|
88
89
|
`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
|
|
|
91
|
+
### Need the output either way? Use `addBranch`
|
|
92
|
+
|
|
93
|
+
A skipped step produces nothing, but `ctx` is still typed as though it ran. When later steps depend on the value, pick between two ways of producing it instead:
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
.addBranch({
|
|
97
|
+
name: "resolve commit",
|
|
98
|
+
condition: ({ flags }) => Boolean(flags.sha),
|
|
99
|
+
onTrue: ({ flags }) => ({ sha: flags.sha as string }),
|
|
100
|
+
onFalse: async () => ({ sha: await git.head() }),
|
|
101
|
+
})
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
See skills/building-scripts/branching-steps/SKILL.md.
|
|
105
|
+
|
|
90
106
|
## Common Mistakes
|
|
91
107
|
|
|
92
108
|
### MEDIUM Skipped step's `clean` still runs
|