@pikku/skills 0.12.9 → 0.12.11
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/CHANGELOG.md +768 -0
- package/dist/skills.gen.js +1 -1
- package/package.json +4 -4
- package/skills/pikku-addon/SKILL.md +20 -14
- package/skills/pikku-addon/references/addon-package-manifest.md +2 -2
- package/skills/{pikku-ai-agent → pikku-agent}/SKILL.md +24 -24
- package/skills/pikku-ai-vercel/SKILL.md +18 -18
- package/skills/pikku-ai-voice/SKILL.md +15 -15
- package/skills/pikku-audit/SKILL.md +28 -13
- package/skills/pikku-aws/SKILL.md +2 -2
- package/skills/pikku-better-auth/SKILL.md +97 -17
- package/skills/pikku-build-app/SKILL.md +621 -0
- package/skills/pikku-build-app/references/multi-app.md +117 -0
- package/skills/pikku-build-app/references/ship.md +98 -0
- package/skills/pikku-build-app/references/theming.md +70 -0
- package/skills/pikku-build-platform/SKILL.md +239 -0
- package/skills/pikku-build-quick/SKILL.md +238 -0
- package/skills/pikku-cli/SKILL.md +7 -7
- package/skills/pikku-cli/references/complete-example.md +1 -1
- package/skills/pikku-concepts/SKILL.md +10 -7
- package/skills/pikku-concepts/references/concept-mapping.md +1 -1
- package/skills/pikku-config/SKILL.md +5 -3
- package/skills/pikku-deploy-azure/SKILL.md +5 -4
- package/skills/pikku-deploy-cloudflare/SKILL.md +9 -9
- package/skills/pikku-deploy-uws/SKILL.md +5 -2
- package/skills/pikku-deps/SKILL.md +42 -3
- package/skills/pikku-emails/SKILL.md +5 -5
- package/skills/pikku-fabric/SKILL.md +27 -3
- package/skills/pikku-fabric-debug/SKILL.md +1 -1
- package/skills/pikku-feature/SKILL.md +5 -4
- package/skills/pikku-http/SKILL.md +4 -4
- package/skills/pikku-http/references/http-options.md +13 -13
- package/skills/pikku-i18n/SKILL.md +2 -1
- package/skills/pikku-info/SKILL.md +1 -1
- package/skills/pikku-knowledge/SKILL.md +13 -13
- package/skills/pikku-kysely/SKILL.md +68 -41
- package/skills/pikku-machine-auth/SKILL.md +10 -10
- package/skills/pikku-mcp/SKILL.md +23 -20
- package/skills/pikku-middleware/SKILL.md +19 -12
- package/skills/pikku-middleware/references/middleware-patterns.md +14 -8
- package/skills/pikku-mongodb/SKILL.md +11 -11
- package/skills/pikku-n8n-import/SKILL.md +12 -12
- package/skills/pikku-n8n-import/SPEC.md +3 -0
- package/skills/pikku-n8n-import/references/addon-mapping.md +14 -8
- package/skills/pikku-n8n-import/references/code-translation.md +26 -22
- package/skills/pikku-n8n-import/references/loops-and-control.md +7 -7
- package/skills/pikku-paraglide/SKILL.md +11 -6
- package/skills/pikku-permissions/SKILL.md +19 -15
- package/skills/pikku-product-second-opinion/README.md +3 -3
- package/skills/pikku-product-second-opinion/SKILL.md +83 -73
- package/skills/pikku-product-second-opinion/example/sample-report.md +15 -12
- package/skills/pikku-product-second-opinion/references/report-template.md +10 -7
- package/skills/pikku-queue/SKILL.md +1 -1
- package/skills/pikku-react/SKILL.md +53 -13
- package/skills/pikku-realtime/SKILL.md +51 -19
- package/skills/pikku-rpc/SKILL.md +1 -1
- package/skills/pikku-rtl/SKILL.md +1 -1
- package/skills/pikku-scenario/SKILL.md +164 -44
- package/skills/pikku-schedule/SKILL.md +6 -1
- package/skills/pikku-schema-ajv/SKILL.md +2 -2
- package/skills/pikku-schema-cfworker/SKILL.md +1 -1
- package/skills/pikku-security/SKILL.md +9 -5
- package/skills/pikku-services/SKILL.md +27 -18
- package/skills/pikku-services/references/audit-wire-service.md +14 -8
- package/skills/pikku-software-archaeology/SKILL.md +27 -23
- package/skills/pikku-software-archaeology/references/blueprint.schema.json +580 -102
- package/skills/pikku-software-archaeology/scripts/validate.mjs +146 -69
- package/skills/pikku-tag-middleware/SKILL.md +1 -0
- package/skills/pikku-template-clone/SKILL.md +2 -1
- package/skills/pikku-trigger/SKILL.md +3 -3
- package/skills/pikku-versioning/SKILL.md +87 -3
- package/skills/pikku-websocket/SKILL.md +4 -3
- package/skills/pikku-workflow/SKILL.md +2 -2
- package/skills/pikku-workflow/references/workflow-reference.md +24 -10
- package/skills/pikku-ws/SKILL.md +5 -2
|
@@ -97,19 +97,38 @@ a one-word change, not a different import:
|
|
|
97
97
|
|
|
98
98
|
```ts
|
|
99
99
|
export class PikkuRealtime {
|
|
100
|
-
constructor(options?: {
|
|
101
|
-
|
|
100
|
+
constructor(options?: {
|
|
101
|
+
reconnect?: boolean
|
|
102
|
+
reconnectDelayMs?: number
|
|
103
|
+
reconnectMaxDelayMs?: number
|
|
104
|
+
})
|
|
105
|
+
setPikkuFetch(fetch: PikkuFetch): void // server URL + auth come from here, not the constructor
|
|
102
106
|
|
|
103
107
|
// WebSocket at /events — many topics on one connection
|
|
104
|
-
subscribe<K extends keyof EventHubTopics>(
|
|
105
|
-
|
|
108
|
+
subscribe<K extends keyof EventHubTopics>(
|
|
109
|
+
topic: K,
|
|
110
|
+
handler: (data: EventHubTopics[K]) => void
|
|
111
|
+
): () => void
|
|
112
|
+
unsubscribe<K extends keyof EventHubTopics>(
|
|
113
|
+
topic: K,
|
|
114
|
+
handler?: (data: EventHubTopics[K]) => void
|
|
115
|
+
): void
|
|
106
116
|
|
|
107
117
|
// SSE at GET /events/:topic — one EventSource per topic
|
|
108
|
-
subscribeToTopic<K extends keyof EventHubTopics>(
|
|
118
|
+
subscribeToTopic<K extends keyof EventHubTopics>(
|
|
119
|
+
topic: K,
|
|
120
|
+
handler: (data: EventHubTopics[K]) => void
|
|
121
|
+
): { close: () => void }
|
|
109
122
|
|
|
110
123
|
// generic escape hatches — see references/other-routes.md
|
|
111
|
-
subscribeToSSE<T>(
|
|
112
|
-
|
|
124
|
+
subscribeToSSE<T>(
|
|
125
|
+
path: string,
|
|
126
|
+
handler: (data: T) => void
|
|
127
|
+
): { close: () => void }
|
|
128
|
+
connectToChannel(
|
|
129
|
+
channelRoute: string,
|
|
130
|
+
protocols?: string | string[]
|
|
131
|
+
): WebSocket
|
|
113
132
|
|
|
114
133
|
close(): void
|
|
115
134
|
}
|
|
@@ -137,14 +156,16 @@ Envelope the payload as `{ topic, data }`: the generated client dispatches on th
|
|
|
137
156
|
`topic` field, so a bare payload arrives but no handler fires.
|
|
138
157
|
|
|
139
158
|
```ts
|
|
140
|
-
import { pikkuFunc } from '#pikku'
|
|
159
|
+
import { pikkuFunc } from '#pikku/function'
|
|
141
160
|
|
|
142
161
|
export const createTodo = pikkuFunc({
|
|
143
162
|
input: CreateTodoInput,
|
|
144
163
|
output: CreateTodoOutput,
|
|
145
164
|
func: async ({ kysely, eventHub }, data) => {
|
|
146
165
|
const todo = await kysely
|
|
147
|
-
.insertInto('todos')
|
|
166
|
+
.insertInto('todos')
|
|
167
|
+
.values(data)
|
|
168
|
+
.returningAll()
|
|
148
169
|
.executeTakeFirstOrThrow()
|
|
149
170
|
|
|
150
171
|
await eventHub.publish('todo-created', null, {
|
|
@@ -160,7 +181,9 @@ A thin helper removes the duplication:
|
|
|
160
181
|
|
|
161
182
|
```ts
|
|
162
183
|
async function publishEvent<K extends keyof EventHubTopics>(
|
|
163
|
-
hub: EventHubService<EventHubTopics>,
|
|
184
|
+
hub: EventHubService<EventHubTopics>,
|
|
185
|
+
topic: K,
|
|
186
|
+
data: EventHubTopics[K]
|
|
164
187
|
) {
|
|
165
188
|
return hub.publish(topic, null, { topic, data })
|
|
166
189
|
}
|
|
@@ -187,7 +210,9 @@ const pikku = createPikku(
|
|
|
187
210
|
// pikku.fetch / pikku.rpc / pikku.realtime — all share the same fetch.
|
|
188
211
|
|
|
189
212
|
createRoot(document.getElementById('root')!).render(
|
|
190
|
-
<PikkuProvider pikku={pikku}
|
|
213
|
+
<PikkuProvider pikku={pikku}>
|
|
214
|
+
<App />
|
|
215
|
+
</PikkuProvider>
|
|
191
216
|
)
|
|
192
217
|
```
|
|
193
218
|
|
|
@@ -214,7 +239,8 @@ function TodoList() {
|
|
|
214
239
|
useEffect(() => {
|
|
215
240
|
// WebSocket multi-topic:
|
|
216
241
|
const off = realtime.subscribe('todo-created', ({ todo }) =>
|
|
217
|
-
setTodos((prev) => [...prev, todo])
|
|
242
|
+
setTodos((prev) => [...prev, todo])
|
|
243
|
+
)
|
|
218
244
|
return off
|
|
219
245
|
|
|
220
246
|
// Single-topic SSE (auto-cleanup on close) instead:
|
|
@@ -223,7 +249,13 @@ function TodoList() {
|
|
|
223
249
|
// return () => sub.close()
|
|
224
250
|
}, [realtime])
|
|
225
251
|
|
|
226
|
-
return
|
|
252
|
+
return (
|
|
253
|
+
<ul>
|
|
254
|
+
{todos.map((t) => (
|
|
255
|
+
<li key={t.id}>{t.title}</li>
|
|
256
|
+
))}
|
|
257
|
+
</ul>
|
|
258
|
+
)
|
|
227
259
|
}
|
|
228
260
|
```
|
|
229
261
|
|
|
@@ -235,12 +267,12 @@ sockets (`subscribeToSSE`, `connectToChannel`). See
|
|
|
235
267
|
|
|
236
268
|
## When to pick which transport
|
|
237
269
|
|
|
238
|
-
| Need | Use
|
|
239
|
-
| ------------------------------------------ |
|
|
240
|
-
| Many topics in one connection | `realtime.subscribe`
|
|
241
|
-
| Single live stream, simple cleanup | `realtime.subscribeToTopic`
|
|
242
|
-
| Bidirectional (client also sends messages) | `realtime.subscribe`
|
|
243
|
-
| WebSockets blocked by infra | `realtime.subscribeToTopic`
|
|
270
|
+
| Need | Use |
|
|
271
|
+
| ------------------------------------------ | --------------------------- |
|
|
272
|
+
| Many topics in one connection | `realtime.subscribe` |
|
|
273
|
+
| Single live stream, simple cleanup | `realtime.subscribeToTopic` |
|
|
274
|
+
| Bidirectional (client also sends messages) | `realtime.subscribe` |
|
|
275
|
+
| WebSockets blocked by infra | `realtime.subscribeToTopic` |
|
|
244
276
|
|
|
245
277
|
Both auto-clean on the server (the eventHub's `onChannelClosed` hook unsubscribes
|
|
246
278
|
all topics for the dead channel id). Don't write manual cleanup unless you're
|
|
@@ -42,7 +42,7 @@ See `pikku-concepts` for the core mental model.
|
|
|
42
42
|
| `rpc.remote(name, data)` | Remote call via DeploymentService |
|
|
43
43
|
| `rpc.exposed(name, data)` | Call functions marked with `expose: true` |
|
|
44
44
|
| `rpc.startWorkflow(name, input)` | Start a workflow (see `pikku-workflow`) |
|
|
45
|
-
| `rpc.agent.run/stream(...)` | Run an AI agent (see `pikku-
|
|
45
|
+
| `rpc.agent.run/stream(...)` | Run an AI agent (see `pikku-agent`) |
|
|
46
46
|
| `rpc.agent.resume/approve(...)` | Answer a tool-approval interrupt |
|
|
47
47
|
| `rpc.agent.interrupt(runId)` | Stop an in-flight run |
|
|
48
48
|
|
|
@@ -18,7 +18,7 @@ Set `dir` **once at the document root** from the active locale, then let the
|
|
|
18
18
|
browser and Mantine mirror everything — _provided_ every custom style is written
|
|
19
19
|
**flow-relative** (start/end), never **physical** (left/right). Get those two
|
|
20
20
|
things right and Arabic, Hebrew, Farsi and Urdu all work with zero per-component
|
|
21
|
-
|
|
21
|
+
_layout_ code — directional icons still need one manual step, covered below.
|
|
22
22
|
|
|
23
23
|
## Agent Operating Procedure
|
|
24
24
|
|
|
@@ -46,7 +46,7 @@ Scenarios live in `srcDirectories` like any other function — by convention `*.
|
|
|
46
46
|
`pikkuScenario` comes from the **generated** workflow types, not `@pikku/core`:
|
|
47
47
|
|
|
48
48
|
```typescript
|
|
49
|
-
import { pikkuScenario } from '#pikku/
|
|
49
|
+
import { pikkuScenario } from '#pikku/scenario'
|
|
50
50
|
|
|
51
51
|
export const orderSupportScenario = pikkuScenario<
|
|
52
52
|
{ value?: number },
|
|
@@ -102,16 +102,23 @@ Prefer `expectEventually` over sleeping.
|
|
|
102
102
|
### Asserting on an agent's answer (`expectScore`)
|
|
103
103
|
|
|
104
104
|
An agent's output is not comparable to a fixed string, so it is graded rather
|
|
105
|
-
than matched. Declare the rubric with `
|
|
106
|
-
`
|
|
105
|
+
than matched. Declare the rubric with `pikkuAgentScorer` (grades in code) or
|
|
106
|
+
`pikkuAgentJudge` (grades with a model) in a `*.scorer.ts` file, name it on the
|
|
107
107
|
agent's `scorers`, then assert on the run the scenario just triggered:
|
|
108
108
|
|
|
109
109
|
```typescript
|
|
110
|
-
const { runId } = await scenario.when(
|
|
111
|
-
|
|
112
|
-
|
|
110
|
+
const { runId } = await scenario.when(
|
|
111
|
+
'asks for a summary',
|
|
112
|
+
'runAssistant',
|
|
113
|
+
{
|
|
114
|
+
prompt: data.prompt,
|
|
115
|
+
},
|
|
116
|
+
{ actor: actors.user }
|
|
117
|
+
)
|
|
113
118
|
|
|
114
|
-
await scenario.expectScore('answered briefly', runId, 'brevity', {
|
|
119
|
+
await scenario.expectScore('answered briefly', runId, 'brevity', {
|
|
120
|
+
atLeast: 0.8,
|
|
121
|
+
})
|
|
115
122
|
```
|
|
116
123
|
|
|
117
124
|
The default bound is `atLeast: 0.5`, so an unqualified `expectScore` still fails
|
|
@@ -168,7 +175,7 @@ Hooks are scenario-only. A `before`/`after` on a `pikkuWorkflowFunc` never runs
|
|
|
168
175
|
`pikkuFeature` groups scenarios the way gherkin's `Feature:` groups `Scenario:`. Scenarios are referenced by **imported identifier**, so a renamed or deleted scenario is a compile error rather than a silent skip:
|
|
169
176
|
|
|
170
177
|
```typescript
|
|
171
|
-
import { pikkuFeature } from '#pikku/
|
|
178
|
+
import { pikkuFeature } from '#pikku/scenario'
|
|
172
179
|
import {
|
|
173
180
|
credentialLazyLoadScenario,
|
|
174
181
|
credentialRoundTripScenario,
|
|
@@ -233,7 +240,7 @@ Utilities are **not steps**. They are plain exported functions, they take the br
|
|
|
233
240
|
|
|
234
241
|
```typescript
|
|
235
242
|
// shop.browser.ts — shared actions. Not steps: nothing here is an intent.
|
|
236
|
-
import type { PikkuBrowserWire } from '
|
|
243
|
+
import type { PikkuBrowserWire } from '#pikku/scenario'
|
|
237
244
|
import type {} from '@pikku/playwright'
|
|
238
245
|
|
|
239
246
|
/** Arrive on the shop, from wherever the browser happens to be. */
|
|
@@ -370,6 +377,9 @@ export const seesTheOrderConfirmed = pikkuScenarioStep<
|
|
|
370
377
|
{ status: string }
|
|
371
378
|
>({
|
|
372
379
|
name: 'seesTheOrderConfirmed',
|
|
380
|
+
// Both bindings run as the persona, so the step declares one and the runner
|
|
381
|
+
// injects `wire.actor` — non-optional in every binding.
|
|
382
|
+
actor: true,
|
|
373
383
|
template: 'sees order {orderId} confirmed',
|
|
374
384
|
// Both run on `--run browser`. Each returns what it observed, and the runner
|
|
375
385
|
// compares them — so this fails when the page disagrees with the database.
|
|
@@ -378,8 +388,9 @@ export const seesTheOrderConfirmed = pikkuScenarioStep<
|
|
|
378
388
|
.locate({ testId: 'order-status', where: { 'data-order': orderId } })
|
|
379
389
|
.getAttribute('data-status'),
|
|
380
390
|
}),
|
|
381
|
-
|
|
382
|
-
|
|
391
|
+
// Through the actor, not through a `rpc` service — see "What a step is given".
|
|
392
|
+
default: async (_services, { orderId }, { actor }) => ({
|
|
393
|
+
status: (await actor.invoke('getOrder', { orderId })).status,
|
|
383
394
|
}),
|
|
384
395
|
})
|
|
385
396
|
```
|
|
@@ -401,8 +412,7 @@ Assertions with no possible browser witness are a different thing and should not
|
|
|
401
412
|
`scenario.do` can only name an RPC. A **step** is a named, typed unit of scenario behaviour whose body is an ordinary pikku function — so it can call several RPCs as its actor, assert, or drive a browser.
|
|
402
413
|
|
|
403
414
|
```typescript
|
|
404
|
-
import { pikkuScenarioStep } from '#pikku/
|
|
405
|
-
import { requireActor } from '@pikku/core/workflow'
|
|
415
|
+
import { pikkuScenarioStep } from '#pikku/scenario'
|
|
406
416
|
|
|
407
417
|
export const buysAnApple = pikkuScenarioStep<
|
|
408
418
|
{ qty: number },
|
|
@@ -411,8 +421,9 @@ export const buysAnApple = pikkuScenarioStep<
|
|
|
411
421
|
name: 'buysAnApple',
|
|
412
422
|
description: 'buys an apple',
|
|
413
423
|
template: 'buys {qty} apples',
|
|
414
|
-
|
|
415
|
-
|
|
424
|
+
actor: true,
|
|
425
|
+
default: async (_services, { qty }, { actor }) => {
|
|
426
|
+
return await actor.invoke('placeOrder', { qty })
|
|
416
427
|
},
|
|
417
428
|
})
|
|
418
429
|
```
|
|
@@ -436,12 +447,63 @@ Rules that bite:
|
|
|
436
447
|
- **The step is referenced by its typed string name, not by importing the const** — exactly like `workflow.do`. The name is the step's `pikkuFuncId` and is checked against the generated step map. A non-literal target is a critical error (`PKU678`).
|
|
437
448
|
- **Steps are not RPCs.** They are deliberately never network-callable — a browser-driving step must not be.
|
|
438
449
|
- **`actor.invoke` is typed over the exposed RPC map**, so the name and the payload are checked and the result comes back narrowed — no cast. `actor.invokeRaw(name, data, { headers })` is the same call reporting `{ status, ok, body }` instead of throwing; use it whenever the refusal _is_ the assertion.
|
|
439
|
-
-
|
|
450
|
+
- **A step that runs as somebody declares `actor: true`**, and the runner injects `wire.actor` — non-optional inside every binding, with no guard to write and nothing to unwrap. A `browser` binding implies it, because a window is opened as somebody. Leave it off for a step with no persona to be: an assertion over what an earlier step returned, or one that posts credentials precisely because it must not reuse an actor's session. Dispatching a step that declared it without `{ actor: actors.x }` fails before the body runs (`ScenarioActorRequired`); a step that did not declare it has no `actor` on its wire at all.
|
|
451
|
+
- **`env` is optional on the wire**, because most steps need nothing from it. Narrow it with `requireScenarioEnv(scenarioStep)` from `#pikku/scenario` rather than a local guard — it names the step and says what to pass. `env` is `{ apiUrl, appUrl? }` from the environment the run targets, and is how a raw-HTTP step learns the target's URL: a step runs in the CLI process, where there is no `variables` service and `process.env` is not the answer.
|
|
440
452
|
- **Steps default to `retries: 0`**, unlike ordinary workflow steps. Retrying a failed assertion is wrong; pass `retries` explicitly if a step is genuinely flaky-by-nature.
|
|
441
453
|
- **Step results are persisted**, so return JSON-serialisable data — never a `Locator` or a client object.
|
|
442
454
|
- **`description` documents the step; `template` is what the report renders.** `template`'s `{placeholders}` are filled from the input the step was called with, so one step reads differently for each call — `sees {state} addon {packageName}` reports as "sees available addon @pikku/addon-stripe". Reflect every input field in the template, and type the values so they read as words (`state?: 'installed' | 'available'`, not `installed?: boolean`). A placeholder with no value renders as nothing and the whitespace collapses.
|
|
443
455
|
- Prose precedence is `options.description` → the step's `template` → the step's own `description` → the positional step name. Repeated names get `#1`, `#2` ordinals, so a `for` loop over a data set is how you write a Scenario Outline. A loop-generated step name is not statically known, so it is matched back to its declaration by step function instead — which works as long as that function's call sites agree on their phase, actor and prose. Two call sites that disagree make the loop step report under its bare runtime name.
|
|
444
456
|
|
|
457
|
+
#### What a step is given
|
|
458
|
+
|
|
459
|
+
A step has the signature of an ordinary pikku function, which makes it look as
|
|
460
|
+
though it runs where the application runs. It does not — **it runs in the CLI
|
|
461
|
+
process**, and the services object is built there, by hand:
|
|
462
|
+
|
|
463
|
+
```typescript
|
|
464
|
+
{ logger, workflowService, workflowRunService, agentRunner? }
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
That is the whole list. There is no `kysely`, no `variables`, no `secrets`, and
|
|
468
|
+
none of the project's own singleton or wire services. A step that destructures
|
|
469
|
+
one gets `undefined` and fails on first use — `Cannot read properties of
|
|
470
|
+
undefined (reading 'selectFrom')` — which reads like a broken container and is
|
|
471
|
+
not.
|
|
472
|
+
|
|
473
|
+
`rpc` is the trap worth naming, because it is present and it throws. It is a
|
|
474
|
+
`guardRpc` whose every member refuses:
|
|
475
|
+
|
|
476
|
+
> Scenario tried to run 'getOrder' as an internal step. Every workflow.do in a
|
|
477
|
+
> scenario must carry { actor: actors.x } so it executes against 'local'.
|
|
478
|
+
|
|
479
|
+
The same guard covers `rpc.agent.run/stream/resume/approve/interrupt` and
|
|
480
|
+
`startWorkflow`.
|
|
481
|
+
|
|
482
|
+
This is the design, not a gap: **everything a step touches of the application
|
|
483
|
+
goes over the wire as somebody.** A test that could reach into the database
|
|
484
|
+
would be testing a different program from the one a person uses. So there are
|
|
485
|
+
exactly three ways in, and they are all through the actor:
|
|
486
|
+
|
|
487
|
+
- `actor.invoke(name, data)` — typed over the exposed RPC map, carrying the
|
|
488
|
+
actor's session. Declare `actor: true` and destructure it off the wire.
|
|
489
|
+
- `.invokeRaw(name, data, { headers })` — same call, reporting
|
|
490
|
+
`{ status, ok, body }`, for when the refusal is the assertion.
|
|
491
|
+
- a plain `fetch` against `requireScenarioEnv(scenarioStep).apiUrl`, for
|
|
492
|
+
anything not an RPC — a websocket, a file upload, a webhook.
|
|
493
|
+
|
|
494
|
+
Two consequences follow, and both shape how steps get written:
|
|
495
|
+
|
|
496
|
+
- **A step cannot observe anything the app does not publish.** If a test needs a
|
|
497
|
+
fact the client never sees, the fix is to emit it on the stream or expose it
|
|
498
|
+
as an RPC — which usually improves the product, since a client debugging the
|
|
499
|
+
same problem needed it too.
|
|
500
|
+
- **`agentRunner` is conditional.** It is built only when the project declares
|
|
501
|
+
agents, and `createDevAgentRunner` needs a base URL *and* a key together
|
|
502
|
+
(`OPENAI_BASE_URL` + `OPENAI_API_KEY`, or the LiteLLM pair). With a key alone
|
|
503
|
+
it returns nothing and `agentRunner` is `undefined`, so `actor.converse`
|
|
504
|
+
fails before the persona says anything. A suite that would rather own its own
|
|
505
|
+
model can pass an `llm` to `runConversation` instead of relying on this one.
|
|
506
|
+
|
|
445
507
|
### Browser steps
|
|
446
508
|
|
|
447
509
|
Declaring a `browser` binding is the whole switch: inside that binding `wire.browser` is guaranteed present and non-optional, and a step without one never sees a browser at all. There is nothing to null-check.
|
|
@@ -451,17 +513,20 @@ A `browser` binding gets a session bound to **its actor**, signed in through the
|
|
|
451
513
|
Browser steps are where **intent, not actions** earns its keep: the step is one intent, the clicking lives in shared utilities, and the step arrives before it acts. Write the mechanics below into utilities and keep the step body to three or four calls that read as a sentence.
|
|
452
514
|
|
|
453
515
|
```typescript
|
|
454
|
-
export const opensTheCart = pikkuScenarioStep<
|
|
455
|
-
{
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
}
|
|
464
|
-
)
|
|
516
|
+
export const opensTheCart = pikkuScenarioStep<
|
|
517
|
+
{ path: string },
|
|
518
|
+
{ url: string }
|
|
519
|
+
>({
|
|
520
|
+
name: 'opensTheCart',
|
|
521
|
+
description: 'opens the cart',
|
|
522
|
+
browser: async (_services, { path }, { browser }) => {
|
|
523
|
+
await browser.goto(path)
|
|
524
|
+
return { url: browser.page.url() }
|
|
525
|
+
},
|
|
526
|
+
default: async (_services, _data, { actor }) => ({
|
|
527
|
+
url: (await actor.invoke('getCart', {})).url,
|
|
528
|
+
}),
|
|
529
|
+
})
|
|
465
530
|
```
|
|
466
531
|
|
|
467
532
|
- Install `@pikku/playwright` and `@playwright/test`, and import `@pikku/playwright` once (`import type {} from '@pikku/playwright'`) so `browser.page` is a typed Playwright `Page`. Without it you still get the structural `goto`/`screenshot` handle.
|
|
@@ -517,11 +582,68 @@ A persona holds only what is true of that kind of person for the app's whole lif
|
|
|
517
582
|
|
|
518
583
|
`kind: "system"` is the app acting on its own — a schedule, a cleanup, a send. It gets **no actor**: there is nobody to sign in. Give it one by hand only if it genuinely has a service account.
|
|
519
584
|
|
|
585
|
+
#### Declaring personas in TypeScript
|
|
586
|
+
|
|
587
|
+
`definePersonas({ … })` is the code form of the block above, and there may be
|
|
588
|
+
**one call in the whole codebase** — one place to read the set from, one place
|
|
589
|
+
to add to it. A second anywhere, including in the same file, is a critical.
|
|
590
|
+
Generated files are exempt and never claim the slot.
|
|
591
|
+
|
|
592
|
+
> [!WARNING]
|
|
593
|
+
> The declaration is **read from source, never evaluated** — the CLI writes it
|
|
594
|
+
> to JSON that a deployed stage carries without the app. So every value has to
|
|
595
|
+
> be statically knowable, and a value that is not comes out as `undefined`
|
|
596
|
+
> rather than as an error. Only `name` is checked, so a computed `personality`,
|
|
597
|
+
> `jobTitle` or `description` is dropped in silence and the persona runs with a
|
|
598
|
+
> blank temperament.
|
|
599
|
+
|
|
600
|
+
What that admits and what it does not:
|
|
601
|
+
|
|
602
|
+
```typescript
|
|
603
|
+
personality: 'Wound up and short with it.' // read
|
|
604
|
+
personality: `Wound up and short with it.
|
|
605
|
+
Says what she wants in a few blunt words.` // read — no ${} in it
|
|
606
|
+
personality: 'Wound up. ' + 'Short with it.' // dropped, silently
|
|
607
|
+
personality: TEMPERAMENTS.impatient // dropped, silently
|
|
608
|
+
```
|
|
609
|
+
|
|
610
|
+
A no-substitution template literal is a string literal as far as the reader is
|
|
611
|
+
concerned, so it is the way to write a long personality across several lines —
|
|
612
|
+
not a concatenation, and not a `prettier-ignore`d single line. Its newlines and
|
|
613
|
+
leading indentation are kept verbatim and reach the model that way, which is
|
|
614
|
+
harmless but worth knowing before you align it to the surrounding code.
|
|
615
|
+
|
|
616
|
+
One more thing worth knowing before writing a rich persona: **`actor.converse`
|
|
617
|
+
builds its prompt from `name`, `jobTitle`, `personality` and the scenario's
|
|
618
|
+
`task` only.** Fields like `disposition`, `goals` and `roles` are read and
|
|
619
|
+
stored, and the console shows them, but they do not reach the conversing
|
|
620
|
+
persona's instructions. Anything that must shape how someone talks belongs in
|
|
621
|
+
`personality` or in the task.
|
|
622
|
+
|
|
520
623
|
An actor with no `persona` is its own persona, so a project that never declares any keeps working unchanged.
|
|
521
624
|
|
|
522
625
|
- `environments.<name>.apiUrl` is required. `signInPath` defaults to `/auth/sign-in/actor`, `rpcPath` to `/rpc`.
|
|
523
626
|
- **`SCENARIO_ACTOR_SECRET` is an environment variable and never goes in `pikku.config.json`.** It signs actors in. `pikku scenario run` throws without it; a server auto-building actors warns and runs without them.
|
|
524
627
|
|
|
628
|
+
### The same actors sign a human in
|
|
629
|
+
|
|
630
|
+
Declared actors are not only for automated runs. `signInPath` is Better Auth's
|
|
631
|
+
`actor` plugin (see `pikku-better-auth`), which any caller can post to — so the
|
|
632
|
+
frontend gets a one-click "Sign in as …" switcher over the **same** list, and an
|
|
633
|
+
app can be reviewed as each kind of user without anyone knowing a seed password.
|
|
634
|
+
|
|
635
|
+
The sandbox dev server bakes both halves into the frontend from the declared
|
|
636
|
+
personas: `VITE_DEV_ACTORS` (the JSON actor list) and
|
|
637
|
+
`VITE_SCENARIO_ACTOR_SECRET`. Neither is set in a production build, so the
|
|
638
|
+
control renders nothing there — but gate the reads on your bundler's dev flag
|
|
639
|
+
anyway (`import.meta.env.DEV ? … : undefined`) so the secret never reaches a
|
|
640
|
+
production bundle in the first place.
|
|
641
|
+
|
|
642
|
+
Do not hand-roll the switcher: `useDevActors()` (`pikku-react`) is the logic and
|
|
643
|
+
`<DevActorSwitcher />` from `@pikku/mantine/dev` is a ready rendering of it.
|
|
644
|
+
`pikku fabric validate` **requires** any frontend with a login screen to ship
|
|
645
|
+
one — without it a reviewer is locked out of their own sandbox.
|
|
646
|
+
|
|
525
647
|
## Running
|
|
526
648
|
|
|
527
649
|
```bash
|
|
@@ -535,19 +657,19 @@ SCENARIO_ACTOR_SECRET=… pikku scenario run local --spawn --no-browser --exclud
|
|
|
535
657
|
|
|
536
658
|
`run` takes the environment as a **required positional** — the key from `environments`. Every filter narrows the same plan, so narrowing a feature to two of its five scenarios still runs the feature's hooks exactly once around those two.
|
|
537
659
|
|
|
538
|
-
| Flag
|
|
539
|
-
|
|
|
540
|
-
| `--flows` / `-f`
|
|
541
|
-
| `--features`
|
|
542
|
-
| `--tags` / `-t`
|
|
543
|
-
| `--exclude-tags`
|
|
544
|
-
| `--run <surface>`
|
|
545
|
-
| `--no-browser`
|
|
546
|
-
| `--strict`
|
|
547
|
-
| `--spawn` / `--keep-alive` | Start `pikku dev` on the environment's apiUrl for the run; optionally leave it up
|
|
548
|
-
| `--api-url` / `--app-url`
|
|
549
|
-
| `--trace`
|
|
550
|
-
| `--coverage`
|
|
660
|
+
| Flag | Effect |
|
|
661
|
+
| -------------------------- | --------------------------------------------------------------------------------- |
|
|
662
|
+
| `--flows` / `-f` | Comma-separated scenario names |
|
|
663
|
+
| `--features` | Comma-separated feature ids |
|
|
664
|
+
| `--tags` / `-t` | Match-any tag filter |
|
|
665
|
+
| `--exclude-tags` | Hold tags back — unless the flow is named directly with `--flows` |
|
|
666
|
+
| `--run <surface>` | `default` (the default), `browser`, or `cli` |
|
|
667
|
+
| `--no-browser` | Shorthand for `--run default`; scenarios with browser steps report as **skipped** |
|
|
668
|
+
| `--strict` | Fail, rather than pass, a `then` with no witness on the run's surface |
|
|
669
|
+
| `--spawn` / `--keep-alive` | Start `pikku dev` on the environment's apiUrl for the run; optionally leave it up |
|
|
670
|
+
| `--api-url` / `--app-url` | Override the environment's URLs — for a target that only exists at run time |
|
|
671
|
+
| `--trace` | Keep every stack frame on failure (default shows only the project's own) |
|
|
672
|
+
| `--coverage` | Reset/snapshot server coverage per scenario |
|
|
551
673
|
|
|
552
674
|
Output is `PASS <name> (<ms>) → <output>` / `FAIL <name> (<ms>): <error>`, then `N/M scenarios passed against '<env>'`. A scenario inside a feature is named `<Feature> › <scenario> <data>`.
|
|
553
675
|
|
|
@@ -584,9 +706,7 @@ The run resets coverage before each scenario and snapshots after, writing **`<ou
|
|
|
584
706
|
"generatedAt": "…",
|
|
585
707
|
"environment": "local",
|
|
586
708
|
"scenarios": {
|
|
587
|
-
"<name>": {
|
|
588
|
-
/* FunctionCoverageReport */
|
|
589
|
-
},
|
|
709
|
+
"<name>": {/* FunctionCoverageReport */},
|
|
590
710
|
},
|
|
591
711
|
}
|
|
592
712
|
```
|
|
@@ -640,7 +760,7 @@ Services are plain objects — a Pikku function is pure business logic, so a moc
|
|
|
640
760
|
| A step named `clicksAddToBasket` / `opensThePage` | That is an action, not an intent. Name the step for what the actor wanted; put the clicking in a utility. |
|
|
641
761
|
| A browser step that assumes it is already on a page | It can then only run mid-flow. Arrive first — check the URL, navigate if needed. |
|
|
642
762
|
| A `browser` binding guarding `if (!browser)` | The binding guarantees it. The guard hides the real error, which is a missing actor (`PKU677`). |
|
|
643
|
-
| A step with a `func:` instead of a surface binding
|
|
763
|
+
| A step with a `func:` instead of a surface binding | There is no `func` on a step. Bodies live under `default` / `browser` / `cli`; a step with none throws at load. |
|
|
644
764
|
| `expectEventually` in a `pikkuWorkflowFunc` | `PKU675` — scenario-only. |
|
|
645
765
|
| Coverage silently 0 | Server not run with `--coverage`, verbose functions meta not deployed, `scaffold.scenarios` unset, or no actors configured. |
|
|
646
766
|
|
|
@@ -51,7 +51,12 @@ development and a single-instance deployment, wrong for anything else.
|
|
|
51
51
|
### Scheduling a one-off RPC
|
|
52
52
|
|
|
53
53
|
```typescript
|
|
54
|
-
const taskId = await schedulerService.scheduleRPC(
|
|
54
|
+
const taskId = await schedulerService.scheduleRPC(
|
|
55
|
+
'5m',
|
|
56
|
+
'sendReminder',
|
|
57
|
+
data,
|
|
58
|
+
session
|
|
59
|
+
)
|
|
55
60
|
await schedulerService.getTask(taskId) // { rpcName, scheduledFor, status, … } | null
|
|
56
61
|
await schedulerService.getAllTasks() // pending one-offs only
|
|
57
62
|
await schedulerService.unschedule(taskId) // true when it was still pending
|
|
@@ -52,7 +52,7 @@ called `schema` in the source, which reads backwards.
|
|
|
52
52
|
|
|
53
53
|
- **Registration is name-keyed and never re-compiles.** A second
|
|
54
54
|
`compileSchema('X', …)` with a different schema is a no-op; the first one wins
|
|
55
|
-
for the process lifetime. `@pikku/schema-cfworker`
|
|
55
|
+
for the process lifetime. `@pikku/schema-cfworker` _does_ recompile on a
|
|
56
56
|
changed value, so a dev hot-reload after codegen picks up a changed schema
|
|
57
57
|
there but not here — restart the process instead.
|
|
58
58
|
- **AJV is a module-level singleton**, shared by every `AjvSchemaService` you
|
|
@@ -62,7 +62,7 @@ called `schema` in the source, which reads backwards.
|
|
|
62
62
|
become `1` — the wiring layer is what coerces, not this service.
|
|
63
63
|
- `ajv-formats` is registered, so `format` keywords (`email`, `uuid`, `date-time`)
|
|
64
64
|
are enforced.
|
|
65
|
-
- A failed validation throws `UnprocessableContentError` (a 422). A
|
|
65
|
+
- A failed validation throws `UnprocessableContentError` (a 422). A _missing_
|
|
66
66
|
schema throws a bare string, `Missing validator for <name>` — not an `Error`,
|
|
67
67
|
so `catch (e) { e.message }` reads `undefined`. That normally means codegen
|
|
68
68
|
didn't run.
|
|
@@ -63,7 +63,7 @@ surface as behaviour changes rather than compile errors:
|
|
|
63
63
|
underlying cause swallowed — check the schema by hand when you see it.
|
|
64
64
|
|
|
65
65
|
A failed validation throws `UnprocessableContentError` (422) with the validator
|
|
66
|
-
errors joined; a
|
|
66
|
+
errors joined; a _missing_ schema throws a bare string, `Missing validator for
|
|
67
67
|
<name>`, not an `Error`.
|
|
68
68
|
|
|
69
69
|
## Usage Patterns
|
|
@@ -62,7 +62,7 @@ Apply these via `addHTTPMiddleware` in a wirings file:
|
|
|
62
62
|
|
|
63
63
|
```typescript
|
|
64
64
|
import { authBearer, authCookie, authAPIKey } from '@pikku/core/middleware'
|
|
65
|
-
import { addHTTPMiddleware } from '#pikku'
|
|
65
|
+
import { addHTTPMiddleware } from '#pikku/http'
|
|
66
66
|
|
|
67
67
|
// JWT bearer token — reads Authorization header
|
|
68
68
|
addHTTPMiddleware('*', [authBearer()])
|
|
@@ -118,14 +118,18 @@ response.
|
|
|
118
118
|
|
|
119
119
|
```typescript
|
|
120
120
|
// permissions.ts
|
|
121
|
-
import { pikkuAuth, pikkuPermission } from '#pikku'
|
|
121
|
+
import { pikkuAuth, pikkuPermission } from '#pikku/function'
|
|
122
122
|
|
|
123
|
-
export const isAuthenticated = pikkuAuth(
|
|
124
|
-
|
|
123
|
+
export const isAuthenticated = pikkuAuth(
|
|
124
|
+
async (_services, session) => !!session
|
|
125
|
+
)
|
|
126
|
+
export const isVerified = pikkuAuth(
|
|
127
|
+
async (_services, session) => !!session?.emailVerified
|
|
128
|
+
)
|
|
125
129
|
|
|
126
130
|
// wirings/auth.wiring.ts
|
|
127
131
|
import { authCookie } from '@pikku/core/middleware'
|
|
128
|
-
import { addHTTPMiddleware } from '#pikku'
|
|
132
|
+
import { addHTTPMiddleware } from '#pikku/http'
|
|
129
133
|
|
|
130
134
|
addHTTPMiddleware('*', [
|
|
131
135
|
authCookie({
|
|
@@ -38,7 +38,7 @@ pikku info tags --verbose # Understand project organization
|
|
|
38
38
|
### `pikkuServices(factory)` — singleton services (created once at startup)
|
|
39
39
|
|
|
40
40
|
```typescript
|
|
41
|
-
import { pikkuServices } from '#pikku'
|
|
41
|
+
import { pikkuServices } from '#pikku/function'
|
|
42
42
|
import { ConsoleLogger } from '@pikku/core/services'
|
|
43
43
|
import { JoseJWTService } from '@pikku/jose'
|
|
44
44
|
|
|
@@ -61,7 +61,7 @@ export const createSingletonServices = pikkuServices(
|
|
|
61
61
|
### `pikkuWireServices(factory)` — per-request services (fresh per HTTP request, queue job, CLI command, etc.)
|
|
62
62
|
|
|
63
63
|
```typescript
|
|
64
|
-
import { pikkuWireServices } from '#pikku'
|
|
64
|
+
import { pikkuWireServices } from '#pikku/function'
|
|
65
65
|
|
|
66
66
|
export const createWireServices = pikkuWireServices(
|
|
67
67
|
async (singletonServices, wire) => {
|
|
@@ -157,13 +157,16 @@ const getUser = pikkuFunc({
|
|
|
157
157
|
|
|
158
158
|
**Never write a `if (!service) throw ...` existence guard in a function body.** It is dead code, and it defeats the platform.
|
|
159
159
|
|
|
160
|
-
Optionality lives in exactly one place — `services.ts` / the `SingletonServices` declaration — and it means
|
|
160
|
+
Optionality lives in exactly one place — `services.ts` / the `SingletonServices` declaration — and it means _"this may not be created"_, not _"this may be missing at call time"_. A service is optional precisely because **nothing destructures it**, and the generated `requiredSingletonServices` manifest therefore never marks it for creation. The moment any wired function destructures it, Pikku creates it and guarantees it is there.
|
|
161
161
|
|
|
162
162
|
The types enforce this rather than merely documenting it. The inspector records the services destructured by every wired `func`, `permissions` **and** `middleware`, and emits them as `RequiredSingletonServices`. The generated function types then default their service parameter to:
|
|
163
163
|
|
|
164
164
|
```typescript
|
|
165
|
-
export type WiredSingletonServices = RequiredSingletonServices &
|
|
166
|
-
|
|
165
|
+
export type WiredSingletonServices = RequiredSingletonServices &
|
|
166
|
+
SingletonServices
|
|
167
|
+
export type WiredServices = SecretlessServices<
|
|
168
|
+
RequiredSingletonServices & Services
|
|
169
|
+
>
|
|
167
170
|
```
|
|
168
171
|
|
|
169
172
|
The `SecretlessServices<...>` wrapper is why `secrets` never appears in a
|
|
@@ -223,21 +226,21 @@ const createSingletonServices = pikkuServices(async (config) => {
|
|
|
223
226
|
|
|
224
227
|
### Built-in Services
|
|
225
228
|
|
|
226
|
-
| Service
|
|
227
|
-
|
|
|
228
|
-
| `ConsoleLogger`
|
|
229
|
-
| `JoseJWTService`
|
|
230
|
-
| `LocalSecretService`
|
|
231
|
-
| `LocalVariablesService`
|
|
232
|
-
| `PinoLogger`
|
|
233
|
-
| `createInvocationAudit`
|
|
234
|
-
| `createAuditedKysely`
|
|
229
|
+
| Service | Package | Purpose |
|
|
230
|
+
| ----------------------- | ---------------------- | --------------------------------------- |
|
|
231
|
+
| `ConsoleLogger` | `@pikku/core/services` | Console-based logging |
|
|
232
|
+
| `JoseJWTService` | `@pikku/jose` | JWT sign/verify via jose |
|
|
233
|
+
| `LocalSecretService` | `@pikku/core/services` | Local development secrets |
|
|
234
|
+
| `LocalVariablesService` | `@pikku/core/services` | Local environment variables |
|
|
235
|
+
| `PinoLogger` | `@pikku/pino` | Structured logging via Pino |
|
|
236
|
+
| `createInvocationAudit` | `@pikku/core/services` | Per-request audit buffer |
|
|
237
|
+
| `createAuditedKysely` | `@pikku/kysely` | Auto-capture DB queries as audit events |
|
|
235
238
|
|
|
236
239
|
## Complete Example
|
|
237
240
|
|
|
238
241
|
```typescript
|
|
239
242
|
// services.ts
|
|
240
|
-
import { pikkuServices, pikkuWireServices } from '#pikku'
|
|
243
|
+
import { pikkuServices, pikkuWireServices } from '#pikku/function'
|
|
241
244
|
import { ConsoleLogger } from '@pikku/core/services'
|
|
242
245
|
import { JoseJWTService } from '@pikku/jose'
|
|
243
246
|
|
|
@@ -249,9 +252,15 @@ class TodoStore {
|
|
|
249
252
|
this.todos.set(todo.id, todo)
|
|
250
253
|
return todo
|
|
251
254
|
}
|
|
252
|
-
async get(id: string) {
|
|
253
|
-
|
|
254
|
-
|
|
255
|
+
async get(id: string) {
|
|
256
|
+
return this.todos.get(id)
|
|
257
|
+
}
|
|
258
|
+
async list() {
|
|
259
|
+
return [...this.todos.values()]
|
|
260
|
+
}
|
|
261
|
+
async delete(id: string) {
|
|
262
|
+
this.todos.delete(id)
|
|
263
|
+
}
|
|
255
264
|
}
|
|
256
265
|
|
|
257
266
|
export const createSingletonServices = pikkuServices(async (config) => {
|