@pikku/skills 0.12.44 → 0.12.47
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 +2 -2
- package/package.json +1 -1
- package/skills/pikku-blueprint-to-fabric/SKILL.md +72 -72
- package/skills/pikku-build/SKILL.md +3 -2
- package/skills/pikku-build/references/app.md +2 -2
- package/skills/pikku-build/references/multi-app.md +10 -5
- package/skills/pikku-build/references/native-app.md +148 -0
- package/skills/pikku-build/references/ship.md +18 -8
- package/skills/pikku-changes/SKILL.md +37 -15
- package/skills/pikku-concepts/references/bootstrap.md +2 -3
- package/skills/pikku-fabric/SKILL.md +105 -37
- package/skills/pikku-kysely/SKILL.md +29 -4
- package/skills/pikku-service-backends/SKILL.md +1 -0
- package/skills/pikku-service-backends/references/mongodb.md +17 -2
- package/skills/pikku-service-backends/references/redis.md +50 -12
- package/skills/pikku-wiring/references/trigger.md +27 -8
- package/skills/pikku-workflow/SKILL.md +38 -24
- package/skills/pikku-workflow/references/workflow-reference.md +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: pikku-changes
|
|
3
|
-
description: 'Work a Fabric project''s changes queue — the todo list someone filed by circling things on a deployed stage. Covers `pikku fabric changes next|claim|show|ask|shot|done`: waiting for work without polling, asking instead of guessing, offering options as images, one commit per item. TRIGGER when: the user says "run the pikkufabric changes", "run the changes against <stage>", "work the changes (queue)", "watch the changes", "pick up the changes", names a change by its #number, or you are otherwise idle in a
|
|
3
|
+
description: 'Work a Fabric project''s changes queue — the todo list someone filed by circling things on a deployed stage. Covers `pikku fabric changes next|claim|show|ask|reply|shot|done`: waiting for work without polling, asking instead of guessing, saying why an item is left undone, offering options as images, one commit per item. TRIGGER when: the user says "run the pikkufabric changes", "run the changes against <stage>", "work the changes (queue)", "watch the changes", "pick up the changes", names a change by its #number, or you are otherwise idle in a checkout linked to a Fabric project (`pikku fabric config` shows one). DO NOT TRIGGER for git changes, diffs or changelogs, and not for deploying or debugging a stage — use pikku-fabric for those.'
|
|
4
4
|
installGroups: [fabric]
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -10,7 +10,7 @@ Someone walked the deployed app and circled things. Each item is their words, a
|
|
|
10
10
|
screenshot of what they saw, and the elements the circle enclosed. You have the repo.
|
|
11
11
|
Empty the queue without making them regret filing.
|
|
12
12
|
|
|
13
|
-
Run every command from the checkout: the project comes from `
|
|
13
|
+
Run every command from the checkout: the project comes from its git remote (`pikku fabric config` shows which).
|
|
14
14
|
`--json` works on all of them. Items are addressed as `2`, `#2` or their uuid.
|
|
15
15
|
|
|
16
16
|
## Which stage
|
|
@@ -33,19 +33,21 @@ something changed. `next` does the waiting and exits only when there is work.
|
|
|
33
33
|
It waits out the grace window (a just-filed item is held about a minute so a batch
|
|
34
34
|
being typed arrives together), claims what is ready as one group, prints it, and
|
|
35
35
|
exits. It also wakes when someone answers a question you asked under that
|
|
36
|
-
`--claimed-by`.
|
|
36
|
+
`--claimed-by`. It is woken by fabric's change events the moment an item is filed
|
|
37
|
+
or answered, and sleeps exactly until a held item becomes claimable; when the event
|
|
38
|
+
stream is unavailable it falls back to checking every `--interval` seconds.
|
|
37
39
|
|
|
38
40
|
2. When it exits, read the exit code:
|
|
39
41
|
|
|
40
|
-
| code | meaning
|
|
41
|
-
|
|
|
42
|
-
| 0
|
|
43
|
-
| 2
|
|
44
|
-
| 3
|
|
45
|
-
| 1
|
|
42
|
+
| code | meaning | do |
|
|
43
|
+
| ---- | ------------------------------------------------------ | ----------------------------------------------- |
|
|
44
|
+
| 0 | work printed (claimed, and/or `Answered`) | work it, then step 3 |
|
|
45
|
+
| 2 | `--timeout`/`--once` found nothing | stop, or restart `next` |
|
|
46
|
+
| 3 | session refused | tell the user to run `pikku fabric login`; stop |
|
|
47
|
+
| 1 | anything else (bad `--stage`, fabric down for minutes) | report the message; stop |
|
|
46
48
|
|
|
47
|
-
3. For each item: `show` → fix → commit → `done
|
|
48
|
-
`next` again, in the background.
|
|
49
|
+
3. For each item: `show` → fix → commit → `done`; or `ask` and move on; or `reply`
|
|
50
|
+
saying why you are leaving it. Then start `next` again, in the background.
|
|
49
51
|
|
|
50
52
|
Without `--claim` it only reports what is claimable; claim it yourself:
|
|
51
53
|
|
|
@@ -53,8 +55,10 @@ Without `--claim` it only reports what is claimable; claim it yourself:
|
|
|
53
55
|
pikku fabric changes claim --change-ids 3,4 --title "Checkout pass" --claimed-by claude-code
|
|
54
56
|
```
|
|
55
57
|
|
|
56
|
-
A `claim` refused with a 409 says per item why
|
|
57
|
-
|
|
58
|
+
A `claim` refused with a 409 says per item why — held for the filer, inside another
|
|
59
|
+
group's lease, done — and when a held or leased item is **claimable at** (a local
|
|
60
|
+
`HH:MM`; `list` and `show` print the same). An item inside someone else's live lease
|
|
61
|
+
cannot be taken. For held items, run `next --claim` rather than retrying. The lease is 30 minutes
|
|
58
62
|
(`--lease-minutes`); an abandoned claim returns to the queue by itself.
|
|
59
63
|
|
|
60
64
|
## Reading an item
|
|
@@ -67,7 +71,7 @@ For held items, run `next --claim` rather than retrying. The lease is 30 minutes
|
|
|
67
71
|
3. **The circled elements**: a testid (greps straight to a component, it is the i18n
|
|
68
72
|
key), a source anchor, a CSS path.
|
|
69
73
|
4. **The source anchor**, `src/routes/app.orders.tsx:42 as of a91c4e2`, is where the JSX
|
|
70
|
-
was at
|
|
74
|
+
was at _that_ commit. Find today's equivalent; never edit line 42 because it said 42.
|
|
71
75
|
|
|
72
76
|
## When to ask
|
|
73
77
|
|
|
@@ -97,6 +101,24 @@ pikku fabric changes shot --change-id 3 --label "Bigger" --kind option --image a
|
|
|
97
101
|
|
|
98
102
|
`--kind evidence` is a picture that proves something, shown inline.
|
|
99
103
|
|
|
104
|
+
## Replying without asking
|
|
105
|
+
|
|
106
|
+
`ask` is for a decision you need from them: it parks the item as needing an answer.
|
|
107
|
+
`done` closes it. Everything else you have to say goes in a `reply`, which leaves the
|
|
108
|
+
item's status exactly where it was:
|
|
109
|
+
|
|
110
|
+
- you are not doing it, and why ("the copy comes from the CMS, not the app");
|
|
111
|
+
- it is blocked on something that is not a question ("needs STRIPE_KEY set on the stage");
|
|
112
|
+
- you could not reproduce it — attach what you saw.
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
pikku fabric changes reply 3 --message "Cannot reproduce on develop @ a91c4e2 — this is what I see." \
|
|
116
|
+
--image seen.png --image-label "develop @ a91c4e2" --author-name claude-code
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Never `ask` a question you do not need answered just to leave a note, and never
|
|
120
|
+
`done --note` an item you did not do — both tell the filer the wrong thing.
|
|
121
|
+
|
|
100
122
|
## Committing
|
|
101
123
|
|
|
102
124
|
One item, one commit — `done` records one sha, and that is what a human reverts. The
|
|
@@ -117,7 +139,7 @@ pikku fabric changes done --change-id 7 --note "What you did, for whoever reads
|
|
|
117
139
|
```
|
|
118
140
|
|
|
119
141
|
Branch and commit default to the checkout you are in — run it there, never type a sha.
|
|
120
|
-
An item you decided not to do is not `done`:
|
|
142
|
+
An item you decided not to do is not `done`: `reply` with why and leave it for a
|
|
121
143
|
human to dismiss.
|
|
122
144
|
|
|
123
145
|
Writes need the `changes:project:write` scope; `list`, `show` and `next` without
|
|
@@ -8,10 +8,9 @@ There are two ways to start a Pikku app. Pick based on whether you need to own t
|
|
|
8
8
|
|
|
9
9
|
```typescript
|
|
10
10
|
// src/lifecycle.ts
|
|
11
|
-
import { pikkuServerLifecycle } from '
|
|
12
|
-
import type { SingletonServices } from '../types/application-types.js'
|
|
11
|
+
import { pikkuServerLifecycle } from '#pikku/setup'
|
|
13
12
|
|
|
14
|
-
export const lifecycle = pikkuServerLifecycle
|
|
13
|
+
export const lifecycle = pikkuServerLifecycle({
|
|
15
14
|
beforeStart: async ({ kysely }) => {
|
|
16
15
|
await runMigrations(kysely)
|
|
17
16
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: pikku-fabric
|
|
3
|
-
description: 'Build, convert and debug apps on the Pikku Fabric platform. Covers SQLite/libSQL database setup with Kysely, fabric project layout, deploy provider config, `
|
|
3
|
+
description: 'Build, convert and debug apps on the Pikku Fabric platform. Covers SQLite/libSQL database setup with Kysely, fabric project layout, deploy provider config, the optional `fabric.projectId` in `pikku.config.json`, the `pikku all` + `tsc` verification loop, and reading logs, traces and metrics from a deployed stage. TRIGGER when: user is working on a Fabric-hosted Pikku project, converting an app to Fabric format, asking about Fabric deployment, database or project conventions, asking about a `pikku fabric validate` finding including app-missing-actor-quick-login, or a deployed stage is erroring, timing out or behaving differently than local ("why is prod failing", "check the logs"). DO NOT TRIGGER when: user is working on a generic (non-Fabric) Pikku deployment — use pikku-deploy instead — or the failure reproduces locally, which is where to debug it.'
|
|
4
4
|
installGroups: [fabric]
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -91,6 +91,31 @@ Numbers must be consecutive and gap-free, and an applied migration is frozen —
|
|
|
91
91
|
correct a mistake with a new forward migration, never by editing or renaming one
|
|
92
92
|
that has already run (the recorded hash will no longer match).
|
|
93
93
|
|
|
94
|
+
**Forward-only rule.** Once a migration exists on the base branch (or any stage
|
|
95
|
+
has applied it), never edit, rename or delete it — add a NEW numbered migration
|
|
96
|
+
that makes the change. A stage records a migration by name and never re-runs it,
|
|
97
|
+
so an edit never reaches a database that already applied it. Two guards enforce
|
|
98
|
+
this:
|
|
99
|
+
|
|
100
|
+
- `pikku fabric validate` reports `migration-modified-after-base-*` (error) for
|
|
101
|
+
any `db/<engine>/*.sql` that exists on the base ref (default `origin/main`, else `main`, `origin/master`, `master`,
|
|
102
|
+
compared at the branch's merge-base; override with `--migrations-base <ref>`
|
|
103
|
+
or `PIKKU_MIGRATIONS_BASE`) but was modified, deleted or renamed in the working
|
|
104
|
+
tree. New files are fine. It is skipped outside a git repo or with no base ref.
|
|
105
|
+
In CI use a full clone (`fetch-depth: 0`) so the base ref exists.
|
|
106
|
+
- `pikku fabric deploy apply` runs the migration-history checks first and
|
|
107
|
+
refuses to create a deployment if any fail. It compares against the stage
|
|
108
|
+
being deployed and the production (`main`) stage's applied ledger, and against
|
|
109
|
+
the base ref — a branch stage can be reset at will, but main's history reaches
|
|
110
|
+
production. Findings: `migration-applied-file-missing-*`, `migration-drift-*`,
|
|
111
|
+
`migration-gap`, `migration-modified-after-base-*`. Unlike `validate`, a check
|
|
112
|
+
that cannot run refuses too: an unreadable ledger (`migration-drift-unchecked`)
|
|
113
|
+
or a base ref that does not resolve (`migration-base-unresolved`). There is no
|
|
114
|
+
override flag — fix the history.
|
|
115
|
+
|
|
116
|
+
Fix a finding by restoring the file (`git checkout origin/main -- db/sqlite/<file>`)
|
|
117
|
+
and putting the change in a new migration.
|
|
118
|
+
|
|
94
119
|
Run migrations: `pikku db migrate`. It also regenerates `.pikku/db/schema.gen.ts`
|
|
95
120
|
(Kysely types) and `.pikku/db/zod.gen.ts` — there is no separate types step.
|
|
96
121
|
|
|
@@ -111,8 +136,10 @@ to be a migration instead.
|
|
|
111
136
|
|
|
112
137
|
This is **local dev data only**: enough rows that a fresh dev database isn't an
|
|
113
138
|
empty app. Nothing else ever runs it. A deployed stage applies `db/<engine>/*.sql`
|
|
114
|
-
and stops there —
|
|
115
|
-
|
|
139
|
+
and stops there — the one exception is a disposable stage deployed with
|
|
140
|
+
`pikku fabric deploy apply <branch> --reset` (see Deploy), which is never
|
|
141
|
+
production. Local reset refuses `NODE_ENV=production` and refuses a database
|
|
142
|
+
outside the runtime directory.
|
|
116
143
|
|
|
117
144
|
So the test is not "is this row realistic?", it is **"would the app be broken
|
|
118
145
|
without it in production?"** If yes, it is configuration and belongs in a
|
|
@@ -186,47 +213,70 @@ packages/functions/
|
|
|
186
213
|
apps/app/ # Frontend(s)
|
|
187
214
|
db/sqlite/ # Plain .sql migrations, numbered, gap-free (project root)
|
|
188
215
|
db/sqlite-dev-seed.sql # Dev-only test data, applied by `pikku db reset`
|
|
189
|
-
pikku.config.json # Pikku + deploy config (project root)
|
|
190
|
-
pikkufabric.config.json # Fabric project link + frontends (project root)
|
|
216
|
+
pikku.config.json # Pikku + deploy config, and the frontends (project root)
|
|
191
217
|
```
|
|
192
218
|
|
|
193
|
-
##
|
|
219
|
+
## How a checkout is linked to its Fabric project
|
|
220
|
+
|
|
221
|
+
`fabric.projectId` in `pikku.config.json` names the project. It is optional. Order
|
|
222
|
+
tried:
|
|
223
|
+
|
|
224
|
+
1. `FABRIC_PROJECT_ID` env var — CI and scripts.
|
|
225
|
+
2. `fabric.projectId` in `pikku.config.json`.
|
|
226
|
+
3. The git remote, matched against Fabric's projects. The id found is written
|
|
227
|
+
into `pikku.config.json` so the lookup happens once. It is never committed
|
|
228
|
+
for you; commit it or not. A deploy ignores a `pikku.config.json` whose only
|
|
229
|
+
change is `fabric.projectId`. If two projects share one repo the CLI refuses; pick
|
|
230
|
+
one with `FABRIC_PROJECT_ID=<projectId>`.
|
|
231
|
+
|
|
232
|
+
`pikku fabric config` prints which project, api url and login the current
|
|
233
|
+
checkout resolves to, and where each came from, and the project's settings.
|
|
234
|
+
The settings Fabric needs that `pikku.config.json` does not describe are stored
|
|
235
|
+
on the project, not in a file, and set with `key=value` arguments:
|
|
194
236
|
|
|
195
|
-
|
|
237
|
+
```bash
|
|
238
|
+
pikku fabric config showcase.name="Watering Log" showcase.tags=voice,realtime
|
|
239
|
+
pikku fabric config guide.docs=docs/guide guide.theme.primaryColor=teal
|
|
240
|
+
pikku fabric config scenarios.env.STRIPE_MODE=test # the scenario run only
|
|
241
|
+
pikku fabric config showcase.tint= # an empty value clears it
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
The keys are `showcase.name` (≤60), `showcase.description` (≤160),
|
|
245
|
+
`showcase.tags` (comma-separated, ≤6, from Fabric's fixed list),
|
|
246
|
+
`showcase.tint` (`#rrggbb`), `guide.docs`, `guide.theme.primaryColor`,
|
|
247
|
+
`guide.theme.fontFamily`, `guide.theme.headingFontFamily` and
|
|
248
|
+
`scenarios.env.<NAME>`. Every assignment is checked before any is applied, so a
|
|
249
|
+
refused one changes nothing. Whether the project appears on the public
|
|
250
|
+
showcase is Fabric's decision, not a setting.
|
|
251
|
+
|
|
252
|
+
`pikku fabric link` creates
|
|
253
|
+
`origin` (with `--gitea`) if there is none, imports the project, writes
|
|
254
|
+
`projectId`, and queues the first deploy; it commits and pushes nothing. A
|
|
255
|
+
custom production domain is set with `pikku fabric domains add`. Production
|
|
256
|
+
always maps to `main`; without a domain it lives on the platform
|
|
257
|
+
`*.pikkufabric.app` hostnames.
|
|
258
|
+
|
|
259
|
+
The apps are the `frontends` in `pikku.config.json`,
|
|
260
|
+
the same list `pikku serve`, `pikku app` and native builds read, and
|
|
261
|
+
`pikku fabric validate` and `smoke` read them from there:
|
|
196
262
|
|
|
197
263
|
```json
|
|
198
|
-
{
|
|
199
|
-
"
|
|
200
|
-
|
|
201
|
-
"
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
"
|
|
205
|
-
"
|
|
206
|
-
"
|
|
207
|
-
"
|
|
208
|
-
"kind": "ssr",
|
|
209
|
-
"dev": {
|
|
210
|
-
"command": ["yarn", "dev"],
|
|
211
|
-
"port": 7105,
|
|
212
|
-
"healthPath": "/"
|
|
213
|
-
}
|
|
264
|
+
"frontends": {
|
|
265
|
+
"app": {
|
|
266
|
+
"cwd": "apps/app",
|
|
267
|
+
"primary": true,
|
|
268
|
+
"deploy": true,
|
|
269
|
+
"kind": "ssr",
|
|
270
|
+
"dev": {
|
|
271
|
+
"command": ["yarn", "dev"],
|
|
272
|
+
"port": 7105,
|
|
273
|
+
"healthPath": "/"
|
|
214
274
|
}
|
|
215
275
|
}
|
|
216
276
|
}
|
|
217
277
|
```
|
|
218
278
|
|
|
219
|
-
|
|
220
|
-
`__PROJECT_ID__` placeholder — that is _not_ a link, and the CLI treats it as
|
|
221
|
-
unlinked.
|
|
222
|
-
- `production.domain`: optional custom domain. Production always maps to `main`;
|
|
223
|
-
without a domain it lives on the platform `*.pikkufabric.app` hostnames.
|
|
224
|
-
- `frontends`: each entry declares a frontend app with its dev command and port
|
|
225
|
-
|
|
226
|
-
Several CLI messages call this file `fabric.config.json` — `fabric init --force`,
|
|
227
|
-
`fabric link --apiUrl`, and the `domains` commands' "No fabric.config.json found".
|
|
228
|
-
The file the CLI actually reads and writes is `pikkufabric.config.json`; don't
|
|
229
|
-
create the shorter name to satisfy an error message.
|
|
279
|
+
Each `frontends` entry declares a frontend app with its dev command and port.
|
|
230
280
|
|
|
231
281
|
## RPC is the default transport
|
|
232
282
|
|
|
@@ -352,6 +402,24 @@ pikku fabric deploy apply my-branch -y # a named one
|
|
|
352
402
|
that drop or rewrite data — that stays `--allow-destructive`, typed out on
|
|
353
403
|
purpose.
|
|
354
404
|
|
|
405
|
+
### Rebuilding a disposable stage: `--reset`
|
|
406
|
+
|
|
407
|
+
A non-production stage whose migration history or schema has drifted (an app's
|
|
408
|
+
`develop` branch) can be wiped and rebuilt in one deploy:
|
|
409
|
+
|
|
410
|
+
```bash
|
|
411
|
+
pikku fabric deploy apply develop --reset # asks first, naming app + stage
|
|
412
|
+
pikku fabric deploy apply develop --reset -y # prints the warning, skips the prompt
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
It is the deployed counterpart of `pikku db reset`: **all data on that stage is
|
|
416
|
+
deleted**, every migration is re-applied and `db/<engine>-dev-seed.sql` is
|
|
417
|
+
loaded. It is refused for `--production`, for `main`, and with `--deployment-id`
|
|
418
|
+
(it only applies to a deploy it creates). It needs a fabric server that reports
|
|
419
|
+
the reset back on the deployment; against one that does not, the command fails
|
|
420
|
+
naming the deployment it created without a reset, and wipes nothing. `-y` does
|
|
421
|
+
not imply `--allow-destructive`.
|
|
422
|
+
|
|
355
423
|
Inferring the branch is safe because the git safety check refuses any branch
|
|
356
424
|
without an upstream or out of sync with it, so it cannot ship an unpushed
|
|
357
425
|
commit; the branch it picked is printed before the build starts. A detached
|
|
@@ -430,8 +498,8 @@ repo, and if it is installed with "selected repositories" this one must be in
|
|
|
430
498
|
the selection. There is no CLI flag that works around a missing installation:
|
|
431
499
|
`init` returns "Connect the GitHub account '<owner>'". Send the user to install
|
|
432
500
|
it, or create the project in the console instead (which provisions a Fabric-hosted
|
|
433
|
-
git repo you push to) and
|
|
434
|
-
`
|
|
501
|
+
git repo you push to) and clone it — the remote links the checkout, or put
|
|
502
|
+
the id in `fabric.projectId` in `pikku.config.json`.
|
|
435
503
|
|
|
436
504
|
Deploy refuses to run unless the target branch equals its upstream — the guard
|
|
437
505
|
compares `main` against `main@{upstream}`. So the remote you pushed to must be
|
|
@@ -520,7 +588,7 @@ Fix every `error` and `warn` in the output before continuing. Then:
|
|
|
520
588
|
3. **Replace DI/IoC with pikkuServices**: move service construction to `createSingletonServices` in `services.ts`.
|
|
521
589
|
4. **Replace `process.env` calls**: plain config becomes `defineVariable` + `variables.get()`, anything sensitive becomes `defineSecret` + `secrets.getSecret()`.
|
|
522
590
|
5. **Add `pikku.config.json`** at project root with `srcDirectories`, `outDir`, and `clientFiles` — plus `metaLocale` if the team does not work in English, which is the language every `description`, `title` and step `template` is then authored in.
|
|
523
|
-
6. **
|
|
591
|
+
6. **Declare the apps** as `frontends` in `pikku.config.json`. There is no fabric config file; `projectId` is optional; without it the CLI finds the project from the git remote.
|
|
524
592
|
7. **Run `pikku all`** — verify codegen succeeds and there are no type errors.
|
|
525
593
|
8. **Run `pikku fabric validate`** once more to confirm no structural issues remain.
|
|
526
594
|
|
|
@@ -374,6 +374,7 @@ a log line or an audit row. See `pikku-services` for the reveal rules.
|
|
|
374
374
|
import {
|
|
375
375
|
PikkuKysely,
|
|
376
376
|
PgKyselyChannelStore,
|
|
377
|
+
PgKyselyLeaseService,
|
|
377
378
|
PgKyselyWorkflowService,
|
|
378
379
|
} from '@pikku/kysely-postgres'
|
|
379
380
|
|
|
@@ -385,10 +386,22 @@ const createSingletonServices = pikkuServices(async (config) => {
|
|
|
385
386
|
const channelStore = new PgKyselyChannelStore(db.kysely)
|
|
386
387
|
await channelStore.init()
|
|
387
388
|
|
|
388
|
-
const
|
|
389
|
+
const leaseService = new PgKyselyLeaseService(db.kysely)
|
|
390
|
+
await leaseService.init()
|
|
391
|
+
|
|
392
|
+
const workflowService = new PgKyselyWorkflowService(db.kysely, {
|
|
393
|
+
leaseService,
|
|
394
|
+
})
|
|
389
395
|
await workflowService.init()
|
|
390
396
|
|
|
391
|
-
return {
|
|
397
|
+
return {
|
|
398
|
+
config,
|
|
399
|
+
logger,
|
|
400
|
+
database: db,
|
|
401
|
+
channelStore,
|
|
402
|
+
leaseService,
|
|
403
|
+
workflowService,
|
|
404
|
+
}
|
|
392
405
|
})
|
|
393
406
|
```
|
|
394
407
|
|
|
@@ -409,8 +422,20 @@ await channelStore.init()
|
|
|
409
422
|
### MySQL Setup
|
|
410
423
|
|
|
411
424
|
```typescript
|
|
412
|
-
import {
|
|
425
|
+
import {
|
|
426
|
+
MySQLKyselyLeaseService,
|
|
427
|
+
MySQLKyselyWorkflowService,
|
|
428
|
+
} from '@pikku/kysely-mysql'
|
|
429
|
+
|
|
430
|
+
const leaseService = new MySQLKyselyLeaseService(kyselyInstance)
|
|
431
|
+
await leaseService.init()
|
|
413
432
|
|
|
414
|
-
const workflowService = new MySQLKyselyWorkflowService(kyselyInstance
|
|
433
|
+
const workflowService = new MySQLKyselyWorkflowService(kyselyInstance, {
|
|
434
|
+
leaseService,
|
|
435
|
+
})
|
|
415
436
|
await workflowService.init()
|
|
416
437
|
```
|
|
438
|
+
|
|
439
|
+
Every persistent workflow service takes a required `leaseService` and locks runs
|
|
440
|
+
and steps on it. Pass the same instance you register as the app's
|
|
441
|
+
`leaseService`.
|
|
@@ -46,6 +46,7 @@ behind the interface.
|
|
|
46
46
|
| `PikkuWorkflowService`, `WorkflowRunService` | Redis, MongoDB | `@pikku/redis`, `@pikku/mongodb` |
|
|
47
47
|
| `SessionStore`, `AgentRunService`, `DeploymentService` | Redis, MongoDB | `@pikku/redis`, `@pikku/mongodb` |
|
|
48
48
|
| `AgentStorageService`, `AgentRunStateService` | MongoDB **only** | `@pikku/mongodb` |
|
|
49
|
+
| `LeaseService` | `RedisLeaseService` | `@pikku/redis` |
|
|
49
50
|
|
|
50
51
|
SQL is the third option for every store interface in that table —
|
|
51
52
|
`KyselyChannelStore`, `KyselyWorkflowService`, `KyselySecretService` and friends
|
|
@@ -73,6 +73,7 @@ import {
|
|
|
73
73
|
MongoDBChannelStore,
|
|
74
74
|
MongoDBWorkflowService,
|
|
75
75
|
} from '@pikku/mongodb'
|
|
76
|
+
import { RedisLeaseService } from '@pikku/redis'
|
|
76
77
|
|
|
77
78
|
const createSingletonServices = pikkuServices(async (config) => {
|
|
78
79
|
const logger = new PinoLogger()
|
|
@@ -82,9 +83,23 @@ const createSingletonServices = pikkuServices(async (config) => {
|
|
|
82
83
|
const channelStore = new MongoDBChannelStore(mongo.db)
|
|
83
84
|
await channelStore.init()
|
|
84
85
|
|
|
85
|
-
const
|
|
86
|
+
const leaseService = new RedisLeaseService(config.redisUrl)
|
|
87
|
+
const workflowService = new MongoDBWorkflowService(mongo.db, {
|
|
88
|
+
leaseService,
|
|
89
|
+
})
|
|
86
90
|
await workflowService.init()
|
|
87
91
|
|
|
88
|
-
return {
|
|
92
|
+
return {
|
|
93
|
+
config,
|
|
94
|
+
logger,
|
|
95
|
+
database: mongo,
|
|
96
|
+
channelStore,
|
|
97
|
+
leaseService,
|
|
98
|
+
workflowService,
|
|
99
|
+
}
|
|
89
100
|
})
|
|
90
101
|
```
|
|
102
|
+
|
|
103
|
+
`MongoDBWorkflowService` requires a `leaseService` to lock runs and steps, and
|
|
104
|
+
`@pikku/mongodb` ships none — pair it with Redis, a Kysely lease service, or
|
|
105
|
+
`InMemoryLeaseService` for a single process.
|
|
@@ -9,16 +9,17 @@ Redis-backed implementations of Pikku's core service interfaces, using
|
|
|
9
9
|
connection — an ioredis `Redis` instance, `RedisOptions`, or a connection
|
|
10
10
|
string — in its constructor. None of them need an `init()` call.
|
|
11
11
|
|
|
12
|
-
| Service
|
|
13
|
-
|
|
|
14
|
-
| `RedisChannelStore`
|
|
15
|
-
| `RedisEventHubStore`
|
|
16
|
-
| `RedisWorkflowService`
|
|
17
|
-
| `RedisWorkflowRunService` | `WorkflowRunService`
|
|
18
|
-
| `RedisDeploymentService`
|
|
19
|
-
| `RedisAgentRunService`
|
|
20
|
-
| `RedisSecretService`
|
|
21
|
-
| `RedisSessionStore`
|
|
12
|
+
| Service | Interface | Purpose |
|
|
13
|
+
| ------------------------- | ---------------------- | ---------------------------------------------- |
|
|
14
|
+
| `RedisChannelStore` | `ChannelStore` | WebSocket channel state persistence |
|
|
15
|
+
| `RedisEventHubStore` | `EventHubStore` | Event hub state persistence |
|
|
16
|
+
| `RedisWorkflowService` | `PikkuWorkflowService` | Workflow definition storage |
|
|
17
|
+
| `RedisWorkflowRunService` | `WorkflowRunService` | Workflow execution tracking |
|
|
18
|
+
| `RedisDeploymentService` | `DeploymentService` | Deployment state management |
|
|
19
|
+
| `RedisAgentRunService` | `AgentRunService` | Agent execution tracking |
|
|
20
|
+
| `RedisSecretService` | `SecretService` | Encrypted secret storage (envelope encryption) |
|
|
21
|
+
| `RedisSessionStore` | `SessionStore` | Persisted user sessions |
|
|
22
|
+
| `RedisLeaseService` | `LeaseService` | Named leases with fencing tokens |
|
|
22
23
|
|
|
23
24
|
There is no Redis implementation of `AgentStorageService` — AI conversation
|
|
24
25
|
storage is MongoDB-only.
|
|
@@ -51,11 +52,38 @@ await secrets.rotateKEK(): Promise<number>
|
|
|
51
52
|
await secrets.close(): Promise<void>
|
|
52
53
|
```
|
|
53
54
|
|
|
55
|
+
## `RedisLeaseService`
|
|
56
|
+
|
|
57
|
+
Each lease is a hash Redis expires itself, and every check-and-set is one Lua
|
|
58
|
+
script that reads the time with `TIME`, so a lease is judged — and its
|
|
59
|
+
`expiresAt` reported — on Redis's clock, never the worker's. A fast worker
|
|
60
|
+
clock can neither take a live lease nor stretch its own.
|
|
61
|
+
|
|
62
|
+
The token comes from a counter stored beside the lease that release never
|
|
63
|
+
deletes, so the next holder always gets a higher token. That counter is one
|
|
64
|
+
small key per lease name, kept forever. On a replicated Redis with async
|
|
65
|
+
failover a promoted replica can miss the latest `INCR`, so a token is only as
|
|
66
|
+
durable as the write it rode on.
|
|
67
|
+
|
|
68
|
+
```typescript
|
|
69
|
+
import { RedisLeaseService } from '@pikku/redis'
|
|
70
|
+
import { holdLease } from '@pikku/core/services'
|
|
71
|
+
|
|
72
|
+
const leaseService = new RedisLeaseService(config.redisUrl, {
|
|
73
|
+
keyPrefix: 'pikku', // lease keys are `<prefix>:lease:{<key>}`
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
await holdLease(leaseService, 'nightly-report', async (lease, signal) => {
|
|
77
|
+
// ...
|
|
78
|
+
})
|
|
79
|
+
```
|
|
80
|
+
|
|
54
81
|
## Full setup
|
|
55
82
|
|
|
56
83
|
```typescript
|
|
57
84
|
import {
|
|
58
85
|
RedisChannelStore,
|
|
86
|
+
RedisLeaseService,
|
|
59
87
|
RedisWorkflowService,
|
|
60
88
|
RedisSecretService,
|
|
61
89
|
} from '@pikku/redis'
|
|
@@ -64,12 +92,22 @@ const createSingletonServices = pikkuServices(async (config) => {
|
|
|
64
92
|
const logger = new PinoLogger()
|
|
65
93
|
|
|
66
94
|
const channelStore = new RedisChannelStore(config.redisUrl)
|
|
67
|
-
const
|
|
95
|
+
const leaseService = new RedisLeaseService(config.redisUrl)
|
|
96
|
+
const workflowService = new RedisWorkflowService(config.redisUrl, {
|
|
97
|
+
leaseService,
|
|
98
|
+
})
|
|
68
99
|
|
|
69
100
|
const secrets = new RedisSecretService(config.redisUrl, {
|
|
70
101
|
key: config.kekPassphrase,
|
|
71
102
|
})
|
|
72
103
|
|
|
73
|
-
return {
|
|
104
|
+
return {
|
|
105
|
+
config,
|
|
106
|
+
logger,
|
|
107
|
+
channelStore,
|
|
108
|
+
leaseService,
|
|
109
|
+
workflowService,
|
|
110
|
+
secrets,
|
|
111
|
+
}
|
|
74
112
|
})
|
|
75
113
|
```
|
|
@@ -113,8 +113,8 @@ subscribe to its events as `<source>:<event>`:
|
|
|
113
113
|
logged and dropped; so is one no `wireTrigger` listens for. Both still get a
|
|
114
114
|
`200`, so the provider does not retry forever.
|
|
115
115
|
- `receive(services, { body, headers, method, url, query })` gets the **raw
|
|
116
|
-
bytes**
|
|
117
|
-
|
|
116
|
+
bytes** and only parses them: it returns `{ events: [{ name, id?, data }] }`,
|
|
117
|
+
or `{ respond: { status, body } }` for a handshake. Throwing
|
|
118
118
|
rejects the request with the error's status (`UnauthorizedError` → 401).
|
|
119
119
|
Omitted, the JSON body becomes one event dispatched to a trigger named just
|
|
120
120
|
`<source>`.
|
|
@@ -129,12 +129,31 @@ id?, data }] }`, or `{ respond: { status, body } }` for a handshake. Throwing
|
|
|
129
129
|
even on queues that ignore job ids, and records each attempt and its last
|
|
130
130
|
error. Its `webhookReceipt` table comes from `pikku db generate`; `pikku dev`
|
|
131
131
|
and `pikku serve` use it when a Kysely database is configured.
|
|
132
|
-
-
|
|
133
|
-
|
|
134
|
-
`
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
132
|
+
- Signature checks belong in `verify`, never in `receive`. pikku runs it on
|
|
133
|
+
every request against the secret in the credential
|
|
134
|
+
`<name>WebhookSecret` (camelCased: `microsoft-outlook` →
|
|
135
|
+
`microsoftOutlookWebhookSecret`; `credential` overrides it). Declaring
|
|
136
|
+
`verify` declares that credential as a singleton string, so there is no
|
|
137
|
+
`defineCredential` and no service holding the secret; `credentialDescription`
|
|
138
|
+
tells whoever sets it where to find it. Pick the declared form that matches
|
|
139
|
+
the provider:
|
|
140
|
+
- `{ hmac: { header, prefix?, algorithm, encoding, secretEncoding? } }`:
|
|
141
|
+
a signature over the raw body in one header (GitHub, Shopify, Linear).
|
|
142
|
+
- `{ token: { header, prefix? } }`: the provider echoes the shared secret
|
|
143
|
+
(GitLab, Telegram).
|
|
144
|
+
- `{ publicKey: { header, algorithm?, dsaEncoding? } }`: signed with the
|
|
145
|
+
provider's private key; the stored secret is its PEM public key (Wise).
|
|
146
|
+
- Anything else (a timestamp in the signed payload, a signature in the body
|
|
147
|
+
or query, a URL in the signed string) is a function
|
|
148
|
+
`(request, secret, services) => boolean`, built from `hmacDigest`,
|
|
149
|
+
`verifyHmacSignature`, `verifyPublicKeySignature` and
|
|
150
|
+
`timingSafeStringEqual` in `@pikku/core/hmac`.
|
|
151
|
+
|
|
152
|
+
A request with a body that fails is refused with a 401. A bodiless request
|
|
153
|
+
that fails (a HEAD probe, a validation token in the query) still reaches
|
|
154
|
+
`receive` so it can answer the handshake, but any events it returns are
|
|
155
|
+
refused. A handshake that hands over the secret (Asana) stores it with
|
|
156
|
+
`credentialService.set` under the same credential name.
|
|
138
157
|
|
|
139
158
|
`check`, `setup` and `teardown` register the route with the provider. Each gets
|
|
140
159
|
`{ url, label, events, previous? }`, where `events` are only the ones some
|
|
@@ -237,38 +237,52 @@ with `outcome: 'success' | 'denied'`, the decider under `userIdentity`, and the
|
|
|
237
237
|
run, reason and refusal in `metadata`. Wire an `audit` service to keep it; a
|
|
238
238
|
project without one records nothing and is otherwise unaffected.
|
|
239
239
|
|
|
240
|
-
###
|
|
240
|
+
### Failure: `compensate`, never try/catch
|
|
241
241
|
|
|
242
242
|
**Do not wrap steps in try/catch.** The DSL extractor serialises the body into a
|
|
243
243
|
step graph, and a `catch` block is control flow it cannot represent — so the
|
|
244
|
-
graph would no longer describe what actually runs
|
|
245
|
-
|
|
244
|
+
graph would no longer describe what actually runs. This is a settled design
|
|
245
|
+
decision, not a temporary limitation.
|
|
246
246
|
|
|
247
|
-
|
|
248
|
-
|
|
247
|
+
Declare how a function is undone **on the function itself**. When a later step
|
|
248
|
+
fails (after its retries), the engine runs the `compensate` of every earlier
|
|
249
|
+
step that completed, newest first, then the failed step's own:
|
|
249
250
|
|
|
250
251
|
```typescript
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
}
|
|
260
|
-
)
|
|
252
|
+
export const chargePayment = pikkuFunc({
|
|
253
|
+
func: async ({ payments }, { orderId }) => payments.charge(orderId),
|
|
254
|
+
compensate: async ({ payments }, { orderId }, { workflow }) => {
|
|
255
|
+
const { ok, output } = workflow!.compensatingFor!
|
|
256
|
+
// ok: false → the charge itself failed; output is null, `error` is set
|
|
257
|
+
if (ok) await payments.refund(output.chargeId)
|
|
258
|
+
},
|
|
259
|
+
})
|
|
261
260
|
```
|
|
262
261
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
262
|
+
- `compensate` gets the **same input** as the forward call. `wire.workflow.compensatingFor`
|
|
263
|
+
is `{ ok: true, output, stepName }` or `{ ok: false, output: null, error, stepName }`.
|
|
264
|
+
- It is never callable over HTTP, MCP or RPC — only the engine runs it, as the
|
|
265
|
+
durable step `<step>:compensate` with the same retry defaults as forward steps.
|
|
266
|
+
- Opt a call site out with `{ compensate: false }` (graph nodes likewise).
|
|
267
|
+
- A run ends `compensated` (everything undone), `compensation_failed` (a
|
|
268
|
+
compensation ran out of retries; `stuckSteps` names them — steps that ran
|
|
269
|
+
before a stuck one are not undone, parallel siblings still are) or `failed`
|
|
270
|
+
(nothing needed undoing).
|
|
271
|
+
- `await workflow.milestone('paid')` bounds the unwind: steps finished before the
|
|
272
|
+
last milestone are kept, and the run ends `compensated` with `restedAt: 'paid'`.
|
|
273
|
+
- A failing child workflow unwinds itself first; a stuck child makes the parent
|
|
274
|
+
`compensation_failed`. Cancelling a run (`cancelRun`) unwinds it the same way,
|
|
275
|
+
children first.
|
|
276
|
+
|
|
277
|
+
To **recover** instead of undo, use a graph node's `recover`:
|
|
278
|
+
`recover: 'nodeId' | ['a','b'] | 'ignore'`. The failure is routed to those nodes
|
|
279
|
+
(`'ignore'` continues to `next` with a null output) and the failing step is not
|
|
280
|
+
compensated. The error arrives as `wire.graph.recoveringFrom`
|
|
281
|
+
(`{ nodeId, stepName, error }`). The DSL has no recovery — branch on a result
|
|
282
|
+
object (`{ success: false, reason }`) instead.
|
|
283
|
+
|
|
284
|
+
Full step options: `description`, `retries`, `retryDelay`, `compensate: false`
|
|
285
|
+
(plus `actor`, which is scenario-only — see `pikku-scenario`).
|
|
272
286
|
|
|
273
287
|
### Parallel fan-out
|
|
274
288
|
|