@pikku/skills 0.12.6 → 0.12.9
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/dist/skills.gen.js +1 -1
- package/package.json +1 -1
- package/skills/pikku-addon/SKILL.md +77 -7
- package/skills/pikku-concepts/SKILL.md +1 -1
- package/skills/pikku-config/SKILL.md +1 -1
- package/skills/pikku-knowledge/SKILL.md +50 -7
- package/skills/pikku-scenario/SKILL.md +33 -1
- package/skills/pikku-workflow/SKILL.md +94 -16
package/package.json
CHANGED
|
@@ -50,9 +50,13 @@ wireAddon({
|
|
|
50
50
|
mcp?: boolean,
|
|
51
51
|
tags?: string[], // Tags applied to all addon functions
|
|
52
52
|
scopes?: string[], // Required of every function, on top of its own
|
|
53
|
-
secretOverrides?: Record<string, string>, // Remap secret names
|
|
53
|
+
secretOverrides?: Record<string, string>, // Remap secret names (and grant them)
|
|
54
54
|
variableOverrides?: Record<string, string>, // Remap variable names
|
|
55
|
-
credentialOverrides?: Record<string, string>, // Remap credential names
|
|
55
|
+
credentialOverrides?: Record<string, string>, // Remap credential names (and grant them)
|
|
56
|
+
secretGrants?: string[], // Secrets the app lends this addon
|
|
57
|
+
credentialGrants?: string[], // Credentials the app lends this addon
|
|
58
|
+
globalSecrets?: string, // Reason for handing over the whole SecretService
|
|
59
|
+
globalCredentials?: string, // Reason for handing over the whole CredentialService
|
|
56
60
|
})
|
|
57
61
|
```
|
|
58
62
|
|
|
@@ -61,6 +65,58 @@ it would weaken the wiring's own gate — so the addon-level setting can require
|
|
|
61
65
|
session but never waive one. The same package wired twice under two namespaces is
|
|
62
66
|
governed by the union of both instances' scopes and tags.
|
|
63
67
|
|
|
68
|
+
### An addon reads only the secrets it declared
|
|
69
|
+
|
|
70
|
+
An addon's `SecretService` and `CredentialService` are **scoped**: it may read
|
|
71
|
+
the secrets its own source declares (literal `getSecret('X')` calls and
|
|
72
|
+
`wireSecret` definitions, which the CLI collects into `declaredSecrets`) and
|
|
73
|
+
nothing else. Anything undeclared throws `Access denied to secret key: X` at
|
|
74
|
+
runtime. The same holds for credentials, and a scoped addon can never call
|
|
75
|
+
`getAllUsers()`.
|
|
76
|
+
|
|
77
|
+
That works for an addon naming its own secrets. It does not work for a _generic_
|
|
78
|
+
addon whose secret names arrive as data — `@pikku/addon-graph` reads
|
|
79
|
+
`getSecret(auth.credential)`, where the name comes off the workflow node — so
|
|
80
|
+
such an addon declares nothing and is scoped to nothing. Only the consuming app
|
|
81
|
+
can widen it, with one of three fields:
|
|
82
|
+
|
|
83
|
+
```typescript
|
|
84
|
+
wireAddon({
|
|
85
|
+
name: 'graph',
|
|
86
|
+
package: '@pikku/addon-graph',
|
|
87
|
+
|
|
88
|
+
secretGrants: ['STRIPE_KEY'], // lend these, unrenamed
|
|
89
|
+
secretOverrides: { MAILGUN_KEY: 'PROD_EMAIL_KEY' }, // lend + rename
|
|
90
|
+
// globalSecrets: 'why no static list can cover it' // lend everything
|
|
91
|
+
})
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
| field | meaning |
|
|
95
|
+
| ----------------- | --------------------------------------- |
|
|
96
|
+
| `secretOverrides` | grant **and** rename |
|
|
97
|
+
| `secretGrants` | grant as-is |
|
|
98
|
+
| `globalSecrets` | grant everything, with a written reason |
|
|
99
|
+
|
|
100
|
+
**Grants name the secret as the addon reads it**, not as your project stores it.
|
|
101
|
+
Scoping is checked _before_ the override map renames anything, so an overridden
|
|
102
|
+
secret is granted by its addon-side key — which is also why an override's key
|
|
103
|
+
grants and its value does not. With no rename in play the two names coincide.
|
|
104
|
+
|
|
105
|
+
`globalSecrets` / `globalCredentials` take the _reason_ for the grant, not a
|
|
106
|
+
boolean, because every grant is enumerated in the deploy manifest
|
|
107
|
+
(`unscopedSecretAddons`, `grantedSecretAddons`). Prefer `secretGrants` — reach
|
|
108
|
+
for `globalSecrets` only when no static list can exist, and never for an addon
|
|
109
|
+
that performs outbound requests, where an unrestricted secret read is an
|
|
110
|
+
exfiltration primitive.
|
|
111
|
+
|
|
112
|
+
A grant naming a secret your project does not declare is a build error from
|
|
113
|
+
`pikku all`, resolved through the override map first:
|
|
114
|
+
|
|
115
|
+
```
|
|
116
|
+
Secret grant 'STIRPE_KEY' in addon 'graph' (@pikku/addon-graph) targets a secret
|
|
117
|
+
that does not exist. Available secrets: BETTER_AUTH_SECRET, GITHUB_OAUTH
|
|
118
|
+
```
|
|
119
|
+
|
|
64
120
|
### `ref(name)`
|
|
65
121
|
|
|
66
122
|
Type-safe reference to a function — local or addon — for use in any wiring. It
|
|
@@ -94,7 +150,8 @@ import { pikkuAddonServices } from '#pikku'
|
|
|
94
150
|
|
|
95
151
|
export const createSingletonServices = pikkuAddonServices(
|
|
96
152
|
async (config, { secrets, logger }) => {
|
|
97
|
-
const creds =
|
|
153
|
+
const creds =
|
|
154
|
+
await secrets.getSecret<GithubCredentials>('GITHUB_CREDENTIALS')
|
|
98
155
|
return { github: new GithubService(creds.reveal()) }
|
|
99
156
|
}
|
|
100
157
|
)
|
|
@@ -184,11 +241,24 @@ approvalDescription: async (_services, { title }) => `Add a todo called "${title
|
|
|
184
241
|
### Build
|
|
185
242
|
|
|
186
243
|
```bash
|
|
187
|
-
|
|
188
|
-
yarn tsc
|
|
189
|
-
cp -r .pikku dist/ #
|
|
244
|
+
yarn pikku all # Generate types
|
|
245
|
+
yarn tsc # Compile TypeScript
|
|
246
|
+
cp -r .pikku types dist/ # Ship the generated files and the types they import
|
|
247
|
+
yarn pikku validate # Check the published file set holds together
|
|
190
248
|
```
|
|
191
249
|
|
|
250
|
+
`yarn pikku`, not `npx pikku`: a scaffolded addon carries `@pikku/cli` as a
|
|
251
|
+
devDependency, and building it against a different CLI than it declares is how
|
|
252
|
+
generated output ends up disagreeing with the packaged one. `npx pikku new
|
|
253
|
+
addon` above is the exception — it runs before the addon, and its CLI, exist.
|
|
254
|
+
|
|
255
|
+
`types/` has to be copied alongside `.pikku`: the generated files import
|
|
256
|
+
`SingletonServices`, `Services`, `Config` and `UserSession` from
|
|
257
|
+
`../../types/application-types.d.js`, and `tsc` never emits a hand-written
|
|
258
|
+
`.d.ts` to `outDir`, so nothing else puts it in `dist`. Leave it out and the
|
|
259
|
+
addon installs fine and fails to typecheck in every app that depends on it —
|
|
260
|
+
which is what `pikku validate` is there to catch before you publish.
|
|
261
|
+
|
|
192
262
|
## Consuming an Addon
|
|
193
263
|
|
|
194
264
|
### Install & Register
|
|
@@ -204,7 +274,7 @@ import { wireAddon } from '#pikku'
|
|
|
204
274
|
wireAddon({ name: 'todos', package: '@my-org/addon-todos' })
|
|
205
275
|
```
|
|
206
276
|
|
|
207
|
-
After registration, run `
|
|
277
|
+
After registration, run `yarn pikku all` to generate types for the addon's functions.
|
|
208
278
|
|
|
209
279
|
### Call via RPC
|
|
210
280
|
|
|
@@ -230,7 +230,7 @@ await server.start()
|
|
|
230
230
|
|
|
231
231
|
**Lifecycle hooks do not run on this path** — only `pikku dev` and `pikku serve` invoke them. Do your startup work directly in the entrypoint instead.
|
|
232
232
|
|
|
233
|
-
`pikku
|
|
233
|
+
`pikku validate` warns when a project starts a server by hand *and* depends on no runtime adapter, since that combination means path 1 was available and unused. Silence it with `"lint": { "customServerBootstrap": "off" }` in `pikku.config.json`.
|
|
234
234
|
|
|
235
235
|
## Code Generation
|
|
236
236
|
|
|
@@ -243,7 +243,7 @@ const apiKey = services.variables.get('API_KEY')
|
|
|
243
243
|
}
|
|
244
244
|
```
|
|
245
245
|
|
|
246
|
-
`customServerBootstrap` is the one evaluated by `pikku
|
|
246
|
+
`customServerBootstrap` is the one evaluated by `pikku validate` rather than codegen: it warns when the root `start`/`dev` script boots a server without `pikku dev` / `pikku serve` and no runtime adapter is installed. Set it to `"off"` to keep a hand-rolled entrypoint, or `"error"` to enforce the hooks.
|
|
247
247
|
|
|
248
248
|
## Complete Example
|
|
249
249
|
|
|
@@ -5,13 +5,14 @@ description: >-
|
|
|
5
5
|
notes that say what the app is, in the language its users use. Covers the Open Knowledge Format
|
|
6
6
|
note (path-as-identity markdown, YAML frontmatter, only `type` required), the sections of the
|
|
7
7
|
app-project profile (slices, entities, decisions, questions, wishlist) and the one question each
|
|
8
|
-
answers, slice status/entities/gherkin rules, the `resource:` URI scheme
|
|
8
|
+
answers, slice status/entities/gherkin rules, the `resource:` URI scheme tying a note to the
|
|
9
9
|
code it is about, the shapes that are NOT a knowledge base, and the `pikku knowledge
|
|
10
|
-
validate|index` commands. TRIGGER when: user asks to write down a decision,
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
validate|index` commands. TRIGGER when: user asks to write down a decision, requirement, entity
|
|
11
|
+
or open question; asks what the app does or is; asks about knowledge/, notes, slices,
|
|
12
|
+
an index.md, or a diagram, callout or decision block; or hands over a product
|
|
13
|
+
brief to record. DO NOT TRIGGER when: user asks what
|
|
13
14
|
functions, routes, tables or permissions exist (that is `pikku meta` / `pikku info`, never a
|
|
14
|
-
note), or
|
|
15
|
+
note), or to write a scenario test (use pikku-scenario).
|
|
15
16
|
installGroups: [core]
|
|
16
17
|
---
|
|
17
18
|
|
|
@@ -142,7 +143,49 @@ And writing again replaces it rather than adding a second
|
|
|
142
143
|
|
|
143
144
|
- **`status`** is `proposed` → `dispatched` → `built`. Nothing else. Every gate compares it literally.
|
|
144
145
|
- **`entities`** lists what the slice touches, **at most three**. Past three it is not one buildable piece — split it.
|
|
145
|
-
- **The scenario is a fenced `gherkin` block, in the third person.** `Given 'owner' has no entry` — never `Given I have no entry`. A quoted word _means a persona_, which is what lets a reader (and a test) tell who is acting. First person hides that, so it is rejected.
|
|
146
|
+
- **The scenario is a fenced `gherkin` block, in the third person.** `Given 'owner' has no entry` — never `Given I have no entry`. A quoted word _means a persona_, which is what lets a reader (and a test) tell who is acting. First person hides that, so it is rejected. The console draws the keywords as a column and each quoted persona as a chip, so a first-person scenario is visibly a block with no personas in it.
|
|
147
|
+
|
|
148
|
+
## Showing it
|
|
149
|
+
|
|
150
|
+
A note is markdown, and four kinds of block are **drawn** rather than printed. Every one of them degrades to something readable — a diagram falls back to its source, a callout to a blockquote, a decision to a code block — so writing one costs nothing where it is not rendered.
|
|
151
|
+
|
|
152
|
+
None of this changes the governing rule. A diagram of the schema is still a copy of `pikku meta` that drifts, and it drifts while looking more authoritative than prose would. These are for the part no generator can derive.
|
|
153
|
+
|
|
154
|
+
**```mermaid — when the relationship is the point.** Prose is bad at graphs: "an entry belongs to a day, a day belongs to an owner, and a grant lets another owner read a day" is a sentence a reader has to re-read twice and draw themselves. Reach for one when a note is about how several things relate, an order of steps across time, or a state machine. Do not draw one thing, or two things and an arrow — that is a sentence.
|
|
155
|
+
|
|
156
|
+
````markdown
|
|
157
|
+
```mermaid
|
|
158
|
+
flowchart LR
|
|
159
|
+
owner -->|writes| entry
|
|
160
|
+
entry -->|belongs to| day
|
|
161
|
+
owner -->|grants read on| day
|
|
162
|
+
```
|
|
163
|
+
````
|
|
164
|
+
|
|
165
|
+
**`> [!NOTE]` — when a line must survive skimming.** Five kinds: `NOTE`, `TIP`, `IMPORTANT`, `WARNING`, `CAUTION`. Use one for the thing a reader who skips the paragraph must still not miss — a trap, a constraint that is easy to violate, an assumption the rest of the note rests on. Two callouts in a note is normal; six means the note has no prose left and nothing stands out.
|
|
166
|
+
|
|
167
|
+
```markdown
|
|
168
|
+
> [!WARNING]
|
|
169
|
+
> A grant is checked on every request, not cached. A permission change is
|
|
170
|
+
> immediate everywhere, and there is no invalidation step to forget.
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
**```decision — the answer a decision note owes.** `decisions/` answers "what was chosen, and what does that rule out?", and the second half is the half that gets dropped. The fence makes it checkable: `pikku knowledge validate` warns when a fence says what was chosen and never says what it closes off.
|
|
174
|
+
|
|
175
|
+
````markdown
|
|
176
|
+
```decision
|
|
177
|
+
chosen: A revoked grant stops working immediately, everywhere.
|
|
178
|
+
rules-out:
|
|
179
|
+
- A "revoked but valid until midnight" state
|
|
180
|
+
- A scheduled cleanup job
|
|
181
|
+
because: Two people disagreeing about who can see today is worse than one of
|
|
182
|
+
them losing access mid-session.
|
|
183
|
+
```
|
|
184
|
+
````
|
|
185
|
+
|
|
186
|
+
It is a **summary, not the note** — the argument continues in prose underneath. `rules-out:` takes one line or a `- item` block, and any value too long for one line wraps onto indented lines under it, as `because:` does above. A decision genuinely argued in prose needs no fence, and validate never asks for one; what it does ask is that a fence you did write is complete.
|
|
187
|
+
|
|
188
|
+
**Fences of any other language are code** — highlighted and copyable, which is right for a snippet and wrong for a scenario or a decision, so do not put either in a bare fence.
|
|
146
189
|
|
|
147
190
|
## `resource:` — tying a note to the code
|
|
148
191
|
|
|
@@ -196,7 +239,7 @@ pikku knowledge index # refresh every index.md
|
|
|
196
239
|
pikku knowledge index --check # report stale indexes without writing (CI gate)
|
|
197
240
|
```
|
|
198
241
|
|
|
199
|
-
`validate` reports: notes with no `type`, a missing `knowledge/index.md`, a section with no `index.md`, notes flat at the root, sections that duplicate what the project already declares, slices with a bad or missing `status`, slices over three entities, slices with no gherkin block or a first-person one, and every `resource:` that no longer resolves. Errors fail the command; warnings do not.
|
|
242
|
+
`validate` reports: notes with no `type`, a missing `knowledge/index.md`, a section with no `index.md`, notes flat at the root, sections that duplicate what the project already declares, slices with a bad or missing `status`, slices over three entities, slices with no gherkin block or a first-person one, `decision` fences that state no `chosen:` or rule nothing out, and every `resource:` that no longer resolves. Errors fail the command; warnings do not.
|
|
200
243
|
|
|
201
244
|
`index` rewrites only the block between `<!-- pikku:knowledge-index -->` markers, creating a scaffolded `index.md` for a section that has none. It is idempotent — running it twice changes nothing.
|
|
202
245
|
|
|
@@ -5,7 +5,7 @@ description: >-
|
|
|
5
5
|
test coverage. A scenario (pikkuScenario) drives the app the way users do — steps run as actors
|
|
6
6
|
over the real transport against a running server — so a flow doubles as an e2e test and a
|
|
7
7
|
staged/production health check. Covers scenario.do / expectEventually / expectError /
|
|
8
|
-
expectService, declared steps via pikkuScenarioStep (including browser steps driven by
|
|
8
|
+
expectService / expectScore, declared steps via pikkuScenarioStep (including browser steps driven by
|
|
9
9
|
@pikku/playwright) written as intent rather than as clicks, with the actions factored into
|
|
10
10
|
shared browser utilities, actors and environments in pikku.config.json, SCENARIO_ACTOR_SECRET, the
|
|
11
11
|
`pikku scenario list|run` commands, live function coverage via `pikku dev --coverage`, and
|
|
@@ -91,6 +91,7 @@ A scenario takes the same config fields as a workflow (`title`, `description`, `
|
|
|
91
91
|
| `scenario.expectEventually(step, rpc, data, predicate, { actor, within, interval })` | Poll until `predicate(out)` passes or `within` elapses. For anything asynchronous — queues, workers, eventual state. |
|
|
92
92
|
| `scenario.expectError(step, rpc, data, { actor, matches })` | Assert the call **fails**. For fault injection and negative paths. |
|
|
93
93
|
| `scenario.expectService(step, 'service.method', { actor, calledWith })` | Assert a stubbed service was called. Requires the server to run with `--test`. |
|
|
94
|
+
| `scenario.expectScore(step, runId, scorer, { atLeast, atMost, reference })` | Grade a finished agent run with a declared scorer and assert the score. See below. |
|
|
94
95
|
| `scenario.given(stepName, step, data, { actor })` | Run a declared `pikkuScenarioStep` as setup. `when` is the same call; `then` also makes the step's bindings witnesses. |
|
|
95
96
|
| `scenario.runScheduledTask(name)` | Fire a wired scheduler on the target now, rather than waiting for its cron. |
|
|
96
97
|
|
|
@@ -98,6 +99,37 @@ A scenario takes the same config fields as a workflow (`title`, `description`, `
|
|
|
98
99
|
|
|
99
100
|
Prefer `expectEventually` over sleeping.
|
|
100
101
|
|
|
102
|
+
### Asserting on an agent's answer (`expectScore`)
|
|
103
|
+
|
|
104
|
+
An agent's output is not comparable to a fixed string, so it is graded rather
|
|
105
|
+
than matched. Declare the rubric with `pikkuAIScorer` (grades in code) or
|
|
106
|
+
`pikkuAIJudge` (grades with a model) in a `*.scorer.ts` file, name it on the
|
|
107
|
+
agent's `scorers`, then assert on the run the scenario just triggered:
|
|
108
|
+
|
|
109
|
+
```typescript
|
|
110
|
+
const { runId } = await scenario.when('asks for a summary', 'runAssistant', {
|
|
111
|
+
prompt: data.prompt,
|
|
112
|
+
}, { actor: actors.user })
|
|
113
|
+
|
|
114
|
+
await scenario.expectScore('answered briefly', runId, 'brevity', { atLeast: 0.8 })
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
The default bound is `atLeast: 0.5`, so an unqualified `expectScore` still fails
|
|
118
|
+
a run the scorer graded zero. `atMost` is for a rubric where high is the failure
|
|
119
|
+
(sycophancy, verbosity). `reference` supplies the answer key a
|
|
120
|
+
`requiresReference` judge grades against — live traffic has none, so such a
|
|
121
|
+
judge is only ever reachable from a scenario.
|
|
122
|
+
|
|
123
|
+
Grading goes through the `pikkuScenarioGradeRun` instrumentation RPC on the
|
|
124
|
+
server under test, which grades from the snapshot the runtime kept at the end of
|
|
125
|
+
the run. Two consequences: the run must have happened on **that** server and be
|
|
126
|
+
recent, and the grade is returned to the scenario rather than recorded — a
|
|
127
|
+
test's score never lands among the production figures. Sampling is ignored, so a
|
|
128
|
+
scorer set to grade 1% of live traffic still grades every scenario run.
|
|
129
|
+
|
|
130
|
+
Tag any scenario whose scorer is a judge `ai-live`: it costs a model call, and
|
|
131
|
+
the default suite excludes it.
|
|
132
|
+
|
|
101
133
|
### Setup and teardown (`before` / `after`)
|
|
102
134
|
|
|
103
135
|
A scenario config takes `before` and `after`. Both have the **same signature as `func`** — `(services, data, wire)` — with the return value discarded:
|
|
@@ -29,11 +29,11 @@ Build durable, multi-step workflows with automatic retry, sleep, suspend/resume,
|
|
|
29
29
|
|
|
30
30
|
## Choosing the right factory
|
|
31
31
|
|
|
32
|
-
| Factory
|
|
33
|
-
|
|
34
|
-
| `pikkuWorkflowFunc`
|
|
35
|
-
| `pikkuWorkflowGraph`
|
|
36
|
-
| `pikkuWorkflowComplexFunc` | Escape hatch only — arbitrary TypeScript, no top-level restriction (e.g. dynamic inline functions the DSL extractor cannot handle).
|
|
32
|
+
| Factory | When to use | Step-graph view? |
|
|
33
|
+
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- |
|
|
34
|
+
| `pikkuWorkflowFunc` | **Default for all new workflows.** Sequential + conditional logic; DSL mode (serialisable, replay-safe). ALL `const`/`let` declarations must be at the top level of the function body (not inside blocks). | ✅ Yes |
|
|
35
|
+
| `pikkuWorkflowGraph` | DAG / fan-out with nodes and typed refs between them. | ✅ Yes |
|
|
36
|
+
| `pikkuWorkflowComplexFunc` | Escape hatch only — arbitrary TypeScript, no top-level restriction (e.g. dynamic inline functions the DSL extractor cannot handle). | ❌ No (loses step-graph view) |
|
|
37
37
|
|
|
38
38
|
**Default to `pikkuWorkflowFunc`.** Use `pikkuWorkflowGraph` ONLY with explicit user approval AND only for a genuine cyclic dependency or Node.js-only import DSL cannot express. Use `pikkuWorkflowComplexFunc` ONLY with explicit user approval — a last-resort escape hatch. Never switch to either just to dodge a PKU641 error; restructure the code instead.
|
|
39
39
|
|
|
@@ -58,7 +58,11 @@ if (priority === 'high') {
|
|
|
58
58
|
|
|
59
59
|
```typescript
|
|
60
60
|
// CORRECT — workflow factories come from the generated types file
|
|
61
|
-
import {
|
|
61
|
+
import {
|
|
62
|
+
pikkuWorkflowFunc,
|
|
63
|
+
pikkuWorkflowGraph,
|
|
64
|
+
pikkuWorkflowComplexFunc,
|
|
65
|
+
} from '#pikku/workflow/pikku-workflow-types.gen.js'
|
|
62
66
|
|
|
63
67
|
// WRONG — '#pikku' does not re-export them (TS2305)
|
|
64
68
|
import { pikkuWorkflowFunc } from '#pikku'
|
|
@@ -73,7 +77,10 @@ import { z } from 'zod'
|
|
|
73
77
|
import { pikkuWorkflowFunc } from '#pikku/workflow/pikku-workflow-types.gen.js'
|
|
74
78
|
|
|
75
79
|
const ProcessOrderInput = z.object({ orderId: z.string(), amount: z.number() })
|
|
76
|
-
const ProcessOrderOutput = z.object({
|
|
80
|
+
const ProcessOrderOutput = z.object({
|
|
81
|
+
status: z.string(),
|
|
82
|
+
discount: z.number().optional(),
|
|
83
|
+
})
|
|
77
84
|
|
|
78
85
|
export const processOrder = pikkuWorkflowFunc({
|
|
79
86
|
description: 'Process an order through payment and fulfillment',
|
|
@@ -86,7 +93,9 @@ export const processOrder = pikkuWorkflowFunc({
|
|
|
86
93
|
let status: string
|
|
87
94
|
|
|
88
95
|
if (data.amount > 1000) {
|
|
89
|
-
const d = await workflow.do('Apply bulk discount', 'calcDiscount', {
|
|
96
|
+
const d = await workflow.do('Apply bulk discount', 'calcDiscount', {
|
|
97
|
+
amount: data.amount,
|
|
98
|
+
})
|
|
90
99
|
discount = d.discountPercent
|
|
91
100
|
}
|
|
92
101
|
|
|
@@ -129,6 +138,65 @@ await workflow.approval('Manager sign-off', { ... })
|
|
|
129
138
|
`workflow.name`, `workflow.runId` and `await workflow.getRun()` identify the
|
|
130
139
|
current run if a step needs to reference it.
|
|
131
140
|
|
|
141
|
+
### Approval gates: who may answer
|
|
142
|
+
|
|
143
|
+
`workflow.approval(reason, options)` takes a `schema` (a runtime value — the
|
|
144
|
+
payload arrives from an untrusted caller, so a type generic would validate
|
|
145
|
+
nothing), an optional `expiry`, and an optional policy for **who** may answer:
|
|
146
|
+
|
|
147
|
+
```typescript
|
|
148
|
+
const signOff = await workflow.approval('Manager sign-off', {
|
|
149
|
+
schema: SignOffSchema,
|
|
150
|
+
expiry: '3d',
|
|
151
|
+
approvers: 'not-initiator', // four-eyes: anyone but whoever started the run
|
|
152
|
+
approverScope: 'payments:approve', // and they must hold this scope
|
|
153
|
+
})
|
|
154
|
+
if (signOff.status === 'expired') { ... }
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
`approvers` is one of:
|
|
158
|
+
|
|
159
|
+
| value | who may answer |
|
|
160
|
+
| ----------------- | -------------------------------------------------------------------------------------------------------- |
|
|
161
|
+
| `any` _(default)_ | anyone the approve entrypoint admits — the gate is a pause for a decision, not an authorization boundary |
|
|
162
|
+
| `owner` | only the user who started the run |
|
|
163
|
+
| `not-initiator` | anyone **except** the user who started the run |
|
|
164
|
+
|
|
165
|
+
Both options are enforced in two phases, because a decision can legitimately
|
|
166
|
+
arrive before the run has reached the gate:
|
|
167
|
+
|
|
168
|
+
- **At submission**, if the run has already reached the gate. Reaching it
|
|
169
|
+
publishes the policy into the run state, so the approve entrypoint can judge
|
|
170
|
+
the caller against it and refuse with a **403**.
|
|
171
|
+
- **On replay**, for a decision that arrived before the gate — there was no
|
|
172
|
+
policy to judge it against yet, so it is accepted and judged when the workflow
|
|
173
|
+
reaches the gate. Failing there discards the decision and leaves the gate
|
|
174
|
+
closed, exactly as a decision that fails the schema does.
|
|
175
|
+
|
|
176
|
+
So the same rejected decision surfaces as an HTTP error or as a silently
|
|
177
|
+
re-closed gate depending on timing. Both are audited.
|
|
178
|
+
|
|
179
|
+
A gate declaring neither option accepts a decision from anyone the approve
|
|
180
|
+
route lets through; gate the route with `auth`/`permissions` to narrow that.
|
|
181
|
+
|
|
182
|
+
#### What survives the run
|
|
183
|
+
|
|
184
|
+
A settled decision carries `decidedBy` and `decidedAt`, so the answer keeps its
|
|
185
|
+
provenance in the step result:
|
|
186
|
+
|
|
187
|
+
```typescript
|
|
188
|
+
if (signOff.status === 'decided') {
|
|
189
|
+
logger.info(`signed by ${signOff.decidedBy?.userId} at ${signOff.decidedAt}`)
|
|
190
|
+
}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
That record is deleted with the run, though — `deleteRun` cascades to steps and
|
|
194
|
+
history — and an attempt that was _refused_ never reaches a step at all. So
|
|
195
|
+
every answer is also written to the audit sink as `workflow.approval.decided`,
|
|
196
|
+
with `outcome: 'success' | 'denied'`, the decider under `userIdentity`, and the
|
|
197
|
+
run, reason and refusal in `metadata`. Wire an `audit` service to keep it; a
|
|
198
|
+
project without one records nothing and is otherwise unaffected.
|
|
199
|
+
|
|
132
200
|
### Error handling: `onError`, never try/catch
|
|
133
201
|
|
|
134
202
|
**Do not wrap steps in try/catch.** The DSL extractor serialises the body into a
|
|
@@ -137,14 +205,19 @@ graph would no longer describe what actually runs, which is the whole point of
|
|
|
137
205
|
the DSL mode. This is a settled design decision, not a temporary limitation.
|
|
138
206
|
|
|
139
207
|
Use the `onError` step option instead: it names an RPC to invoke when the step
|
|
140
|
-
has failed
|
|
208
|
+
has failed _after_ exhausting its retries.
|
|
141
209
|
|
|
142
210
|
```typescript
|
|
143
|
-
await workflow.do(
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
211
|
+
await workflow.do(
|
|
212
|
+
'Charge',
|
|
213
|
+
'chargePayment',
|
|
214
|
+
{ orderId },
|
|
215
|
+
{
|
|
216
|
+
retries: 3,
|
|
217
|
+
retryDelay: '1s',
|
|
218
|
+
onError: 'refundReservation', // compensation RPC
|
|
219
|
+
}
|
|
220
|
+
)
|
|
148
221
|
```
|
|
149
222
|
|
|
150
223
|
The handler receives `{ error: { message } }`, and the original error is still
|
|
@@ -161,7 +234,9 @@ Full step options: `description`, `retries`, `retryDelay`, `onError` (plus
|
|
|
161
234
|
|
|
162
235
|
```typescript
|
|
163
236
|
const users = await Promise.all(
|
|
164
|
-
data.userIds.map((userId) =>
|
|
237
|
+
data.userIds.map((userId) =>
|
|
238
|
+
workflow.do(`Fetch user ${userId}`, 'getUser', { userId })
|
|
239
|
+
)
|
|
165
240
|
)
|
|
166
241
|
```
|
|
167
242
|
|
|
@@ -182,7 +257,10 @@ export const userOnboarding = pikkuWorkflowGraph({
|
|
|
182
257
|
config: {
|
|
183
258
|
createProfile: { next: ['sendWelcome', 'setupDefaults'] }, // run in parallel
|
|
184
259
|
sendWelcome: {
|
|
185
|
-
input: (ref) => ({
|
|
260
|
+
input: (ref) => ({
|
|
261
|
+
to: ref('createProfile', 'email'),
|
|
262
|
+
subject: 'Welcome!',
|
|
263
|
+
}),
|
|
186
264
|
},
|
|
187
265
|
},
|
|
188
266
|
})
|