slotlock 0.5.2 → 0.5.3
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 +98 -0
- package/README.md +97 -18
- package/SPEC.md +10 -7
- package/agent-server.d.ts +38 -4
- package/agent-server.js +464 -145
- package/agent-store-backend.d.ts +9 -0
- package/agent-store-backend.js +83 -15
- package/api-keys.d.ts +12 -2
- package/api-keys.js +36 -7
- package/application-api.d.ts +7 -1
- package/application-api.js +67 -7
- package/assistant.js +2 -1
- package/booking-rules.d.ts +61 -9
- package/booking-rules.js +106 -16
- package/change-watch.d.ts +56 -0
- package/change-watch.js +213 -0
- package/dashboard.d.ts +3 -1
- package/dashboard.js +98 -15
- package/ddl.d.ts +1 -1
- package/ddl.js +395 -4
- package/event-feed.d.ts +37 -4
- package/event-feed.js +74 -6
- package/index.d.ts +7 -6
- package/index.js +5 -4
- package/maintenance.js +12 -10
- package/openapi.json +1284 -82
- package/package.json +1 -1
- package/self-host.js +8 -4
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +32 -3
- package/store.js +324 -124
- package/types.d.ts +14 -1
- package/webhooks.d.ts +9 -2
- package/webhooks.js +6 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,104 @@
|
|
|
3
3
|
All notable changes follow [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and semantic
|
|
4
4
|
versioning.
|
|
5
5
|
|
|
6
|
+
## 0.5.3 - 2026-10-28
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- Configuration changes on the feed and in history (RFC 0002 P1-5, P1-6), each in the change's own
|
|
11
|
+
transaction, by trigger: `resource.created`, `resource.updated` (booking and check-in rules,
|
|
12
|
+
pool, bookable hours, time zone or reference, with `before` and `after` of the fields that
|
|
13
|
+
changed), `resource.archived`, `resource.restored`, `resource.deleted`, `coverage.recorded` (a
|
|
14
|
+
source's first read) and `coverage.deleted`, `webhook.created`, `webhook.enabled` and
|
|
15
|
+
`webhook.deleted`, and `key.created`, `key.rotated` and `key.revoked`. Writing a value that is
|
|
16
|
+
already there records nothing. Rows carry ids and states: never a webhook's URL or secret, nor an
|
|
17
|
+
API key, its digest, prefix or name, nor a coverage source, which `coverage.*` changes name by
|
|
18
|
+
`source_id` (`src_` and 24 hex digits, a digest of the tenant and the source; `subject` too).
|
|
19
|
+
`slotlock_get_coverage` and `listCoverage` give each source's `source_id`, and
|
|
20
|
+
`slotlockCoverageSourceId` works it out. A webhook's `event_types` takes any of them, up to every
|
|
21
|
+
type (it took at most 20).
|
|
22
|
+
- Where a change came from: `withTenant` takes `origin` (`mcp`, `a2a`, `rest`, `application`,
|
|
23
|
+
`dashboard`, `dashboard-assistant`, `maintenance` or `library`, the default, also for a write
|
|
24
|
+
outside `withTenant`) and `keyId`, set by
|
|
25
|
+
each surface: the agent server per protocol, the application API, the dashboard and its
|
|
26
|
+
assistant, maintenance and webhook delivery. API key principals carry `keyId`, and the API key
|
|
27
|
+
store's `create`, `rotate` and `revoke` take `actor` and `origin`. The feed's new nullable
|
|
28
|
+
columns `origin` and `key_id` are in `listHistory` and `listChanges` (`origin`, `keyId`), the
|
|
29
|
+
history API (`origin`, `key_id`) and CloudEvents (extension attributes `slotlockorigin` and
|
|
30
|
+
`slotlockkeyid`). Rows written before keep null; an origin this release does not know reads as
|
|
31
|
+
null. `key_id` is pseudonymous and stays on a revoked key's changes for the retention. No address
|
|
32
|
+
or other personal data is recorded. The column's CHECK is added NOT VALID and validated after the
|
|
33
|
+
migration commits, so upgrading scans no rows under the table lock. An authenticator's key id
|
|
34
|
+
that is not a version 1-5 UUID is left out, as documented, rather than refusing the call.
|
|
35
|
+
- Live updates from the change feed (RFC 0002 P1-5). With `subscriptions.changes`
|
|
36
|
+
(`createSlotlockStoreChangeSource(store, sql)`, which `slotlock serve` sets), MCP resource
|
|
37
|
+
subscriptions follow each tenant's change feed instead of re-reading every 10 seconds: one reader
|
|
38
|
+
per tenant per process, shared by its subscriptions, polls the feed by cursor (one indexed query,
|
|
39
|
+
every 250 ms while changes come, `SLOTLOCK_FEED_BUSY_MS`, backing off to 1 s when quiet,
|
|
40
|
+
`SLOTLOCK_FEED_IDLE_MS`) and re-reads only the subscriptions a change touches, each at most once a
|
|
41
|
+
second, so a booking is announced within about a second. Nothing is added to the write path: no
|
|
42
|
+
trigger, no `NOTIFY`. A cursor pruned past announces every watched resource once. The re-read every
|
|
43
|
+
`pollIntervalMs` stays as the fallback and revalidates the caller (15 s with a feed), and the
|
|
44
|
+
subscription caps are unchanged. `slotlockChangeFeedHead` is where a reader starts.
|
|
45
|
+
- A2A streaming: the card declares `streaming`. `SendStreamingMessage` streams any skill's reply as
|
|
46
|
+
one message, and the `slotlock_watch_resources` skill streams a watch task with a status update
|
|
47
|
+
for each change to a watched resource's free/busy, from the same feed and caps as MCP
|
|
48
|
+
subscriptions, ending `TASK_STATE_COMPLETED` or, when access is withdrawn, `TASK_STATE_FAILED`.
|
|
49
|
+
Push notifications stay unsupported: webhooks deliver a tenant's changes to a URL.
|
|
50
|
+
- Benchmarks on every release (RFC 0002, P2-1): once a tag's suite passes, release.yml runs
|
|
51
|
+
`scripts/bench.mjs` against a PostgreSQL 16 service container, attaches `bench.json` and
|
|
52
|
+
`bench.md` to the GitHub release and rebuilds the site, whose new Benchmarks page shows the newest
|
|
53
|
+
results with the method and caveats. The job is informational: nothing waits on it and its failure
|
|
54
|
+
fails no release. The script now also measures free/busy reads and the non-overlapping workload
|
|
55
|
+
with durable commits off, records the PostgreSQL version, CPU count, clients and duration, and
|
|
56
|
+
writes both files with `--out DIR`.
|
|
57
|
+
- Booking rules: a leading buffer (`bufferBeforeMinutes`), the time kept free before each booking,
|
|
58
|
+
so two bookings stay the earlier one's trailing buffer plus the later one's leading buffer apart.
|
|
59
|
+
A reservation now occupies from `occupied_starts_at` (its start less `buffer_before_ms`, set by
|
|
60
|
+
a trigger) and the `slotlock_reservations_no_overlap` constraint keeps that time apart; free/busy,
|
|
61
|
+
searches, pool availability, holds and their reaping, coverage checks, series rolls and the
|
|
62
|
+
waitlist all read it.
|
|
63
|
+
- Booking rules: a step offset (`stepOffsetMinutes`, 0 to the step less one): with a 30-minute step
|
|
64
|
+
and 15, bookings start on :15 and :45, local time in the resource's zone, on writes and searches.
|
|
65
|
+
- Default booking rules for the tenant and for each pool (`setBookingRuleDefaults`,
|
|
66
|
+
`getBookingRuleDefaults`; `GET`/`PUT /v1/booking-rules`, `PUT /v1/pools/{pool}/booking-rules`):
|
|
67
|
+
a resource's rules as they apply (`effectiveBookingRules`, `effective_booking_rules`) are the
|
|
68
|
+
tenant's, overlaid by its pool's, overlaid by its own. A new tenant table under forced row-level
|
|
69
|
+
security, `booking_rule_defaults`, which erasure deletes. The dashboard's rules form has both new
|
|
70
|
+
rules and shows each inherited rule and where it comes from; an inherited on-or-off rule
|
|
71
|
+
(`enforceHours`, `requireCertain`) is a choice of default, on or off, so a resource can turn it
|
|
72
|
+
off. At most 1,000 pools a tenant have defaults (`pool_rules_limit_reached`). Merged rules are
|
|
73
|
+
read leniently: an offset under a shorter step applies modulo it, and an invalid field is ignored
|
|
74
|
+
and logged, never read as refusing every booking.
|
|
75
|
+
- Pool availability counts a member busy over its bookings' occupied time, buffers included, not
|
|
76
|
+
the buffers a new booking would keep.
|
|
77
|
+
|
|
78
|
+
### Upgrading
|
|
79
|
+
|
|
80
|
+
- The migration adds `reservations.buffer_before_ms` and `occupied_starts_at` and
|
|
81
|
+
`calendar_events.buffer_before_ms` without rewriting a row (existing rows keep no leading buffer,
|
|
82
|
+
so their occupied time is unchanged), adds their checks NOT VALID and validates them after it
|
|
83
|
+
commits, and rebuilds `slotlock_reservations_no_overlap` once.
|
|
84
|
+
- The rebuild holds an ACCESS EXCLUSIVE lock on `slotlock.reservations` for the whole index build,
|
|
85
|
+
about 15 seconds per million rows as measured in review: every read and write of reservations
|
|
86
|
+
waits until it ends. Schedule it, and for a very large table raise the migration's
|
|
87
|
+
`statementTimeoutMs` (`SlotlockMigrationOptions`, default 900,000 ms) so the build is not
|
|
88
|
+
cancelled.
|
|
89
|
+
- `slotlock migrate` applies row-level security to the new table and grants it to the serving role.
|
|
90
|
+
A library user runs `applyTenantRls`, then `grantApplicationRole` right after `applySchema` and
|
|
91
|
+
before serving traffic: until then every booking fails with a permission error on
|
|
92
|
+
`booking_rule_defaults`, which every booking reads.
|
|
93
|
+
|
|
94
|
+
### Changed
|
|
95
|
+
|
|
96
|
+
- The feed now also records configuration changes, so a reader or webhook with no `event_types`
|
|
97
|
+
filter receives them too; filter by type to keep only bookings.
|
|
98
|
+
- `coverage.stale` names its source by `source_id`, as `subject` and in `data`, in place of `source`,
|
|
99
|
+
and no longer carries `last_error`: a source can be an iCalendar URL with a token or an address,
|
|
100
|
+
and an error can quote it. `slotlock_get_coverage` still shows both to the tenant, with the
|
|
101
|
+
`source_id` to match a change to its source. Changes already on the feed keep the old payload
|
|
102
|
+
until they are pruned.
|
|
103
|
+
|
|
6
104
|
## 0.5.2 - 2026-10-28
|
|
7
105
|
|
|
8
106
|
### Added
|
package/README.md
CHANGED
|
@@ -155,6 +155,8 @@ slotlock rules clear --resource vehicle-42
|
|
|
155
155
|
| `minNoticeMinutes` | starts at least this far ahead |
|
|
156
156
|
| `maxAdvanceDays` | starts at most this far ahead |
|
|
157
157
|
| `slotStepMinutes` | starts on a step from local midnight in the resource's time zone |
|
|
158
|
+
| `stepOffsetMinutes` | starts this far past each step (0 to the step less one): with a 30-minute step, 15 starts bookings on :15 and :45 |
|
|
159
|
+
| `bufferBeforeMinutes` | keeps this long free before it: its leading buffer, which the database enforces |
|
|
158
160
|
| `turnaroundMinutes` | keeps this long free after it: its trailing buffer, which the database enforces |
|
|
159
161
|
| `enforceHours` | lies inside the resource's bookable hours |
|
|
160
162
|
| `checkInMinutes` | is claimed (`checkInReservation`) this long after its start, or maintenance releases it |
|
|
@@ -169,8 +171,22 @@ slotlock rules clear --resource vehicle-42
|
|
|
169
171
|
step and hours. Imported calendar events are not checked: they happened elsewhere.
|
|
170
172
|
- Searches (`slotlock_find_next_available`, `slotlock_find_available_slots`, `findNextAvailableFor`)
|
|
171
173
|
offer only slots the rules allow, so an offered slot is one the write accepts.
|
|
174
|
+
- Two bookings stay at least the earlier one's trailing buffer plus the later one's leading buffer
|
|
175
|
+
apart: each occupies from its start less its leading buffer to its end plus its trailing one, and
|
|
176
|
+
the exclusion constraint keeps those apart. Free/busy reports that time as busy.
|
|
172
177
|
- The REST application API sets them with `PUT /v1/resources/{resource_id}/booking-rules`, in
|
|
173
178
|
snake_case; the store with `setResourceBookingRules`.
|
|
179
|
+
- Defaults: rules for every resource of the tenant (`PUT /v1/booking-rules`) and for every member
|
|
180
|
+
of a pool (`PUT /v1/pools/{pool}/booking-rules`), read back with `GET /v1/booking-rules`; the
|
|
181
|
+
store's `setBookingRuleDefaults` and `getBookingRuleDefaults`. A resource's rules as they apply
|
|
182
|
+
(`effective_booking_rules`, `effectiveBookingRules`) are the tenant's defaults, overlaid by its
|
|
183
|
+
pool's, overlaid by its own: a rule set at a narrower level wins. `checkInMinutes` is a resource's
|
|
184
|
+
own only. The dashboard's resource page shows which rules a resource inherits, and from where.
|
|
185
|
+
- With no rule set anywhere, nothing changes: every rule is opt-in.
|
|
186
|
+
- A tenant has default rules for at most 1,000 pools (`pool_rules_limit_reached`, 409, for one
|
|
187
|
+
more). Merged rules are read leniently: a pool's `stepOffsetMinutes` under a resource's shorter
|
|
188
|
+
step applies modulo that step, and a field that is invalid on its own is ignored (and logged),
|
|
189
|
+
never read as refusing every booking.
|
|
174
190
|
|
|
175
191
|
### Without Docker
|
|
176
192
|
|
|
@@ -834,8 +850,10 @@ import {
|
|
|
834
850
|
type SlotlockStore,
|
|
835
851
|
createSlotlockAgentServer,
|
|
836
852
|
createSlotlockStoreAgentBackend,
|
|
853
|
+
createSlotlockStoreChangeSource,
|
|
837
854
|
} from 'slotlock';
|
|
838
855
|
import { createSlotlockNodeServer } from 'slotlock/node-server';
|
|
856
|
+
import type { Sql } from 'postgres';
|
|
839
857
|
|
|
840
858
|
const ALLOWED_OPERATIONS: ReadonlySet<SlotlockAgentOperation> = new Set([
|
|
841
859
|
'slotlock_list_resources',
|
|
@@ -849,6 +867,8 @@ const ALLOWED_OPERATIONS: ReadonlySet<SlotlockAgentOperation> = new Set([
|
|
|
849
867
|
|
|
850
868
|
export interface CalendarServerConfig {
|
|
851
869
|
store: SlotlockStore;
|
|
870
|
+
/** The store's client: live updates read the change feed's head with it. */
|
|
871
|
+
sql: Sql;
|
|
852
872
|
publicBaseUrl: string;
|
|
853
873
|
port: number;
|
|
854
874
|
verifyToken(token: string): Promise<SlotlockAgentPrincipal | null>;
|
|
@@ -869,7 +889,8 @@ export async function startCalendarServer(config: CalendarServerConfig) {
|
|
|
869
889
|
}
|
|
870
890
|
: {}),
|
|
871
891
|
resourceWindowDays: 90,
|
|
872
|
-
|
|
892
|
+
// Live updates follow each tenant's change feed: a booking is announced as it commits.
|
|
893
|
+
subscriptions: { changes: createSlotlockStoreChangeSource(config.store, config.sql) },
|
|
873
894
|
backend: createSlotlockStoreAgentBackend(config.store, {
|
|
874
895
|
availabilityRules: async () => [
|
|
875
896
|
{ rrule: 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR', startMinutes: 9 * 60, durationMinutes: 480 },
|
|
@@ -957,7 +978,9 @@ Rentals sell a class, not a plate. Put interchangeable resources in a pool
|
|
|
957
978
|
members with `slotlock_list_resources` and `pool`, and book the class with `slotlock_book_pool`,
|
|
958
979
|
which takes the first member that is free and allows the booking, in one transaction: a class of
|
|
959
980
|
five sells exactly five for a time, however many race (`pool_full` after that). To see how many are
|
|
960
|
-
free, `slotlock_get_pool_availability` splits a window where that number changes
|
|
981
|
+
free, `slotlock_get_pool_availability` splits a window where that number changes (counting each
|
|
982
|
+
member busy over its bookings' occupied time, buffers included, but not the buffers a new booking
|
|
983
|
+
would keep, so a member it counts free may still refuse a booking that abuts another); to see which,
|
|
961
984
|
search the members with `slotlock_find_available_slots` and `require_free: 1`: each slot names the
|
|
962
985
|
members free in `resource_ids`. `GET /v1/pools` lists the pools with their capacity. The older names `calendar.<verb>` and `calendar_<verb>` still work and
|
|
963
986
|
are no longer advertised.
|
|
@@ -998,7 +1021,7 @@ on stderr.
|
|
|
998
1021
|
| HTTP | `401` with `WWW-Authenticate`, `403` disallowed origin, `413`/`415` bad body, `429` rate limit (MCP, REST) |
|
|
999
1022
|
| MCP | `-32602` unknown tool, `arguments` that are not an object, or altered confirmation; `-32603` server fault; `-32601` unknown method |
|
|
1000
1023
|
| MCP 2026-07-28 | `-32020` header disagrees with body, `-32021` form elicitation not declared, `-32022` unsupported revision, `-31029` rate limit |
|
|
1001
|
-
| A2A | `-32602` malformed message or parameters, `-32029` rate limit (`RATE_LIMITED`, in the 200 envelope like every A2A error), `-32009` unsupported `A2A-Version`, `-32003` push notifications, `-32004`
|
|
1024
|
+
| A2A | `-32602` malformed message or parameters, `-32029` rate limit (`RATE_LIMITED`, in the 200 envelope like every A2A error), `-32009` unsupported `A2A-Version`, `-32003` push notifications, `-32004` task subscription or extended card, `-32001` task not found (the server keeps no tasks) |
|
|
1002
1025
|
|
|
1003
1026
|
### Authentication and OAuth discovery
|
|
1004
1027
|
|
|
@@ -1049,8 +1072,18 @@ On MCP 2026-07-28 each resource is also an MCP resource, `slotlock://resources/{
|
|
|
1049
1072
|
returns busy time and coverage for the next `resourceWindowDays` (30). `subscriptions/listen` streams
|
|
1050
1073
|
`notifications/resources/updated` when a resource's free/busy changes.
|
|
1051
1074
|
|
|
1052
|
-
-
|
|
1053
|
-
|
|
1075
|
+
- With `subscriptions.changes` (`createSlotlockStoreChangeSource(store, sql)`; `slotlock serve`
|
|
1076
|
+
sets it), changes come from the tenant's change feed: one reader per tenant per process, shared by
|
|
1077
|
+
all its subscriptions, polls the feed by cursor, one indexed query per read, under the tenant's
|
|
1078
|
+
row-level security. It reads again 250 ms after a read that found changes
|
|
1079
|
+
(`SLOTLOCK_FEED_BUSY_MS`, also the least gap between two reads) and backs off to once a second
|
|
1080
|
+
when quiet (`SLOTLOCK_FEED_IDLE_MS`), so a booking is announced within about a second. A change
|
|
1081
|
+
re-reads the subscriptions watching its resource, at most once a second each however many
|
|
1082
|
+
changes arrive, and each announces only what actually changed in its window. A cursor the feed
|
|
1083
|
+
has pruned past announces every watched resource once, then follows from now.
|
|
1084
|
+
- Each subscription is also re-read every `pollIntervalMs` (15 s with a feed, 10 s without one,
|
|
1085
|
+
which is then the only way changes are found), re-checking the caller each time, so a revoked key
|
|
1086
|
+
or what the feed does not carry (bookable hours) is caught.
|
|
1054
1087
|
- A subscription ends after `maxDurationMs` (15 minutes), on `shutdown()`, or when access is lost.
|
|
1055
1088
|
- At most 4 open per caller and 256 per server (`maxPerPrincipal`, `maxTotal`). `maxPerTenant`
|
|
1056
1089
|
caps one tenant's across all its callers (default `maxTotal`; `slotlock serve` sets 16), so a
|
|
@@ -1208,12 +1241,37 @@ curl -X PUT "http://localhost:8080/v1/reservations/BK-42" \
|
|
|
1208
1241
|
|
|
1209
1242
|
### Changes and webhooks
|
|
1210
1243
|
|
|
1211
|
-
Every committed change to a tenant's holds, bookings
|
|
1212
|
-
transaction (by a database trigger, so no write path skips it), and nothing is recorded
|
|
1213
|
-
not commit. A change carries ids, times and states, never titles, descriptions
|
|
1244
|
+
Every committed change to a tenant's holds, bookings, calendar events and configuration is recorded
|
|
1245
|
+
in the same transaction (by a database trigger, so no write path skips it), and nothing is recorded
|
|
1246
|
+
that did not commit. A change carries ids, times and states, never titles, descriptions, attendees,
|
|
1247
|
+
webhook URLs or secrets.
|
|
1248
|
+
|
|
1249
|
+
- The types: `hold.created`, `.updated`, `.confirmed`, `.released`, `.expired`;
|
|
1250
|
+
`reservation.created`, `.updated`, `.cancelled`, `.checked_in`, `.no_show`; `event.created`,
|
|
1251
|
+
`.updated`, `.cancelled`, `.timezone_shifted`, `.timezone_conflict`; `waitlist.offered`,
|
|
1252
|
+
`.expired`; `coverage.stale`; and configuration: `resource.created`, `resource.updated` (booking
|
|
1253
|
+
and check-in rules, pool, bookable hours, time zone or reference, with `before` and `after` of
|
|
1254
|
+
only the fields that changed), `resource.archived`, `.restored`, `.deleted`; `coverage.recorded`
|
|
1255
|
+
(a source's first read; reading it again is not a change) and `coverage.deleted`. A `coverage.*`
|
|
1256
|
+
change names its source by `source_id` (also its `subject`), a digest of the tenant and the source
|
|
1257
|
+
that `slotlock_get_coverage` and `slotlockCoverageSourceId` give too, never the source itself,
|
|
1258
|
+
which can be an iCalendar URL with a token or an address;
|
|
1259
|
+
`webhook.created`, `.enabled`, `.disabled`, `.deleted` (the id and `event_types`, never the URL);
|
|
1260
|
+
`key.created`, `.rotated`, `.revoked` (the id, scopes and expiry, never the key, its digest,
|
|
1261
|
+
prefix or name). `SLOTLOCK_CHANGE_TYPES` lists them.
|
|
1262
|
+
- Each change records who made it (`actor`: the API key or agent), the API key's id (`key_id`) when
|
|
1263
|
+
a key made it, and where it came from (`origin`): `mcp`, `a2a`, `rest` (the REST agent routes),
|
|
1264
|
+
`application` (the application API), `dashboard`, `dashboard-assistant`, `maintenance`, or
|
|
1265
|
+
`library`: code calling the store itself, inside `withTenant` with no `origin` or outside it, and
|
|
1266
|
+
any write that names none. Never an address or anything else about a person. A change an earlier
|
|
1267
|
+
release recorded has neither (`null`).
|
|
1268
|
+
- `key_id` is pseudonymous: the key's id, never the key or its name. It stays on the changes the key
|
|
1269
|
+
made after the key is revoked or deleted, for as long as the feed keeps them (the retention below,
|
|
1270
|
+
and longer while an enabled webhook has still to receive them); erasing the tenant removes them.
|
|
1214
1271
|
|
|
1215
1272
|
- `GET /v1/changes?cursor=…` lists them oldest first as [CloudEvents 1.0](https://cloudevents.io)
|
|
1216
|
-
(`io.pylota.slotlock.hold.created`, `.reservation.cancelled`, `.event.updated`, …)
|
|
1273
|
+
(`io.pylota.slotlock.hold.created`, `.reservation.cancelled`, `.event.updated`, …), with the
|
|
1274
|
+
extension attributes `slotlockorigin` and `slotlockkeyid` when the change records them. Follow
|
|
1217
1275
|
`next_cursor`, which comes back even when nothing is new: it never skips a change, even one whose
|
|
1218
1276
|
transaction commits after a later one's. Changes are kept 30 days (`SLOTLOCK_RETENTION_DAYS`,
|
|
1219
1277
|
`retentionDays` in `createSlotlockStore`, 1-3,650, for `serve` and `maintain` alike); a cursor
|
|
@@ -1224,7 +1282,7 @@ not commit. A change carries ids, times and states, never titles, descriptions o
|
|
|
1224
1282
|
- `POST /v1/webhooks` with an https `url` (and `event_types`, optionally) sends each change from
|
|
1225
1283
|
then on as a [Standard Webhooks](https://www.standardwebhooks.com) request: `webhook-id` (the
|
|
1226
1284
|
change's id, the same on every retry), `webhook-timestamp` and `webhook-signature`, keyed by the
|
|
1227
|
-
`whsec_` secret the call returns once. Verify it with any Standard Webhooks library, and use
|
|
1285
|
+
`whsec_` secret the call returns once. `event_types` takes any of the types above. Verify it with any Standard Webhooks library, and use
|
|
1228
1286
|
`webhook-id` to drop a repeat: delivery is at least once.
|
|
1229
1287
|
- Changes go to each webhook in order, one at a time, until one fails; that one is retried after
|
|
1230
1288
|
5 seconds, 5 minutes, 30 minutes, 2, 5, then every 10 hours. After a day of failures, or a `410`,
|
|
@@ -1234,11 +1292,14 @@ not commit. A change carries ids, times and states, never titles, descriptions o
|
|
|
1234
1292
|
an enabled webhook has still to receive, however old. At most 10 webhooks per tenant.
|
|
1235
1293
|
- Each request has 15 seconds in all, and a tenant's pass 30 seconds; tenants are delivered 8 at
|
|
1236
1294
|
a time, so a slow endpoint holds up only its own tenant.
|
|
1237
|
-
- History reads the same record backwards, with who made each change (
|
|
1238
|
-
for a change of an existing booking or
|
|
1239
|
-
`GET /v1/resources/{id}/history
|
|
1240
|
-
`GET /v1/tenant/events/{id}/history`, newest first,
|
|
1241
|
-
|
|
1295
|
+
- History reads the same record backwards, with who made each change (`actor`, `key_id`), where
|
|
1296
|
+
from (`origin`) and, for a change of an existing booking, event or resource, what it was before:
|
|
1297
|
+
`GET /v1/resources/{id}/history` (its bookings, events and its own configuration),
|
|
1298
|
+
`GET /v1/reservations/{reference}/history`, `GET /v1/tenant/events/{id}/history`, newest first,
|
|
1299
|
+
paged by `before`. In the library, pass `actor`, `origin` and `keyId` to `withTenant` (`origin`
|
|
1300
|
+
defaults to the enclosing call's, else `library`; `createSlotlockApiKeyAuthenticator` gives the
|
|
1301
|
+
principal its `keyId`), and `actor` and `origin` to the API key store's `create`, `rotate` and
|
|
1302
|
+
`revoke`; read `listHistory`. It is kept as long as the feed and erased with
|
|
1242
1303
|
the tenant.
|
|
1243
1304
|
- When time zone rules change (a new tzdata), a booking's instant and the wall time it was made for
|
|
1244
1305
|
part: Winnipeg's 09:00 is no longer the instant stored. Slotlock keeps both, and maintenance finds
|
|
@@ -1247,7 +1308,8 @@ not commit. A change carries ids, times and states, never titles, descriptions o
|
|
|
1247
1308
|
to `maintainSlotlockTenant` moves them instead, as a new revision, unless that overlaps another
|
|
1248
1309
|
booking (`event.timezone_conflict`; it stays, for a person or an agent to decide). `/healthz`
|
|
1249
1310
|
reports the `tzdata` the server runs.
|
|
1250
|
-
- Feed health: `GET /v1/resources/{id}/coverage` shows each source
|
|
1311
|
+
- Feed health: `GET /v1/resources/{id}/coverage` shows each source (and its `source_id`, as the feed
|
|
1312
|
+
names it), its window, when it was last read,
|
|
1251
1313
|
its last error (`POST /v1/resources/{id}/coverage/{source}/failure` records one) and whether it is
|
|
1252
1314
|
stale (not read for a day, so the time it covers is unproven, never free). A source that goes stale
|
|
1253
1315
|
is put on the feed once, as `coverage.stale`, until it is read again. Retire a sync you have
|
|
@@ -1421,6 +1483,15 @@ export async function listResourcesOverA2a(baseUrl: string, token: string) {
|
|
|
1421
1483
|
Send one `application/json` data part, `{"skill": "<id>", "arguments": {…}}`, with header
|
|
1422
1484
|
`A2A-Version: 1.0`. Each skill in the agent card has an example; replies are synchronous.
|
|
1423
1485
|
|
|
1486
|
+
The card declares `streaming`. `SendStreamingMessage` with any skill streams its reply as one
|
|
1487
|
+
message. With `slotlock_watch_resources` (`{"resource_ids": [...]}`, up to 20) it streams a watch:
|
|
1488
|
+
a task in `TASK_STATE_WORKING` naming the resources it can see, then a status update
|
|
1489
|
+
`{"resource_id", "uri"}` each time one's free/busy changes, from the same feed and under the same
|
|
1490
|
+
caps as MCP subscriptions, until `TASK_STATE_COMPLETED` (its duration, shutdown) or
|
|
1491
|
+
`TASK_STATE_FAILED` (access withdrawn). Closing the stream ends it; the task is not kept, so
|
|
1492
|
+
`SubscribeToTask` and `GetTask` do not find it. Push notifications are not offered: a tenant's
|
|
1493
|
+
changes reach a URL as signed webhooks.
|
|
1494
|
+
|
|
1424
1495
|
### MCP Apps
|
|
1425
1496
|
|
|
1426
1497
|
Read-only tools link to `ui://slotlock/calendar`, a self-contained calendar view that hosts
|
|
@@ -1506,7 +1577,9 @@ export async function maintainTenant(store: SlotlockStore, tenantRef: string) {
|
|
|
1506
1577
|
7. Monitor conflicts, refused rules, sync lag, capped maintenance runs, quota errors and confirmation outcomes.
|
|
1507
1578
|
8. Keep writes confirmation-gated wherever your policy needs a person (`slotlock serve`:
|
|
1508
1579
|
`SLOTLOCK_CONFIRM_WRITES`, `none` by default).
|
|
1509
|
-
9. Size live updates:
|
|
1580
|
+
9. Size live updates: one indexed feed query per second per tenant with an open subscription (four
|
|
1581
|
+
a second while its feed is busy), at most one backend read a second per subscription a change
|
|
1582
|
+
touches, and at most `maxTotal` backend reads per `pollIntervalMs`.
|
|
1510
1583
|
10. Give each agent its own API key with the narrowest scope and an expiry.
|
|
1511
1584
|
11. After each deployment, check it end to end with a short-lived admin key, then revoke the key:
|
|
1512
1585
|
`SLOTLOCK_URL=https://… SLOTLOCK_ADMIN_KEY=… node scripts/smoke-deployment.mjs`, from a clone
|
|
@@ -1517,7 +1590,13 @@ export async function maintainTenant(store: SlotlockStore, tenantRef: string) {
|
|
|
1517
1590
|
server (`SLOTLOCK_LIMITS`); behind a proxy, set `SLOTLOCK_TRUSTED_PROXY_HEADER` and firewall
|
|
1518
1591
|
the server so nothing reaches it except through that proxy. Embedding the
|
|
1519
1592
|
agent server, give it a `consumeRateLimit`. Watch serve's `refusals` log line for floods.
|
|
1520
|
-
13.
|
|
1593
|
+
13. On an upgrade, migrate first, then, as a library user, run `grantApplicationRole` again right
|
|
1594
|
+
after `applySchema` and before serving traffic: a release that adds a table (the leading-buffer
|
|
1595
|
+
release adds `booking_rule_defaults`, which every booking reads) fails every booking with a
|
|
1596
|
+
permission error until the serving role is granted it. `slotlock migrate` does both. A migration
|
|
1597
|
+
that rebuilds a constraint holds its table's lock for the whole build (see the CHANGELOG's
|
|
1598
|
+
Upgrading notes).
|
|
1599
|
+
14. Bound how long a transaction may run. A store over your own transaction (a savepoint per call)
|
|
1521
1600
|
keeps its locks and its snapshot for as long as yours runs, and a hold you confirm inside it is
|
|
1522
1601
|
judged by the statement's clock, not the transaction's. On PostgreSQL 17 and later, give the
|
|
1523
1602
|
serving role a `transaction_timeout` above your longest legitimate transaction
|
package/SPEC.md
CHANGED
|
@@ -245,13 +245,16 @@ envelope (a body over the size limit is HTTP 413); A2A's own errors, below, carr
|
|
|
245
245
|
`google.rpc.ErrorInfo` detail and JSON-RPC's (`-32700`, `-32600`, `-32601`, `-32603`) none. An
|
|
246
246
|
`A2A-Version` other than `1.0`, including none (read as 0.3), is `VersionNotSupportedError`
|
|
247
247
|
(`-32009`); push-notification methods are
|
|
248
|
-
`PushNotificationNotSupportedError` (`-32003`);
|
|
249
|
-
|
|
248
|
+
`PushNotificationNotSupportedError` (`-32003`); task subscription and the extended card are
|
|
249
|
+
`UnsupportedOperationError` (`-32004`); a message that is not exactly one data part
|
|
250
250
|
`{"skill": "<id>", "arguments": {…}}` (`arguments` left out or null is none; any other value that is
|
|
251
251
|
not an object is not arguments) is `-32602`, as is a `configuration` or `metadata` that is set but
|
|
252
252
|
is not an object; a rate-limited request is `-32029` with the reason `RATE_LIMITED` and a null id,
|
|
253
|
-
answered before its body is read.
|
|
254
|
-
|
|
253
|
+
answered before its body is read. `SendStreamingMessage` streams a skill's reply as one message;
|
|
254
|
+
with `slotlock_watch_resources` it streams a watch task (TASK_STATE_WORKING, a status update per
|
|
255
|
+
changed resource, then TASK_STATE_COMPLETED or TASK_STATE_FAILED) that lives only in its stream,
|
|
256
|
+
under the MCP subscription caps; refused before it opens, it is the skill's reply. The server keeps
|
|
257
|
+
no tasks, so the core task operations answer as for an unknown task: `GetTask` and `CancelTask` are
|
|
255
258
|
`TaskNotFoundError` (`-32001`), `ListTasks` returns an empty page (`pageSize` 1-100, default 50).
|
|
256
259
|
Their params are checked first, as `SendMessage`'s are: a field the method does not define, a
|
|
257
260
|
`tenant` other than the caller's, a value of the wrong type (a `status` outside `TaskState`, say), a
|
|
@@ -364,9 +367,9 @@ with no remaining command; `internal` tombstones remain authoritative until tena
|
|
|
364
367
|
command evaluated after its evidence is gone is decided afresh by the expected-revision guard.
|
|
365
368
|
|
|
366
369
|
The protocol backend remains injectable so a deployment owns credential verification, policy, rate
|
|
367
|
-
limits, and networking. The A2A binding
|
|
368
|
-
|
|
369
|
-
persists that lifecycle itself.
|
|
370
|
+
limits, and networking. The A2A binding advertises streaming (a reply as one message, a watch as
|
|
371
|
+
a task that lives only in its stream) and not push notifications; an embedder must not claim
|
|
372
|
+
persisted task lifecycle support unless it persists that lifecycle itself.
|
|
370
373
|
|
|
371
374
|
The optional Node HTTP adapter preserves the Fetch boundary rather than defining another protocol
|
|
372
375
|
implementation. It constructs callback-visible request URLs from an explicit canonical origin,
|
package/agent-server.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { type SlotlockTraceContext } from './mcp-modern.js';
|
|
2
2
|
import { type SlotlockApplicationBackend, type SlotlockApplicationOperation } from './application-api.js';
|
|
3
|
+
import { type SlotlockAgentChangeSource } from './change-watch.js';
|
|
4
|
+
import type { SlotlockChangeOrigin } from './event-feed.js';
|
|
3
5
|
export type { SlotlockTraceContext } from './mcp-modern.js';
|
|
4
6
|
export { clientSupportsSlotlockMcpApp, SLOTLOCK_MCP_APP_HTML, SLOTLOCK_MCP_APP_MIME_TYPE, SLOTLOCK_MCP_APP_RESOURCE, SLOTLOCK_MCP_APP_RESOURCE_URI, SLOTLOCK_MCP_APPS_EXTENSION, } from './mcp-app.js';
|
|
5
7
|
/**
|
|
@@ -64,6 +66,11 @@ export interface SlotlockAgentPrincipal {
|
|
|
64
66
|
* Unset, `authorize` alone decides.
|
|
65
67
|
*/
|
|
66
68
|
scopes?: readonly string[];
|
|
69
|
+
/**
|
|
70
|
+
* The API key the credential is, by its id (a UUID), when it is one: the history records it with
|
|
71
|
+
* each change the principal makes (`createSlotlockApiKeyAuthenticator` sets it).
|
|
72
|
+
*/
|
|
73
|
+
keyId?: string;
|
|
67
74
|
}
|
|
68
75
|
export interface SlotlockAgentInvocationContext {
|
|
69
76
|
principal: SlotlockAgentPrincipal;
|
|
@@ -74,6 +81,11 @@ export interface SlotlockAgentInvocationContext {
|
|
|
74
81
|
* backend's spans as its children to follow one agent action across services.
|
|
75
82
|
*/
|
|
76
83
|
trace?: SlotlockTraceContext;
|
|
84
|
+
/**
|
|
85
|
+
* The surface the call came through (`mcp`, `a2a`, `rest`, `dashboard-assistant`), for a backend
|
|
86
|
+
* to record with its changes (withTenant's `origin`).
|
|
87
|
+
*/
|
|
88
|
+
origin?: SlotlockChangeOrigin;
|
|
77
89
|
}
|
|
78
90
|
/** A bounded domain failure a backend may deliberately expose through a protocol tool result. */
|
|
79
91
|
export declare class SlotlockAgentOperationError extends Error {
|
|
@@ -182,13 +194,27 @@ export interface SlotlockAgentConfirmationOptions {
|
|
|
182
194
|
/**
|
|
183
195
|
* Live calendar updates over MCP 2026-07-28 `subscriptions/listen`. A client subscribes to calendar
|
|
184
196
|
* resource URIs (`slotlockCalendarResourceUri`) and receives `notifications/resources/updated` on the
|
|
185
|
-
* response stream when a resource's free/busy changes.
|
|
186
|
-
*
|
|
187
|
-
*
|
|
197
|
+
* response stream when a resource's free/busy changes. With `changes`, the server follows each
|
|
198
|
+
* tenant's change feed (one reader per tenant, shared by all its subscriptions) and re-reads a
|
|
199
|
+
* subscription's resources when a change touches one of them; without it, it re-reads them every
|
|
200
|
+
* `pollIntervalMs`. Each re-read runs `authenticate` and `authorize` again, so a revoked credential
|
|
201
|
+
* or permission ends the stream, and announces only the resources whose free/busy changed.
|
|
188
202
|
*/
|
|
189
203
|
export interface SlotlockAgentSubscriptionOptions {
|
|
190
|
-
/**
|
|
204
|
+
/**
|
|
205
|
+
* How often an open subscription re-reads its resources regardless, in ms: 1,000-300,000. Default
|
|
206
|
+
* 10,000, or 15,000 with `changes`, where it is the fallback for what the feed does not carry
|
|
207
|
+
* (bookable hours, a revoked key or permission) and for a feed that stopped.
|
|
208
|
+
*/
|
|
191
209
|
pollIntervalMs?: number;
|
|
210
|
+
/**
|
|
211
|
+
* The change feed to follow (createSlotlockStoreChangeSource, or your own over
|
|
212
|
+
* SlotlockEventFeed.listChanges), polled by one reader per tenant every SLOTLOCK_FEED_BUSY_MS to
|
|
213
|
+
* SLOTLOCK_FEED_IDLE_MS: a change is announced within about a second instead of at the next
|
|
214
|
+
* re-read. A subscription re-reads its resources at most once a second, however many changes
|
|
215
|
+
* touch them.
|
|
216
|
+
*/
|
|
217
|
+
changes?: SlotlockAgentChangeSource;
|
|
192
218
|
/** When the server ends a subscription gracefully, in ms: 60,000-86,400,000, default 900,000. */
|
|
193
219
|
maxDurationMs?: number;
|
|
194
220
|
/** How often a quiet stream carries an SSE keep-alive comment, in ms: 1,000-60,000, default 15,000. */
|
|
@@ -449,6 +475,8 @@ export declare function invokeSlotlockAgentOperation(args: {
|
|
|
449
475
|
options: SlotlockAgentOperationDispatchOptions;
|
|
450
476
|
/** Validated W3C trace context to hand the backend (see `SlotlockAgentInvocationContext.trace`). */
|
|
451
477
|
trace?: SlotlockTraceContext;
|
|
478
|
+
/** The surface the call came through, handed to the backend (`SlotlockAgentInvocationContext.origin`). */
|
|
479
|
+
origin?: SlotlockChangeOrigin;
|
|
452
480
|
}): Promise<SlotlockAgentOperationDispatchResult>;
|
|
453
481
|
/**
|
|
454
482
|
* Read an A2A `google.protobuf.Timestamp` in its JSON form, as `ListTasks.statusTimestampAfter`
|
|
@@ -478,6 +506,12 @@ export declare function describeSlotlockAgentWrite(args: {
|
|
|
478
506
|
input: Record<string, unknown>;
|
|
479
507
|
signal: AbortSignal;
|
|
480
508
|
}): Promise<string>;
|
|
509
|
+
/**
|
|
510
|
+
* The A2A skill that watches calendars: `SendStreamingMessage` only. Its stream opens with a task
|
|
511
|
+
* in TASK_STATE_WORKING and carries a status update for each change to a watched resource's
|
|
512
|
+
* free/busy, from the same feed as MCP resource subscriptions, until the watch's duration is up.
|
|
513
|
+
*/
|
|
514
|
+
export declare const SLOTLOCK_A2A_WATCH_SKILL = "slotlock_watch_resources";
|
|
481
515
|
export declare function createSlotlockAgentServer(options: SlotlockAgentServerOptions): {
|
|
482
516
|
fetch(request: Request): Promise<Response>;
|
|
483
517
|
manifest: Readonly<Record<string, unknown>>;
|