@pikku/skills 0.12.44 → 0.12.46

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.
@@ -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 repo that has a pikkufabric.config.json. DO NOT TRIGGER for git changes, diffs or changelogs, and not for deploying or debugging a stage — use pikku-fabric for those.'
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 `pikkufabric.config.json`.
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 | do |
41
- | --- | --- | --- |
42
- | 0 | work printed (claimed, and/or `Answered`) | work it, then step 3 |
43
- | 2 | `--timeout`/`--once` found nothing | stop, or restart `next` |
44
- | 3 | session refused | tell the user to run `pikku fabric login`; stop |
45
- | 1 | anything else (bad `--stage`, fabric down for minutes) | report the message; stop |
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`, or `ask` and move on. Then start
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 (held, claimed by someone else, done).
57
- For held items, run `next --claim` rather than retrying. The lease is 30 minutes
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 *that* commit. Find today's equivalent; never edit line 42 because it said 42.
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`: say why in the thread and leave it for a
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 '@pikku/core'
12
- import type { SingletonServices } from '../types/application-types.js'
11
+ import { pikkuServerLifecycle } from '#pikku/setup'
13
12
 
14
- export const lifecycle = pikkuServerLifecycle<SingletonServices>({
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, `pikkufabric.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.'
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 — reset refuses `NODE_ENV=production` and refuses a database
115
- outside the runtime directory, and no deploy step reaches for the seed file.
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
- ## `pikkufabric.config.json`
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
- Links the repo to a Fabric project and declares its frontends:
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
- "projectId": "my-project-id",
200
- "production": {
201
- "domain": "example.com"
202
- },
203
- "frontends": {
204
- "app": {
205
- "cwd": "apps/app",
206
- "primary": true,
207
- "deploy": true,
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
- - `projectId`: written by `pikku fabric init` / `link`. Templates ship the
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 write the returned `projectId` into
434
- `pikkufabric.config.json` yourself.
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. **Add `pikkufabric.config.json`** at project root with `projectId`, `production.domain`, and `frontends` (production is always `main`, so there is no `production.branch`).
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 workflowService = new PgKyselyWorkflowService(db.kysely)
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 { config, logger, database: db, channelStore, workflowService }
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 { MySQLKyselyWorkflowService } from '@pikku/kysely-mysql'
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 workflowService = new MongoDBWorkflowService(mongo.db)
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 { config, logger, database: mongo, channelStore, workflowService }
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 | 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 |
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 workflowService = new RedisWorkflowService(config.redisUrl)
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 { config, logger, channelStore, workflowService, secrets }
104
+ return {
105
+ config,
106
+ logger,
107
+ channelStore,
108
+ leaseService,
109
+ workflowService,
110
+ secrets,
111
+ }
74
112
  })
75
113
  ```