slotlock 0.8.0 → 0.10.0
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 +542 -0
- package/NOTICE +2 -0
- package/README.md +4 -2
- package/agent-server.d.ts +25 -5
- package/agent-server.js +152 -7
- package/agent-store-backend.d.ts +6 -1
- package/agent-store-backend.js +1068 -68
- package/application-api.d.ts +28 -1
- package/application-api.js +789 -21
- package/approvals.d.ts +2 -0
- package/approvals.js +12 -5
- package/assistant.d.ts +34 -8
- package/assistant.js +81 -26
- package/availability-store.d.ts +193 -0
- package/availability-store.js +566 -0
- package/booking-rules.d.ts +103 -5
- package/booking-rules.js +263 -15
- package/config.d.ts +50 -0
- package/config.js +107 -0
- package/dashboard-assistant-ui.d.ts +1 -1
- package/dashboard-assistant-ui.js +109 -8
- package/dashboard-scope.d.ts +7 -0
- package/dashboard-scope.js +21 -0
- package/dashboard.d.ts +152 -8
- package/dashboard.js +3868 -399
- package/ddl-identity.d.ts +25 -0
- package/ddl-identity.js +881 -0
- package/ddl-scim.d.ts +5 -0
- package/ddl-scim.js +740 -0
- package/ddl-sso.d.ts +15 -0
- package/ddl-sso.js +1033 -0
- package/ddl.d.ts +44 -9
- package/ddl.js +2872 -47
- package/domains.d.ts +117 -0
- package/domains.js +340 -0
- package/event-feed.d.ts +2 -2
- package/event-feed.js +24 -34
- package/holiday-import.d.ts +38 -0
- package/holiday-import.js +56 -0
- package/holidays.d.ts +38 -0
- package/holidays.js +209 -0
- package/hours.d.ts +125 -0
- package/hours.js +224 -0
- package/index.d.ts +24 -5
- package/index.js +17 -3
- package/interop.d.ts +25 -7
- package/interop.js +145 -17
- package/invitation-email.d.ts +26 -0
- package/invitation-email.js +73 -0
- package/lifecycle.d.ts +31 -0
- package/lifecycle.js +115 -0
- package/maintenance.d.ts +26 -2
- package/maintenance.js +70 -2
- package/metrics.js +3 -0
- package/oidc.d.ts +135 -0
- package/oidc.js +452 -0
- package/openapi.json +20172 -9850
- package/package.json +2 -1
- package/ranges.d.ts +28 -0
- package/ranges.js +135 -0
- package/resource-search.d.ts +19 -0
- package/resource-search.js +153 -0
- package/rest-api.js +7 -0
- package/rules.d.ts +2 -0
- package/rules.js +2 -0
- package/scim-server.d.ts +19 -0
- package/scim-server.js +243 -0
- package/scim.d.ts +180 -0
- package/scim.js +416 -0
- package/self-host.d.ts +19 -6
- package/self-host.js +483 -28
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +637 -23
- package/store.js +2529 -147
- package/types.d.ts +230 -0
- package/types.js +68 -1
- package/workspace-domains.d.ts +146 -0
- package/workspace-domains.js +249 -0
- package/workspace-roles.d.ts +47 -0
- package/workspace-roles.js +304 -0
- package/workspace-scim.d.ts +89 -0
- package/workspace-scim.js +157 -0
- package/workspace-sso.d.ts +131 -0
- package/workspace-sso.js +180 -0
- package/workspaces.d.ts +240 -0
- package/workspaces.js +433 -0
package/agent-server.d.ts
CHANGED
|
@@ -39,6 +39,8 @@ export declare const SLOTLOCK_A2A_TASK_PAGE_SIZE: Readonly<{
|
|
|
39
39
|
}>;
|
|
40
40
|
/** The A2A 1.0 `TaskState` names, as a `ListTasks` status filter spells them in JSON. */
|
|
41
41
|
export declare const SLOTLOCK_A2A_TASK_STATES: readonly ["TASK_STATE_UNSPECIFIED", "TASK_STATE_SUBMITTED", "TASK_STATE_WORKING", "TASK_STATE_COMPLETED", "TASK_STATE_FAILED", "TASK_STATE_CANCELED", "TASK_STATE_INPUT_REQUIRED", "TASK_STATE_REJECTED", "TASK_STATE_AUTH_REQUIRED"];
|
|
42
|
+
/** The longest resource search (resource-search.ts SLOTLOCK_RESOURCE_SEARCH_MAX; a test holds them equal). */
|
|
43
|
+
export declare const SLOTLOCK_AGENT_RESOURCE_SEARCH_MAX = 200;
|
|
42
44
|
/** The tools' copy of the profile's kinds and bounds, for the test that holds it to resource-profile.ts. */
|
|
43
45
|
export declare const SLOTLOCK_AGENT_RESOURCE_PROFILE: Readonly<{
|
|
44
46
|
kinds: ("desk" | "equipment" | "machine" | "other" | "person" | "room" | "service" | "space" | "vehicle")[];
|
|
@@ -62,7 +64,7 @@ export declare const SLOTLOCK_MAX_ALTERNATIVES = 3;
|
|
|
62
64
|
* Operation (MCP tool / A2A skill) names. They use only `[a-z_]`, so they are valid tool names for the
|
|
63
65
|
* Claude Messages API (`^[a-zA-Z0-9_-]{1,128}$`), OpenAI function calling and every MCP host.
|
|
64
66
|
*/
|
|
65
|
-
export type SlotlockAgentOperation = 'slotlock_list_resources' | 'slotlock_get_free_busy' | 'slotlock_find_next_available' | 'slotlock_find_available_slots' | 'slotlock_hold_slots' | 'slotlock_release_slots' | 'slotlock_confirm_slot' | 'slotlock_reschedule' | 'slotlock_cancel' | 'slotlock_book_together' | 'slotlock_book_pool' | 'slotlock_get_pool_availability' | 'slotlock_create_event' | 'slotlock_get_event' | 'slotlock_list_events' | 'slotlock_update_event' | 'slotlock_delete_event';
|
|
67
|
+
export type SlotlockAgentOperation = 'slotlock_list_resources' | 'slotlock_get_free_busy' | 'slotlock_find_next_available' | 'slotlock_find_available_slots' | 'slotlock_find_available_ranges' | 'slotlock_hold_slots' | 'slotlock_hold_together' | 'slotlock_release_slots' | 'slotlock_confirm_slot' | 'slotlock_reschedule' | 'slotlock_cancel' | 'slotlock_book_together' | 'slotlock_book_pool' | 'slotlock_get_pool_availability' | 'slotlock_create_event' | 'slotlock_get_event' | 'slotlock_list_events' | 'slotlock_update_event' | 'slotlock_delete_event';
|
|
66
68
|
/**
|
|
67
69
|
* Names earlier builds published, which every entry point still accepts and dispatches as the same
|
|
68
70
|
* operation; nothing advertises them. The dotted names came first, before tool names had to be
|
|
@@ -189,6 +191,12 @@ export interface SlotlockAgentCalendarBackend {
|
|
|
189
191
|
* (`slotlock_hold_slots`); a tool without it answers `holds_unavailable`.
|
|
190
192
|
*/
|
|
191
193
|
holdSlots?(context: SlotlockAgentInvocationContext, input: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
194
|
+
/**
|
|
195
|
+
* Optional. Hold one time on 2-20 resources all or nothing, as one group with one time to live
|
|
196
|
+
* (`slotlock_hold_together`, RFC 0004 B7), else `holds_unavailable`. `slotlock_book_together`
|
|
197
|
+
* with its `hold_group_id` books the set.
|
|
198
|
+
*/
|
|
199
|
+
holdTogether?(context: SlotlockAgentInvocationContext, input: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
192
200
|
/** Optional. Book any one free member of a pool (`slotlock_book_pool`), else `pools_unavailable`. */
|
|
193
201
|
bookPool?(context: SlotlockAgentInvocationContext, input: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
194
202
|
/**
|
|
@@ -228,6 +236,18 @@ export interface SlotlockAgentCalendarBackend {
|
|
|
228
236
|
}[];
|
|
229
237
|
certain: boolean;
|
|
230
238
|
}>;
|
|
239
|
+
/**
|
|
240
|
+
* Optional. Hires across days (`slotlock_find_available_ranges`, RFC 0004 B3): each resource's, or
|
|
241
|
+
* each pool member's, earliest range from a pick-up window to a return window, else
|
|
242
|
+
* `ranges_unavailable`.
|
|
243
|
+
*/
|
|
244
|
+
findAvailableRanges?(context: SlotlockAgentInvocationContext, input: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
245
|
+
/**
|
|
246
|
+
* Optional. The length a search for these resources offers when it is asked for none: their
|
|
247
|
+
* booking rules' `defaultDurationMinutes` when they all give the same one, else null. Without it,
|
|
248
|
+
* a search names its length.
|
|
249
|
+
*/
|
|
250
|
+
defaultDuration?(context: SlotlockAgentInvocationContext, resourceIds: readonly string[]): Promise<number | null>;
|
|
231
251
|
}
|
|
232
252
|
/**
|
|
233
253
|
* OAuth 2.0 discovery for the MCP endpoint (MCP 2025-11-25 authorization, RFC 9728). Slotlock only
|
|
@@ -593,11 +613,11 @@ export declare function slotlockOpenApi(options?: {
|
|
|
593
613
|
application?: boolean;
|
|
594
614
|
}): Record<string, unknown>;
|
|
595
615
|
export declare function slotlockAgentTools(): {
|
|
596
|
-
name: "slotlock_book_pool" | "slotlock_book_together" | "slotlock_cancel" | "slotlock_confirm_slot" | "slotlock_create_event" | "slotlock_delete_event" | "slotlock_find_available_slots" | "slotlock_find_next_available" | "slotlock_get_event" | "slotlock_get_free_busy" | "slotlock_get_pool_availability" | "slotlock_hold_slots" | "slotlock_list_events" | "slotlock_list_resources" | "slotlock_release_slots" | "slotlock_reschedule" | "slotlock_update_event";
|
|
597
|
-
title: "Book from a pool" | "Book resources together" | "Cancel an event" | "Confirm a held slot" | "Create resource event" | "Delete resource event" | "Find available slots" | "Find next available interval" | "Get free-busy" | "Get pool availability" | "Get resource event" | "Hold candidate slots" | "List calendar resources" | "List resource events" | "Release held slots" | "Reschedule an event" | "Update resource event";
|
|
598
|
-
description: "Book a resource for a time, as an event of up to 3,660 days (a booking, lease or long rental). The database refuses a time already taken: 409 overlap, with alternatives (the earliest free times of the same length) and, when the booking in the way is yours, its id. A booking rule that refuses the time answers booking_rule_… with alternatives too. To book a time you hold, pass its hold_id, or use slotlock_confirm_slot. A retry with the same idempotency_key answers the same event." | "Book a time you hold (a hold_id from slotlock_hold_slots): the held resource and time exactly, with the title and details you give, and the rest of its group released. 409 hold_expired when the hold lapsed first; 404 hold_not_found when it was released, booked or is not yours. A retry answers the same event (replayed)." | "Book any one member of a pool (a vehicle class, a room type) for a time: members are tried in the pool's order (by id, fewest bookings in the last 30 days first, or in turn, as the tenant sets it) and the first that is free and allows the booking is booked, in one transaction, so a class sells out exactly at its number of members. The event names the member booked. 409 pool_full when none is free; 404 pool_not_found when the pool has no member you may book. A retry with the same idempotency_key answers the same event." | "Book the same time on 2-20 resources at once, all or none: a van with its driver, a room with its projector. If any of them is taken, nothing is booked, and the refusal names that resource_id. Find such a time with slotlock_find_available_slots and require_free. A retry with the same idempotency_key answers the same events." | "Cancel one of your events at the revision you last read (expected_revision); a stale one is 409 revision_conflict. It cannot come back at that revision, so an old copy synced later does not restore it. slotlock_cancel does the same with fewer fields." | "Cancel one of your events at the revision you last read (expected_revision); a stale one is 409 revision_conflict. Its time is free at once. A retry answers the same cancellation (replayed)." | "Change one of your events at the revision you last read (expected_revision): its time, resource or details, all or nothing. A time already taken is 409 overlap with alternatives; a stale revision is 409 revision_conflict (read the event again). With expected_resource_id, the resource you read it on, an event since moved elsewhere is not found. To change only the time, slotlock_reschedule is simpler." | "Find the earliest certain interval that satisfies the requested duration (up to the 367-day search window, so a multi-month rental slot can be found). Name the resources with resource_ids, or select them as slotlock_get_free_busy does: at most 100." | "Hold up to five candidate slots for a short time (30-900 seconds) while a person chooses, as alternatives in one group: each free one is held, the others are listed with why not. Book one by passing its hold_id to slotlock_create_event, which releases the rest; unbooked holds expire on their own. A hold is yours alone, and you may have 20 live at once. A candidate refused for its time comes with alternatives (the earliest free times of its length). slotlock_confirm_slot books a held time." | "How many members of a pool (a vehicle class, a room type) are free across a window, each only where its booking rules let a booking start: the window split where that number changes, each part with how many are free, and coverage certainty. Book one with slotlock_book_pool. 404 pool_not_found when the pool has no member you may book." | "List resources visible to the authenticated tenant principal, with what each says about itself: its name (show it rather than the reference), description, kind, capacity and labels, where it is (location), the groups it is in (a team, a department), and the pool it belongs to (a vehicle class, a room type). With kind, only resources of that kind; with labels, only those with every label given; with location, only those there or at a location in it; with group, only that group's. With pool, only that pool's members, in the order slotlock_book_pool tries them." | "List up to `limit` certain slots of the requested duration, earliest first across the resources, each one free of busy time and inside the resource's hours, to offer a person a choice. With require_free, only times when at least that many of the resources are free together, each slot naming them in resource_ids: every resource for a booking that needs them all (book it with slotlock_book_together), or any n of them. No slot is listed unless every resource's coverage is certain. Name the resources with resource_ids, or select them as slotlock_get_free_busy does: at most 20." | "List your events that meet a time window, earliest first." | "Move one of your events to a new time, and to another resource if you give one, at the revision you last read (expected_revision). A time already taken is 409 overlap with alternatives; a stale revision 409 revision_conflict. A retry answers the same move (replayed)." | "Read one of your events by its id, with its revision (pass it as expected_revision to change or cancel it)." | "Release one of your holds, and the rest of its group, at once instead of letting them expire. `released` is false when it was already gone." | "When each resource is busy in a window, as busy intervals only (never whose or what), and whether that is certain (coverage: \"uncertain\" where a calendar feeding it has not been read lately). Name the resources with resource_ids, or select them instead by location (and every location in it), group, labels (every one), kind or pool: at most 100 (more answers selection_too_large, none selection_empty), named in selected_resource_ids.";
|
|
616
|
+
name: "slotlock_book_pool" | "slotlock_book_together" | "slotlock_cancel" | "slotlock_confirm_slot" | "slotlock_create_event" | "slotlock_delete_event" | "slotlock_find_available_ranges" | "slotlock_find_available_slots" | "slotlock_find_next_available" | "slotlock_get_event" | "slotlock_get_free_busy" | "slotlock_get_pool_availability" | "slotlock_hold_slots" | "slotlock_hold_together" | "slotlock_list_events" | "slotlock_list_resources" | "slotlock_release_slots" | "slotlock_reschedule" | "slotlock_update_event";
|
|
617
|
+
title: "Book from a pool" | "Book resources together" | "Cancel an event" | "Confirm a held slot" | "Create resource event" | "Delete resource event" | "Find available ranges" | "Find available slots" | "Find next available interval" | "Get free-busy" | "Get pool availability" | "Get resource event" | "Hold candidate slots" | "Hold resources together" | "List calendar resources" | "List resource events" | "Release held slots" | "Reschedule an event" | "Update resource event";
|
|
618
|
+
description: "Book a resource for a time, as an event of up to 3,660 days (a booking, lease or long rental). The database refuses a time already taken: 409 overlap, with alternatives (the earliest free times of the same length) and, when the booking in the way is yours, its id. A booking rule that refuses the time answers booking_rule_… with alternatives too. To book a time you hold, pass its hold_id, or use slotlock_confirm_slot. A retry with the same idempotency_key answers the same event." | "Book a time you hold (a hold_id from slotlock_hold_slots): the held resource and time exactly, with the title and details you give, and the rest of its group released. 409 hold_expired when the hold lapsed first; 404 hold_not_found when it was released, booked or is not yours. A retry answers the same event (replayed)." | "Book any one member of a pool (a vehicle class, a room type) for a time: members are tried in the pool's order (by id, fewest bookings in the last 30 days first, or in turn, as the tenant sets it) and the first that is free and allows the booking is booked, in one transaction, so a class sells out exactly at its number of members. The event names the member booked. 409 pool_full when none is free; 404 pool_not_found when the pool has no member you may book. A retry with the same idempotency_key answers the same event." | "Book the same time on 2-20 resources at once, all or none: a van with its driver, a room with its projector. If any of them is taken, nothing is booked, and the refusal names that resource_id. Find such a time with slotlock_find_available_slots and require_free. With hold_group_id, it books the set slotlock_hold_together held, from those holds. A retry with the same idempotency_key answers the same events." | "Cancel one of your events at the revision you last read (expected_revision); a stale one is 409 revision_conflict. It cannot come back at that revision, so an old copy synced later does not restore it. slotlock_cancel does the same with fewer fields." | "Cancel one of your events at the revision you last read (expected_revision); a stale one is 409 revision_conflict. Its time is free at once. A retry answers the same cancellation (replayed)." | "Change one of your events at the revision you last read (expected_revision): its time, resource or details, all or nothing. A time already taken is 409 overlap with alternatives; a stale revision is 409 revision_conflict (read the event again). With expected_resource_id, the resource you read it on, an event since moved elsewhere is not found. To change only the time, slotlock_reschedule is simpler." | "Find hires across days, such as a car from Friday to Monday: for each resource named, or each member of a pool, the earliest range that starts between pickup_start and pickup_end and either lasts `days` days (ending at the same local time) or ends between return_start and return_end. Each range is free throughout with its buffers, allowed by the resource's booking rules (pick-up and return times, lengths, notice, step) and starts and ends inside its bookable hours. Up to `limit` ranges, earliest first, one per resource; none unless every resource's coverage is certain. Book one with slotlock_create_event, or a pool's with slotlock_book_pool, at its start and end." | "Find the earliest certain interval that satisfies the requested duration (up to the 367-day search window, so a multi-month rental slot can be found). Name the resources with resource_ids, or select them as slotlock_get_free_busy does: at most 100." | "Hold one time on 2-20 resources at once, all or none, such as a car with its driver while a person decides: if any is taken, nothing is held and the refusal names that resource_id. The holds are one group with one time to live (ttl_seconds, the earliest of them). Book the set with slotlock_book_together and its hold_group_id; release them with slotlock_release_slots and any hold_id. A retry with the same idempotency_key answers the same holds." | "Hold up to five candidate slots for a short time (30-900 seconds) while a person chooses, as alternatives in one group: each free one is held, the others are listed with why not. Book one by passing its hold_id to slotlock_create_event, which releases the rest; unbooked holds expire on their own. With payment_required, a hold is booked only once the system you book for records its payment (the person pays there): booking it before then answers payment_required, so send the person to pay and try again. A hold is yours alone, and you may have 20 live at once. A candidate refused for its time comes with alternatives (the earliest free times of its length). slotlock_confirm_slot books a held time." | "How many members of a pool (a vehicle class, a room type) are free across a window, each only where its booking rules let a booking start: the window split where that number changes, each part with how many are free, and coverage certainty. Book one with slotlock_book_pool. 404 pool_not_found when the pool has no member you may book." | "List resources visible to the authenticated tenant principal, with what each says about itself: its name (show it rather than the reference), description, kind, capacity and labels, where it is (location), the groups it is in (a team, a department), and the pool it belongs to (a vehicle class, a room type). With kind, only resources of that kind; with labels, only those with every label given; with location, only those there or at a location in it; with group, only that group's. With pool, only that pool's members, in the order slotlock_book_pool tries them. With query, the resources that best match what a person calls one, best first, at most limit and with no next_cursor: each word is looked for in the name, reference, labels, kind, location and description, ignoring case and accents, and forgiving a typo, a word cut short or words run together (\"mercedez\", \"merc 2025\" and \"mercedesbenz\" find \"Mercedes-Benz 2025\"); numbers match exactly. It is the way to find the id of a resource a person names, since other tools take ids only. When several match about equally, ask which one is meant. A resource that states no kind, labels or location is left out by those filters, so search without them before saying a resource does not exist." | "List up to `limit` certain slots of the requested duration, earliest first across the resources, each one free of busy time and inside the resource's hours, to offer a person a choice. With require_free, only times when at least that many of the resources are free together, each slot naming them in resource_ids: every resource for a booking that needs them all (book it with slotlock_book_together), or any n of them. No slot is listed unless every resource's coverage is certain. Name the resources with resource_ids, or select them as slotlock_get_free_busy does: at most 20." | "List your events that meet a time window, earliest first." | "Move one of your events to a new time, and to another resource if you give one, at the revision you last read (expected_revision). A time already taken is 409 overlap with alternatives; a stale revision 409 revision_conflict. A retry answers the same move (replayed)." | "Read one of your events by its id, with its revision (pass it as expected_revision to change or cancel it)." | "Release one of your holds, and the rest of its group, at once instead of letting them expire. `released` is false when it was already gone." | "When each resource is busy in a window, as busy intervals only (never whose or what), and whether that is certain (coverage: \"uncertain\" where a calendar feeding it has not been read lately). Name the resources with resource_ids, or select them instead by location (and every location in it), group, labels (every one), kind or pool: at most 100 (more answers selection_too_large, none selection_empty), named in selected_resource_ids.";
|
|
599
619
|
annotations: {
|
|
600
|
-
title: "Book from a pool" | "Book resources together" | "Cancel an event" | "Confirm a held slot" | "Create resource event" | "Delete resource event" | "Find available slots" | "Find next available interval" | "Get free-busy" | "Get pool availability" | "Get resource event" | "Hold candidate slots" | "List calendar resources" | "List resource events" | "Release held slots" | "Reschedule an event" | "Update resource event";
|
|
620
|
+
title: "Book from a pool" | "Book resources together" | "Cancel an event" | "Confirm a held slot" | "Create resource event" | "Delete resource event" | "Find available ranges" | "Find available slots" | "Find next available interval" | "Get free-busy" | "Get pool availability" | "Get resource event" | "Hold candidate slots" | "Hold resources together" | "List calendar resources" | "List resource events" | "Release held slots" | "Reschedule an event" | "Update resource event";
|
|
601
621
|
readOnlyHint: boolean;
|
|
602
622
|
destructiveHint: boolean;
|
|
603
623
|
idempotentHint: true;
|
package/agent-server.js
CHANGED
|
@@ -61,6 +61,8 @@ export const SLOTLOCK_A2A_TASK_STATES = Object.freeze([
|
|
|
61
61
|
]);
|
|
62
62
|
const DATE_TIME = z.string().datetime({ offset: true });
|
|
63
63
|
const RESOURCE_ID = z.string().trim().min(1).max(200);
|
|
64
|
+
/** The longest resource search (resource-search.ts SLOTLOCK_RESOURCE_SEARCH_MAX; a test holds them equal). */
|
|
65
|
+
export const SLOTLOCK_AGENT_RESOURCE_SEARCH_MAX = 200;
|
|
64
66
|
const EVENT_ID = z.string().trim().min(1).max(500);
|
|
65
67
|
const HOLD_ID = z.string().uuid();
|
|
66
68
|
const POOL = z.string().regex(/^[a-z0-9][a-z0-9._-]{0,99}$/);
|
|
@@ -112,6 +114,18 @@ function bookPool(backend, context, input) {
|
|
|
112
114
|
throw new SlotlockAgentOperationError('pools_unavailable', 409);
|
|
113
115
|
return backend.bookPool(context, input);
|
|
114
116
|
}
|
|
117
|
+
/** `slotlock_hold_together`, from a backend that holds sets (RFC 0004 B7). */
|
|
118
|
+
function holdTogether(backend, context, input) {
|
|
119
|
+
if (!backend.holdTogether)
|
|
120
|
+
throw new SlotlockAgentOperationError('holds_unavailable', 409);
|
|
121
|
+
return backend.holdTogether(context, input);
|
|
122
|
+
}
|
|
123
|
+
/** `slotlock_find_available_ranges`, from a backend that searches hires (RFC 0004 B3). */
|
|
124
|
+
function findAvailableRanges(backend, context, input) {
|
|
125
|
+
if (!backend.findAvailableRanges)
|
|
126
|
+
throw new SlotlockAgentOperationError('ranges_unavailable', 409);
|
|
127
|
+
return backend.findAvailableRanges(context, input);
|
|
128
|
+
}
|
|
115
129
|
/** `slotlock_get_pool_availability`, from a backend that knows pools, as `holdSlots`. */
|
|
116
130
|
function getPoolAvailability(backend, context, input) {
|
|
117
131
|
if (!backend.getPoolAvailability)
|
|
@@ -226,6 +240,8 @@ const ALTERNATIVE_REFUSALS = new Set([
|
|
|
226
240
|
'booking_rule_max_advance',
|
|
227
241
|
'booking_rule_slot_step',
|
|
228
242
|
'booking_rule_outside_hours',
|
|
243
|
+
'booking_rule_pickup_time',
|
|
244
|
+
'booking_rule_return_time',
|
|
229
245
|
]);
|
|
230
246
|
/** At most this many alternatives, the earliest first. */
|
|
231
247
|
export const SLOTLOCK_MAX_ALTERNATIVES = 3;
|
|
@@ -263,7 +279,8 @@ async function alternativesFor(backend, context, want) {
|
|
|
263
279
|
const found = await findAvailableSlots(backend, { ...context, operation: 'slotlock_find_available_slots' }, {
|
|
264
280
|
resource_ids: resourceIds,
|
|
265
281
|
start: new Date(from).toISOString(),
|
|
266
|
-
|
|
282
|
+
// Starts over the next seven days, however long the booking runs.
|
|
283
|
+
end: new Date(from + ALTERNATIVES_WINDOW_MS + durationMs).toISOString(),
|
|
267
284
|
duration_minutes: durationMs / 60_000,
|
|
268
285
|
limit: SLOTLOCK_MAX_ALTERNATIVES,
|
|
269
286
|
...(want.needed !== undefined ? { require_free: Math.min(want.needed, resourceIds.length) } : {}),
|
|
@@ -542,7 +559,9 @@ const OPERATION_SCOPES = Object.freeze({
|
|
|
542
559
|
slotlock_get_free_busy: 'read',
|
|
543
560
|
slotlock_find_next_available: 'read',
|
|
544
561
|
slotlock_find_available_slots: 'read',
|
|
562
|
+
slotlock_find_available_ranges: 'read',
|
|
545
563
|
slotlock_hold_slots: 'write',
|
|
564
|
+
slotlock_hold_together: 'write',
|
|
546
565
|
slotlock_book_together: 'write',
|
|
547
566
|
slotlock_book_pool: 'write',
|
|
548
567
|
slotlock_get_pool_availability: 'read',
|
|
@@ -614,6 +633,14 @@ const SELECTING_OPERATIONS = new Map([
|
|
|
614
633
|
['slotlock_find_available_slots', 20],
|
|
615
634
|
]);
|
|
616
635
|
const SELECTION_FIELDS = ['location', 'group', 'labels', 'kind', 'pool'];
|
|
636
|
+
/** Searches that take the resources' usual length when they name none. */
|
|
637
|
+
const DEFAULT_LENGTH_OPERATIONS = new Set([
|
|
638
|
+
'slotlock_find_next_available',
|
|
639
|
+
'slotlock_find_available_slots',
|
|
640
|
+
]);
|
|
641
|
+
/** The most ranges one search answers. */
|
|
642
|
+
const MAX_LISTED_RANGES = 50;
|
|
643
|
+
const MAX_RANGE_RESOURCES = 100;
|
|
617
644
|
/** Whether a search's input names its resources, or selects them, and not both or neither. */
|
|
618
645
|
function selectsOrNames(input) {
|
|
619
646
|
const selects = SELECTION_FIELDS.some((field) => input[field] !== undefined);
|
|
@@ -656,6 +683,8 @@ const EVENT_CREATE_INPUT = z
|
|
|
656
683
|
recurrence_exceptions: z.array(RECURRENCE_EXCEPTION_INPUT).max(500).optional(),
|
|
657
684
|
/** Book the time one of your holds keeps (slotlock_hold_slots); its alternatives are released. */
|
|
658
685
|
hold_id: HOLD_ID.optional(),
|
|
686
|
+
/** False: no one should be notified about it, nor reminded (RFC 0004 P3). Default true. */
|
|
687
|
+
notify: z.boolean().optional(),
|
|
659
688
|
})
|
|
660
689
|
.strict()
|
|
661
690
|
.refine((value) => boundedEventSpan(value.starts_at, value.ends_at));
|
|
@@ -663,10 +692,11 @@ const OPERATION_DEFINITIONS = [
|
|
|
663
692
|
{
|
|
664
693
|
name: 'slotlock_list_resources',
|
|
665
694
|
title: 'List calendar resources',
|
|
666
|
-
description: 'List resources visible to the authenticated tenant principal, with what each says about itself: its name (show it rather than the reference), description, kind, capacity and labels, where it is (location), the groups it is in (a team, a department), and the pool it belongs to (a vehicle class, a room type). With kind, only resources of that kind; with labels, only those with every label given; with location, only those there or at a location in it; with group, only that group\'s. With pool, only that pool\'s members, in the order slotlock_book_pool tries them.',
|
|
695
|
+
description: 'List resources visible to the authenticated tenant principal, with what each says about itself: its name (show it rather than the reference), description, kind, capacity and labels, where it is (location), the groups it is in (a team, a department), and the pool it belongs to (a vehicle class, a room type). With kind, only resources of that kind; with labels, only those with every label given; with location, only those there or at a location in it; with group, only that group\'s. With pool, only that pool\'s members, in the order slotlock_book_pool tries them. With query, the resources that best match what a person calls one, best first, at most limit and with no next_cursor: each word is looked for in the name, reference, labels, kind, location and description, ignoring case and accents, and forgiving a typo, a word cut short or words run together ("mercedez", "merc 2025" and "mercedesbenz" find "Mercedes-Benz 2025"); numbers match exactly. It is the way to find the id of a resource a person names, since other tools take ids only. When several match about equally, ask which one is meant. A resource that states no kind, labels or location is left out by those filters, so search without them before saying a resource does not exist.',
|
|
667
696
|
input: z
|
|
668
697
|
.object({
|
|
669
698
|
cursor: z.string().max(500).optional(),
|
|
699
|
+
query: z.string().trim().min(1).max(SLOTLOCK_AGENT_RESOURCE_SEARCH_MAX).optional(),
|
|
670
700
|
limit: z.number().int().min(1).max(100).default(50),
|
|
671
701
|
pool: POOL.optional(),
|
|
672
702
|
kind: RESOURCE_KIND.optional(),
|
|
@@ -758,7 +788,8 @@ const OPERATION_DEFINITIONS = [
|
|
|
758
788
|
pool: POOL.optional(),
|
|
759
789
|
start: DATE_TIME,
|
|
760
790
|
end: DATE_TIME,
|
|
761
|
-
|
|
791
|
+
/** Left out: the resources' usual length (their booking rules' default_duration_minutes). */
|
|
792
|
+
duration_minutes: z.number().int().min(1).max(MAX_SLOT_MINUTES).optional(),
|
|
762
793
|
})
|
|
763
794
|
.strict()
|
|
764
795
|
.refine(selectsOrNames, 'Give resource_ids, or select resources by location, group, labels, kind or pool: one or the other')
|
|
@@ -791,7 +822,8 @@ const OPERATION_DEFINITIONS = [
|
|
|
791
822
|
pool: POOL.optional(),
|
|
792
823
|
start: DATE_TIME,
|
|
793
824
|
end: DATE_TIME,
|
|
794
|
-
|
|
825
|
+
/** Left out: the resources' usual length (their booking rules' default_duration_minutes). */
|
|
826
|
+
duration_minutes: z.number().int().min(1).max(MAX_SLOT_MINUTES).optional(),
|
|
795
827
|
limit: z.number().int().min(1).max(MAX_LISTED_SLOTS).default(10),
|
|
796
828
|
require_free: z.number().int().min(1).max(MAX_SLOT_RESOURCES).optional(),
|
|
797
829
|
})
|
|
@@ -819,6 +851,54 @@ const OPERATION_DEFINITIONS = [
|
|
|
819
851
|
invoke: findAvailableSlots,
|
|
820
852
|
example: { resource_ids: [EXAMPLE_RESOURCE_ID], ...EXAMPLE_WINDOW, duration_minutes: 60, limit: 10 },
|
|
821
853
|
},
|
|
854
|
+
{
|
|
855
|
+
name: 'slotlock_find_available_ranges',
|
|
856
|
+
title: 'Find available ranges',
|
|
857
|
+
description: 'Find hires across days, such as a car from Friday to Monday: for each resource named, or each member of a pool, the earliest range that starts between pickup_start and pickup_end and either lasts `days` days (ending at the same local time) or ends between return_start and return_end. Each range is free throughout with its buffers, allowed by the resource\'s booking rules (pick-up and return times, lengths, notice, step) and starts and ends inside its bookable hours. Up to `limit` ranges, earliest first, one per resource; none unless every resource\'s coverage is certain. Book one with slotlock_create_event, or a pool\'s with slotlock_book_pool, at its start and end.',
|
|
858
|
+
input: z
|
|
859
|
+
.object({
|
|
860
|
+
resource_ids: z.array(RESOURCE_ID).min(1).max(MAX_SLOT_RESOURCES).optional(),
|
|
861
|
+
pool: POOL.optional(),
|
|
862
|
+
pickup_start: DATE_TIME,
|
|
863
|
+
pickup_end: DATE_TIME,
|
|
864
|
+
days: z.number().int().min(1).max(366).optional(),
|
|
865
|
+
return_start: DATE_TIME.optional(),
|
|
866
|
+
return_end: DATE_TIME.optional(),
|
|
867
|
+
limit: z.number().int().min(1).max(MAX_LISTED_RANGES).default(10),
|
|
868
|
+
})
|
|
869
|
+
.strict()
|
|
870
|
+
.refine((value) => (value.resource_ids !== undefined) !== (value.pool !== undefined), 'Give resource_ids or pool, one of them')
|
|
871
|
+
.refine((value) => value.days !== undefined
|
|
872
|
+
? value.return_start === undefined && value.return_end === undefined
|
|
873
|
+
: value.return_start !== undefined && value.return_end !== undefined, 'Give days, or return_start and return_end')
|
|
874
|
+
.refine((value) => {
|
|
875
|
+
const pickupStart = Date.parse(value.pickup_start);
|
|
876
|
+
const pickupEnd = Date.parse(value.pickup_end);
|
|
877
|
+
const last = value.days !== undefined ? pickupEnd + (value.days + 1) * 86_400_000 : Date.parse(value.return_end ?? '');
|
|
878
|
+
const returnStart = value.return_start === undefined ? pickupStart : Date.parse(value.return_start);
|
|
879
|
+
return pickupEnd >= pickupStart && last >= returnStart && last - pickupStart <= MAX_AGENT_WINDOW_MS;
|
|
880
|
+
}, 'The windows must run forward, within the longest window a search reads'),
|
|
881
|
+
output: z
|
|
882
|
+
.object({
|
|
883
|
+
ranges: z
|
|
884
|
+
.array(z.object({ resource_id: RESOURCE_ID, start: DATE_TIME, end: DATE_TIME }).strict())
|
|
885
|
+
.max(MAX_LISTED_RANGES),
|
|
886
|
+
coverage: COVERAGE,
|
|
887
|
+
/** The pool's members searched, when the call named a pool. */
|
|
888
|
+
selected_resource_ids: z.array(RESOURCE_ID).max(MAX_RANGE_RESOURCES).optional(),
|
|
889
|
+
})
|
|
890
|
+
.strict(),
|
|
891
|
+
risk: { readOnly: true, destructive: false, idempotent: true },
|
|
892
|
+
invoke: findAvailableRanges,
|
|
893
|
+
example: {
|
|
894
|
+
pool: 'compact-car',
|
|
895
|
+
pickup_start: '2027-03-05T09:00:00Z',
|
|
896
|
+
pickup_end: '2027-03-05T12:00:00Z',
|
|
897
|
+
return_start: '2027-03-08T09:00:00Z',
|
|
898
|
+
return_end: '2027-03-08T12:00:00Z',
|
|
899
|
+
limit: 5,
|
|
900
|
+
},
|
|
901
|
+
},
|
|
822
902
|
{
|
|
823
903
|
name: 'slotlock_create_event',
|
|
824
904
|
title: 'Create resource event',
|
|
@@ -890,7 +970,7 @@ const OPERATION_DEFINITIONS = [
|
|
|
890
970
|
{
|
|
891
971
|
name: 'slotlock_book_together',
|
|
892
972
|
title: 'Book resources together',
|
|
893
|
-
description: 'Book the same time on 2-20 resources at once, all or none: a van with its driver, a room with its projector. If any of them is taken, nothing is booked, and the refusal names that resource_id. Find such a time with slotlock_find_available_slots and require_free. A retry with the same idempotency_key answers the same events.',
|
|
973
|
+
description: 'Book the same time on 2-20 resources at once, all or none: a van with its driver, a room with its projector. If any of them is taken, nothing is booked, and the refusal names that resource_id. Find such a time with slotlock_find_available_slots and require_free. With hold_group_id, it books the set slotlock_hold_together held, from those holds. A retry with the same idempotency_key answers the same events.',
|
|
894
974
|
input: z
|
|
895
975
|
.object({
|
|
896
976
|
resource_ids: z.array(RESOURCE_ID).min(2).max(MAX_SLOT_RESOURCES),
|
|
@@ -898,6 +978,8 @@ const OPERATION_DEFINITIONS = [
|
|
|
898
978
|
ends_at: DATE_TIME,
|
|
899
979
|
timezone: z.string().min(1).max(100),
|
|
900
980
|
title: z.string().trim().min(1).max(500).optional(),
|
|
981
|
+
/** The set slotlock_hold_together held, booked from its holds (RFC 0004 B7). */
|
|
982
|
+
hold_group_id: z.string().uuid().optional(),
|
|
901
983
|
idempotency_key: IDEMPOTENCY_KEY,
|
|
902
984
|
})
|
|
903
985
|
.strict()
|
|
@@ -1032,7 +1114,7 @@ const OPERATION_DEFINITIONS = [
|
|
|
1032
1114
|
{
|
|
1033
1115
|
name: 'slotlock_hold_slots',
|
|
1034
1116
|
title: 'Hold candidate slots',
|
|
1035
|
-
description: 'Hold up to five candidate slots for a short time (30-900 seconds) while a person chooses, as alternatives in one group: each free one is held, the others are listed with why not. Book one by passing its hold_id to slotlock_create_event, which releases the rest; unbooked holds expire on their own. A hold is yours alone, and you may have 20 live at once. A candidate refused for its time comes with alternatives (the earliest free times of its length). slotlock_confirm_slot books a held time.',
|
|
1117
|
+
description: 'Hold up to five candidate slots for a short time (30-900 seconds) while a person chooses, as alternatives in one group: each free one is held, the others are listed with why not. Book one by passing its hold_id to slotlock_create_event, which releases the rest; unbooked holds expire on their own. With payment_required, a hold is booked only once the system you book for records its payment (the person pays there): booking it before then answers payment_required, so send the person to pay and try again. A hold is yours alone, and you may have 20 live at once. A candidate refused for its time comes with alternatives (the earliest free times of its length). slotlock_confirm_slot books a held time.',
|
|
1036
1118
|
input: z
|
|
1037
1119
|
.object({
|
|
1038
1120
|
slots: z
|
|
@@ -1044,8 +1126,19 @@ const OPERATION_DEFINITIONS = [
|
|
|
1044
1126
|
.max(5),
|
|
1045
1127
|
ttl_seconds: z.number().int().min(30).max(900).default(300),
|
|
1046
1128
|
idempotency_key: IDEMPOTENCY_KEY,
|
|
1129
|
+
/** Booked only once the integrator records its payment (RFC 0004 C3). */
|
|
1130
|
+
payment_required: z.boolean().optional(),
|
|
1131
|
+
/** What that payment must be, in the currency's minor unit. */
|
|
1132
|
+
payment: z
|
|
1133
|
+
.object({ amount_minor: z.number().int().min(0).max(1_000_000_000_000), currency: z.string().regex(/^[A-Z]{3}$/) })
|
|
1134
|
+
.strict()
|
|
1135
|
+
.optional(),
|
|
1047
1136
|
})
|
|
1048
|
-
.strict()
|
|
1137
|
+
.strict()
|
|
1138
|
+
.refine((value) => value.payment === undefined || value.payment_required === true, {
|
|
1139
|
+
message: 'payment needs payment_required',
|
|
1140
|
+
path: ['payment'],
|
|
1141
|
+
}),
|
|
1049
1142
|
output: z
|
|
1050
1143
|
.object({
|
|
1051
1144
|
group_id: HOLD_ID,
|
|
@@ -1057,6 +1150,12 @@ const OPERATION_DEFINITIONS = [
|
|
|
1057
1150
|
start: DATE_TIME,
|
|
1058
1151
|
end: DATE_TIME,
|
|
1059
1152
|
expires_at: DATE_TIME,
|
|
1153
|
+
payment_required: z.literal(true).optional(),
|
|
1154
|
+
payment_amount: z
|
|
1155
|
+
.object({ amount_minor: z.number().int().min(0), currency: z.string().regex(/^[A-Z]{3}$/) })
|
|
1156
|
+
.strict()
|
|
1157
|
+
.nullable()
|
|
1158
|
+
.optional(),
|
|
1060
1159
|
})
|
|
1061
1160
|
.strict())
|
|
1062
1161
|
.max(5),
|
|
@@ -1083,6 +1182,42 @@ const OPERATION_DEFINITIONS = [
|
|
|
1083
1182
|
idempotency_key: 'hold-handover-2027-03-02',
|
|
1084
1183
|
},
|
|
1085
1184
|
},
|
|
1185
|
+
{
|
|
1186
|
+
name: 'slotlock_hold_together',
|
|
1187
|
+
title: 'Hold resources together',
|
|
1188
|
+
description: 'Hold one time on 2-20 resources at once, all or none, such as a car with its driver while a person decides: if any is taken, nothing is held and the refusal names that resource_id. The holds are one group with one time to live (ttl_seconds, the earliest of them). Book the set with slotlock_book_together and its hold_group_id; release them with slotlock_release_slots and any hold_id. A retry with the same idempotency_key answers the same holds.',
|
|
1189
|
+
input: z
|
|
1190
|
+
.object({
|
|
1191
|
+
resource_ids: z.array(RESOURCE_ID).min(2).max(MAX_SLOT_RESOURCES),
|
|
1192
|
+
start: DATE_TIME,
|
|
1193
|
+
end: DATE_TIME,
|
|
1194
|
+
ttl_seconds: z.number().int().min(30).max(900).default(300),
|
|
1195
|
+
idempotency_key: IDEMPOTENCY_KEY,
|
|
1196
|
+
})
|
|
1197
|
+
.strict()
|
|
1198
|
+
.refine(boundedAgentWindow),
|
|
1199
|
+
output: z
|
|
1200
|
+
.object({
|
|
1201
|
+
group_id: z.string().uuid(),
|
|
1202
|
+
holds: z
|
|
1203
|
+
.array(z
|
|
1204
|
+
.object({ hold_id: z.string().uuid(), resource_id: RESOURCE_ID, start: DATE_TIME, end: DATE_TIME, expires_at: DATE_TIME })
|
|
1205
|
+
.strict())
|
|
1206
|
+
.min(2)
|
|
1207
|
+
.max(MAX_SLOT_RESOURCES),
|
|
1208
|
+
expires_at: DATE_TIME,
|
|
1209
|
+
})
|
|
1210
|
+
.strict(),
|
|
1211
|
+
risk: { readOnly: false, destructive: false, idempotent: true },
|
|
1212
|
+
invoke: holdTogether,
|
|
1213
|
+
example: {
|
|
1214
|
+
resource_ids: [EXAMPLE_RESOURCE_ID, '3c2d1e0f-9a8b-4c7d-8e6f-5a4b3c2d1e0f'],
|
|
1215
|
+
start: '2027-03-02T09:00:00Z',
|
|
1216
|
+
end: '2027-03-02T17:00:00Z',
|
|
1217
|
+
ttl_seconds: 300,
|
|
1218
|
+
idempotency_key: 'hold-van-and-driver-1',
|
|
1219
|
+
},
|
|
1220
|
+
},
|
|
1086
1221
|
{
|
|
1087
1222
|
name: 'slotlock_release_slots',
|
|
1088
1223
|
title: 'Release held slots',
|
|
@@ -1633,6 +1768,15 @@ export async function invokeSlotlockAgentOperation(args) {
|
|
|
1633
1768
|
input = Object.fromEntries(Object.entries(input).filter(([key]) => !SELECTION_FIELDS.includes(key)));
|
|
1634
1769
|
input.resource_ids = selected;
|
|
1635
1770
|
}
|
|
1771
|
+
// A search that names no length takes the resources' usual one (RFC 0004 B3).
|
|
1772
|
+
if (DEFAULT_LENGTH_OPERATIONS.has(operation.name) && input.duration_minutes === undefined) {
|
|
1773
|
+
const usual = backend.defaultDuration
|
|
1774
|
+
? await backend.defaultDuration(context, (input.resource_ids ?? []))
|
|
1775
|
+
: null;
|
|
1776
|
+
if (usual === null)
|
|
1777
|
+
return { ok: false, status: 400, code: 'duration_required' };
|
|
1778
|
+
input = { ...input, duration_minutes: usual };
|
|
1779
|
+
}
|
|
1636
1780
|
const asked = input;
|
|
1637
1781
|
recover = (code) => alternativesForRefusal(backend, context, operation.name, asked, code);
|
|
1638
1782
|
let answered = await method.call(backend, context, input);
|
|
@@ -1990,6 +2134,7 @@ function parseSubscriptionFilter(value) {
|
|
|
1990
2134
|
/** Natural-language guidance `server/discover` gives the model on how to use these tools well. */
|
|
1991
2135
|
const SLOTLOCK_MCP_INSTRUCTIONS = [
|
|
1992
2136
|
'Slotlock is a tenant-isolated resource calendar (vehicles, rooms, equipment).',
|
|
2137
|
+
'A resource is named in calls by its id (resource_id, resource_ids), never by its name or reference: find the id of one a person names with slotlock_list_resources and query (what they call it; typos and accents are forgiven). resource_not_found for something that is not an id names, in candidate_1 to candidate_3, the resources that best match it. A kind, label or location filter leaves out resources that do not state it, so search without one before saying a resource does not exist, and ask the person when several match equally.',
|
|
1993
2138
|
'Check availability before booking: slotlock_get_free_busy returns busy intervals and slotlock_find_next_available the earliest free slot of a given length, and slotlock_find_available_slots up to 50 of them to offer a person a choice; an answer is certain only when coverage.certainty is "certain".',
|
|
1994
2139
|
'slotlock_create_event needs an idempotency_key: retry with the same key and arguments, never a new key for the same booking. slotlock_update_event and slotlock_delete_event need the event revision you last read as expected_revision.',
|
|
1995
2140
|
'Times are RFC 3339 with an offset; intervals are half-open [start, end).',
|
package/agent-store-backend.d.ts
CHANGED
|
@@ -39,7 +39,6 @@ export declare function slotlockAgentErrorStatus(code: string): 400 | 404 | 409
|
|
|
39
39
|
* store's client, used for the feed's head.
|
|
40
40
|
*/
|
|
41
41
|
export declare function createSlotlockStoreChangeSource(store: SlotlockStore, sql: Sql): SlotlockAgentChangeSource;
|
|
42
|
-
/** Bind the protocol server to the production store without giving a request control of tenancy. */
|
|
43
42
|
export declare function createSlotlockStoreAgentBackend(store: SlotlockStore, options: SlotlockStoreAgentBackendOptions): SlotlockAgentCalendarBackend;
|
|
44
43
|
export interface SlotlockStoreApplicationBackendOptions {
|
|
45
44
|
/**
|
|
@@ -66,6 +65,12 @@ export interface SlotlockStoreApplicationBackendOptions {
|
|
|
66
65
|
* approvals_unavailable; declining still works.
|
|
67
66
|
*/
|
|
68
67
|
agentBackend?: SlotlockAgentCalendarBackend;
|
|
68
|
+
/**
|
|
69
|
+
* How `slotlock_import_holidays` fetches a calendar's url (RFC 0004 B1): give it a fetch that
|
|
70
|
+
* refuses private addresses (`createSlotlockGuardedFetch`). Unset: a url answers 400
|
|
71
|
+
* holiday_url_not_served and only the calendar's text is read.
|
|
72
|
+
*/
|
|
73
|
+
holidayFetch?: typeof fetch;
|
|
69
74
|
}
|
|
70
75
|
/**
|
|
71
76
|
* Run a granted approval's write as the agent that asked for it, in one transaction with the grant:
|