slotlock 0.8.0 → 0.9.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 +237 -0
- 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 +861 -55
- package/application-api.d.ts +19 -1
- package/application-api.js +578 -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 +558 -0
- package/booking-rules.d.ts +92 -5
- package/booking-rules.js +243 -15
- package/dashboard-assistant-ui.d.ts +1 -1
- package/dashboard-assistant-ui.js +109 -8
- package/dashboard.d.ts +55 -6
- package/dashboard.js +790 -105
- package/ddl.d.ts +5 -1
- package/ddl.js +760 -9
- package/event-feed.d.ts +1 -1
- package/event-feed.js +18 -0
- package/holiday-import.d.ts +38 -0
- package/holiday-import.js +57 -0
- package/holidays.d.ts +38 -0
- package/holidays.js +150 -0
- package/hours.d.ts +125 -0
- package/hours.js +224 -0
- package/index.d.ts +14 -3
- package/index.js +10 -2
- package/interop.d.ts +25 -7
- package/interop.js +145 -17
- package/lifecycle.d.ts +31 -0
- package/lifecycle.js +115 -0
- package/maintenance.d.ts +19 -0
- package/maintenance.js +55 -1
- package/metrics.js +3 -0
- package/openapi.json +16237 -7795
- package/package.json +1 -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/self-host.d.ts +1 -1
- package/self-host.js +221 -21
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +367 -24
- package/store.js +1567 -97
- package/types.d.ts +198 -0
package/package.json
CHANGED
package/ranges.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type SlotlockBookingRules } from './booking-rules.js';
|
|
2
|
+
import { type SlotlockRankedException } from './hours.js';
|
|
3
|
+
import type { Interval, WeeklyAvailabilityRule } from './types.js';
|
|
4
|
+
/** Candidates one resource's search judges before it gives up. */
|
|
5
|
+
export declare const SLOTLOCK_MAX_RANGE_CANDIDATES = 5000;
|
|
6
|
+
export interface SlotlockRangeSearch {
|
|
7
|
+
/** Occupied time on the resource (other bookings with their buffers). */
|
|
8
|
+
busy: readonly Interval[];
|
|
9
|
+
rules: SlotlockBookingRules | undefined;
|
|
10
|
+
timezone: string;
|
|
11
|
+
hours: readonly WeeklyAvailabilityRule[];
|
|
12
|
+
exceptions?: readonly SlotlockRankedException[];
|
|
13
|
+
now: Date;
|
|
14
|
+
/** The start lies from `pickup.start` to `pickup.end`, both included. */
|
|
15
|
+
pickup: Interval;
|
|
16
|
+
/** The end lies from `return.start` to `return.end`, both included (or give `days`). */
|
|
17
|
+
return?: Interval;
|
|
18
|
+
/** The end is the start's local time this many days later. */
|
|
19
|
+
days?: number;
|
|
20
|
+
}
|
|
21
|
+
/** The instant `days` local days after `instant`, at the same wall time (a clock change moves it). */
|
|
22
|
+
export declare function sameTimeDaysLater(instant: Date, days: number, timezone: string): Date;
|
|
23
|
+
/**
|
|
24
|
+
* The earliest range the search allows, or null. Search honours hours always, as slot search does:
|
|
25
|
+
* a range from one local day to another starts and ends inside the hours, one within a day lies
|
|
26
|
+
* inside them. Bounded by SLOTLOCK_MAX_RANGE_CANDIDATES judged candidates.
|
|
27
|
+
*/
|
|
28
|
+
export declare function findEarliestRange(search: SlotlockRangeSearch): Interval | null;
|
package/ranges.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Hires across days (RFC 0004 B3): the earliest range on one resource that starts in a pick-up
|
|
2
|
+
// window and ends a number of local days later, or in a return window, free throughout with its
|
|
3
|
+
// buffers and allowed by the resource's booking rules. Pure: the caller reads the busy time, the
|
|
4
|
+
// hours and the exceptions; the booking rules' own judge decides each candidate.
|
|
5
|
+
import { bookingBufferAfterMs, bookingBufferBeforeMs, bookingRuleBroken, hoursApplyOf, isMultiDay, nextStepStart, withinDayWindows, } from './booking-rules.js';
|
|
6
|
+
import { mergeIntervals } from './engine.js';
|
|
7
|
+
import { openWindows } from './hours.js';
|
|
8
|
+
import { createTimeZoneConverter, zonedOffsetMinutes } from './timezone.js';
|
|
9
|
+
const MINUTE_MS = 60_000;
|
|
10
|
+
const DAY_MS = 86_400_000;
|
|
11
|
+
/** Candidates one resource's search judges before it gives up. */
|
|
12
|
+
export const SLOTLOCK_MAX_RANGE_CANDIDATES = 5_000;
|
|
13
|
+
/** The instant `days` local days after `instant`, at the same wall time (a clock change moves it). */
|
|
14
|
+
export function sameTimeDaysLater(instant, days, timezone) {
|
|
15
|
+
const offset = zonedOffsetMinutes(instant, timezone) * MINUTE_MS;
|
|
16
|
+
return createTimeZoneConverter(timezone).wallFrameToUtc(instant.getTime() + offset + days * DAY_MS);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* The earliest range the search allows, or null. Search honours hours always, as slot search does:
|
|
20
|
+
* a range from one local day to another starts and ends inside the hours, one within a day lies
|
|
21
|
+
* inside them. Bounded by SLOTLOCK_MAX_RANGE_CANDIDATES judged candidates.
|
|
22
|
+
*/
|
|
23
|
+
export function findEarliestRange(search) {
|
|
24
|
+
const { timezone, now } = search;
|
|
25
|
+
// Judged as a write is, inside the hours always: as the write is held to them where it is
|
|
26
|
+
// (enforceHours), else at a hire's start and end, what a hire needs.
|
|
27
|
+
const rules = {
|
|
28
|
+
...(search.rules ?? {}),
|
|
29
|
+
enforceHours: true,
|
|
30
|
+
hoursApply: search.rules?.hoursApply ?? (search.rules?.enforceHours === true ? hoursApplyOf(search.rules) : 'ends'),
|
|
31
|
+
};
|
|
32
|
+
const before = bookingBufferBeforeMs(rules);
|
|
33
|
+
const after = bookingBufferAfterMs(rules);
|
|
34
|
+
const busy = mergeIntervals([...search.busy]);
|
|
35
|
+
const lastEnd = search.return
|
|
36
|
+
? search.return.end.getTime()
|
|
37
|
+
: sameTimeDaysLater(search.pickup.end, search.days ?? 1, timezone).getTime() + DAY_MS;
|
|
38
|
+
const open = openWindows({
|
|
39
|
+
hours: search.hours,
|
|
40
|
+
...(search.exceptions ? { exceptions: search.exceptions } : {}),
|
|
41
|
+
timezone,
|
|
42
|
+
window: { start: new Date(search.pickup.start.getTime() - DAY_MS), end: new Date(lastEnd + DAY_MS) },
|
|
43
|
+
});
|
|
44
|
+
const isOpen = (instant, closed = false) => open.some((part) => part.start.getTime() <= instant && (closed ? instant <= part.end.getTime() : instant < part.end.getTime()));
|
|
45
|
+
const nextOpen = (instant) => open.find((part) => part.end.getTime() > instant)?.start.getTime() ?? null;
|
|
46
|
+
const step = rules.slotStepMinutes;
|
|
47
|
+
const offset = step === undefined ? 0 : (rules.stepOffsetMinutes ?? 0) % step;
|
|
48
|
+
const align = (instant) => step === undefined
|
|
49
|
+
? Math.ceil(instant / MINUTE_MS) * MINUTE_MS
|
|
50
|
+
: nextStepStart(new Date(instant), step, timezone, offset).getTime();
|
|
51
|
+
const earliest = Math.max(search.pickup.start.getTime(), now.getTime() + (rules.minNoticeMinutes ?? 0) * MINUTE_MS);
|
|
52
|
+
const latest = Math.min(search.pickup.end.getTime(), rules.maxAdvanceDays === undefined ? Number.POSITIVE_INFINITY : now.getTime() + rules.maxAdvanceDays * DAY_MS);
|
|
53
|
+
const blocker = (start, end) => busy.find((taken) => taken.start.getTime() < end + after && taken.end.getTime() > start - before);
|
|
54
|
+
/** The earliest end in the return window a range from `start` may have, or null. */
|
|
55
|
+
const endFor = (start) => {
|
|
56
|
+
if (search.days !== undefined)
|
|
57
|
+
return sameTimeDaysLater(new Date(start), search.days, timezone).getTime();
|
|
58
|
+
const window = search.return;
|
|
59
|
+
const lengths = rules.allowedDurations;
|
|
60
|
+
if (lengths) {
|
|
61
|
+
for (const minutes of lengths) {
|
|
62
|
+
const end = start + minutes * MINUTE_MS;
|
|
63
|
+
if (end >= window.start.getTime() && end <= window.end.getTime() && returnable(start, end))
|
|
64
|
+
return end;
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
let end = Math.max(window.start.getTime(), start + Math.max(1, rules.minDurationMinutes ?? 1) * MINUTE_MS);
|
|
69
|
+
end = Math.ceil(end / MINUTE_MS) * MINUTE_MS;
|
|
70
|
+
for (let tries = 0; end <= window.end.getTime() && tries < 3 * 1_440; tries++) {
|
|
71
|
+
if (rules.maxDurationMinutes !== undefined && end - start > rules.maxDurationMinutes * MINUTE_MS)
|
|
72
|
+
return null;
|
|
73
|
+
if (returnable(start, end))
|
|
74
|
+
return end;
|
|
75
|
+
// On to the next return time or opening, else the next minute.
|
|
76
|
+
const reopening = isOpen(end, true) ? null : nextOpen(end);
|
|
77
|
+
end = reopening !== null && reopening > end ? reopening : end + MINUTE_MS;
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
};
|
|
81
|
+
/** Whether a range may end at `end`: inside the hours at its end, and in a return time for a hire. */
|
|
82
|
+
const returnable = (start, end) => isOpen(end, true) &&
|
|
83
|
+
(!rules.returnTimes ||
|
|
84
|
+
!isMultiDay({ start: new Date(start), end: new Date(end) }, timezone) ||
|
|
85
|
+
withinDayWindows(new Date(end), rules.returnTimes, timezone, true));
|
|
86
|
+
/** The next start of a pick-up time at or after `instant`, by the local clock. */
|
|
87
|
+
const nextPickup = (instant) => {
|
|
88
|
+
const windows = rules.pickupTimes ?? [];
|
|
89
|
+
const local = ((((instant + zonedOffsetMinutes(new Date(instant), timezone) * MINUTE_MS) / MINUTE_MS) % 1_440) + 1_440) % 1_440;
|
|
90
|
+
const ahead = Math.min(...windows.map((window) => (((window.startMinutes - local) % 1_440) + 1_440) % 1_440));
|
|
91
|
+
return instant + Math.max(1, Math.round(ahead)) * MINUTE_MS;
|
|
92
|
+
};
|
|
93
|
+
let candidate = align(earliest);
|
|
94
|
+
for (let judged = 0; candidate <= latest && judged < SLOTLOCK_MAX_RANGE_CANDIDATES; judged++) {
|
|
95
|
+
// Start inside the hours, and inside a pick-up time.
|
|
96
|
+
if (!isOpen(candidate)) {
|
|
97
|
+
const reopening = nextOpen(candidate);
|
|
98
|
+
if (reopening === null)
|
|
99
|
+
return null;
|
|
100
|
+
candidate = align(Math.max(reopening, candidate + 1));
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const end = endFor(candidate);
|
|
104
|
+
if (end !== null &&
|
|
105
|
+
rules.pickupTimes &&
|
|
106
|
+
isMultiDay({ start: new Date(candidate), end: new Date(end) }, timezone) &&
|
|
107
|
+
!withinDayWindows(new Date(candidate), rules.pickupTimes, timezone)) {
|
|
108
|
+
candidate = align(nextPickup(candidate));
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (end !== null && end > candidate) {
|
|
112
|
+
const taken = blocker(candidate, end);
|
|
113
|
+
if (taken) {
|
|
114
|
+
candidate = align(Math.max(taken.end.getTime() + before, candidate + 1));
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const range = { start: new Date(candidate), end: new Date(end) };
|
|
118
|
+
if (bookingRuleBroken({
|
|
119
|
+
rules,
|
|
120
|
+
timezone,
|
|
121
|
+
hours: search.hours,
|
|
122
|
+
...(search.exceptions ? { exceptions: search.exceptions } : {}),
|
|
123
|
+
interval: range,
|
|
124
|
+
now,
|
|
125
|
+
}) === null) {
|
|
126
|
+
return range;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
else if (search.return && candidate + (rules.minDurationMinutes ?? 0) * MINUTE_MS > search.return.end.getTime()) {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
candidate = align(candidate + MINUTE_MS);
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { SlotlockResource } from './types.js';
|
|
2
|
+
/** The longest search, and the most words one may have once read. */
|
|
3
|
+
export declare const SLOTLOCK_RESOURCE_SEARCH_MAX = 200;
|
|
4
|
+
/** What a search looks at: a resource as listResources gives it. */
|
|
5
|
+
export type SlotlockSearchableResource = Pick<SlotlockResource, 'id' | 'externalRef' | 'name' | 'description' | 'kind' | 'labels' | 'locationName' | 'location'>;
|
|
6
|
+
/**
|
|
7
|
+
* A search's words, or `invalid_query` when it is empty, longer than 200 characters or more than 12
|
|
8
|
+
* words. Filler ("the", "my") is dropped when other words remain.
|
|
9
|
+
*/
|
|
10
|
+
export declare function slotlockResourceSearchWords(query: string): string[];
|
|
11
|
+
/**
|
|
12
|
+
* The resources that match a search's words, best first, each with its score (0 to 1). A resource is
|
|
13
|
+
* kept when at least half of the words find it somewhere; one that every word names exactly scores 1.
|
|
14
|
+
* Ties keep the order the resources were given in.
|
|
15
|
+
*/
|
|
16
|
+
export declare function slotlockSearchResources<T extends SlotlockSearchableResource>(resources: readonly T[], words: readonly string[]): {
|
|
17
|
+
resource: T;
|
|
18
|
+
score: number;
|
|
19
|
+
}[];
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/** The longest search, and the most words one may have once read. */
|
|
2
|
+
export const SLOTLOCK_RESOURCE_SEARCH_MAX = 200;
|
|
3
|
+
const MAX_WORDS = 12;
|
|
4
|
+
/** Words that say nothing about which resource is meant, when others do. */
|
|
5
|
+
const FILLER = new Set(['the', 'a', 'an', 'my', 'our', 'your', 'of', 'for', 'at', 'in', 'on', 'to', 'and', 'with']);
|
|
6
|
+
/** Lower case, accents and other marks taken off: "Zürich" reads as "zurich". */
|
|
7
|
+
const fold = (text) => text.normalize('NFKD').replace(/\p{M}+/gu, '').toLowerCase();
|
|
8
|
+
const wordsOf = (text) => fold(text).split(/[^\p{L}\p{N}]+/u).filter((word) => word !== '');
|
|
9
|
+
/**
|
|
10
|
+
* A search's words, or `invalid_query` when it is empty, longer than 200 characters or more than 12
|
|
11
|
+
* words. Filler ("the", "my") is dropped when other words remain.
|
|
12
|
+
*/
|
|
13
|
+
export function slotlockResourceSearchWords(query) {
|
|
14
|
+
const trimmed = typeof query === 'string' ? query.trim() : '';
|
|
15
|
+
if (trimmed === '' || [...trimmed].length > SLOTLOCK_RESOURCE_SEARCH_MAX)
|
|
16
|
+
throw invalidQuery();
|
|
17
|
+
// Counted as written: repeating a word does not make room for more.
|
|
18
|
+
const written = wordsOf(trimmed);
|
|
19
|
+
if (written.length > MAX_WORDS)
|
|
20
|
+
throw invalidQuery();
|
|
21
|
+
const words = [...new Set(written)];
|
|
22
|
+
const meaningful = words.filter((word) => !FILLER.has(word));
|
|
23
|
+
// Only symbols ("%", "#"): looked for as written.
|
|
24
|
+
if (words.length === 0)
|
|
25
|
+
return [fold(trimmed)];
|
|
26
|
+
return meaningful.length > 0 ? meaningful : words;
|
|
27
|
+
}
|
|
28
|
+
function invalidQuery() {
|
|
29
|
+
return Object.assign(new Error(`Slotlock resource search must be 1-${String(SLOTLOCK_RESOURCE_SEARCH_MAX)} characters of at most ${String(MAX_WORDS)} words`), { code: 'invalid_query' });
|
|
30
|
+
}
|
|
31
|
+
/** Edits between two words (a swap of neighbours is one), or more than `most` once past it. */
|
|
32
|
+
function distance(a, b, most) {
|
|
33
|
+
if (Math.abs(a.length - b.length) > most)
|
|
34
|
+
return most + 1;
|
|
35
|
+
let before = [];
|
|
36
|
+
let previous = Array.from({ length: b.length + 1 }, (_, index) => index);
|
|
37
|
+
for (let i = 1; i <= a.length; i += 1) {
|
|
38
|
+
const current = [i];
|
|
39
|
+
let smallest = i;
|
|
40
|
+
for (let j = 1; j <= b.length; j += 1) {
|
|
41
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
42
|
+
let value = Math.min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + cost);
|
|
43
|
+
if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
|
|
44
|
+
value = Math.min(value, before[j - 2] + 1);
|
|
45
|
+
}
|
|
46
|
+
current[j] = value;
|
|
47
|
+
smallest = Math.min(smallest, value);
|
|
48
|
+
}
|
|
49
|
+
if (smallest > most)
|
|
50
|
+
return most + 1;
|
|
51
|
+
before = previous;
|
|
52
|
+
previous = current;
|
|
53
|
+
}
|
|
54
|
+
return previous[b.length];
|
|
55
|
+
}
|
|
56
|
+
const DIGITS = /^\p{N}+$/u;
|
|
57
|
+
/**
|
|
58
|
+
* The most edit distances one search computes: past it, matching is exact, by prefix and by words
|
|
59
|
+
* run together only. A tenant's resources (up to 5,000, each with a long description) cannot make
|
|
60
|
+
* one search cost seconds.
|
|
61
|
+
*/
|
|
62
|
+
const FUZZY_BUDGET = 20_000;
|
|
63
|
+
/** A description is looked through by its first words only. */
|
|
64
|
+
const DESCRIPTION_WORDS = 60;
|
|
65
|
+
/**
|
|
66
|
+
* How well one word of a search names a field (0 to 1), given the field's words and those run
|
|
67
|
+
* together. Typos are forgiven only where `fuzzy`, and only while `budget` lasts.
|
|
68
|
+
*/
|
|
69
|
+
function wordScore(word, field, fuzzy, budget) {
|
|
70
|
+
let best = 0;
|
|
71
|
+
for (const candidate of field.words) {
|
|
72
|
+
if (candidate === word)
|
|
73
|
+
return 1;
|
|
74
|
+
if (word.length >= 2 && candidate.startsWith(word))
|
|
75
|
+
best = Math.max(best, 0.85);
|
|
76
|
+
}
|
|
77
|
+
// "mercedesbenz" for "Mercedes-Benz", "car17" for "car-17".
|
|
78
|
+
if (word.length >= 3 && field.joined.includes(word))
|
|
79
|
+
best = Math.max(best, 0.75);
|
|
80
|
+
if (fuzzy && best < 0.7 && !DIGITS.test(word) && word.length >= 4 && word.length <= 24) {
|
|
81
|
+
const most = word.length >= 8 ? 2 : 1;
|
|
82
|
+
for (const candidate of field.words) {
|
|
83
|
+
if (DIGITS.test(candidate) || candidate.length > 32)
|
|
84
|
+
continue;
|
|
85
|
+
if (budget.left <= 0)
|
|
86
|
+
break;
|
|
87
|
+
budget.left -= 2;
|
|
88
|
+
const edits = distance(word, candidate, most);
|
|
89
|
+
if (edits <= most)
|
|
90
|
+
best = Math.max(best, edits === 1 ? 0.7 : 0.6);
|
|
91
|
+
// A typo in a word cut short: "merced" for "mercedes" is a prefix; "mercad" is one edit off one.
|
|
92
|
+
if (candidate.length > word.length && distance(word, candidate.slice(0, word.length), 1) <= 1) {
|
|
93
|
+
best = Math.max(best, 0.6);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return best;
|
|
98
|
+
}
|
|
99
|
+
/** How much a match in each field counts (a name most, a description least), and whether typos are forgiven there. */
|
|
100
|
+
const FIELDS = [
|
|
101
|
+
['name', 1, true],
|
|
102
|
+
['ref', 0.95, true],
|
|
103
|
+
['labels', 0.7, true],
|
|
104
|
+
['kind', 0.6, true],
|
|
105
|
+
['location', 0.6, true],
|
|
106
|
+
['description', 0.4, false],
|
|
107
|
+
];
|
|
108
|
+
const indexed = (text, most = Number.POSITIVE_INFINITY) => {
|
|
109
|
+
const words = text ? wordsOf(text).slice(0, most) : [];
|
|
110
|
+
return { words, joined: words.join('') };
|
|
111
|
+
};
|
|
112
|
+
/**
|
|
113
|
+
* The resources that match a search's words, best first, each with its score (0 to 1). A resource is
|
|
114
|
+
* kept when at least half of the words find it somewhere; one that every word names exactly scores 1.
|
|
115
|
+
* Ties keep the order the resources were given in.
|
|
116
|
+
*/
|
|
117
|
+
export function slotlockSearchResources(resources, words) {
|
|
118
|
+
if (words.length === 0)
|
|
119
|
+
return [];
|
|
120
|
+
const budget = { left: FUZZY_BUDGET };
|
|
121
|
+
const scored = [];
|
|
122
|
+
resources.forEach((resource, at) => {
|
|
123
|
+
const fields = {
|
|
124
|
+
name: indexed(resource.name),
|
|
125
|
+
ref: indexed(resource.externalRef),
|
|
126
|
+
labels: indexed(resource.labels?.join(' ')),
|
|
127
|
+
kind: indexed(resource.kind),
|
|
128
|
+
location: indexed(resource.locationName ?? resource.location),
|
|
129
|
+
description: indexed(resource.description, DESCRIPTION_WORDS),
|
|
130
|
+
};
|
|
131
|
+
let total = 0;
|
|
132
|
+
let found = 0;
|
|
133
|
+
for (const word of words) {
|
|
134
|
+
let best = 0;
|
|
135
|
+
for (const [field, weight, fuzzy] of FIELDS)
|
|
136
|
+
best = Math.max(best, weight * wordScore(word, fields[field], fuzzy, budget));
|
|
137
|
+
// A search of symbols only ("%"): looked for as written in the name and reference.
|
|
138
|
+
if (best === 0 && !/[\p{L}\p{N}]/u.test(word)) {
|
|
139
|
+
if (fold(`${resource.name ?? ''} ${resource.externalRef ?? ''}`).includes(word))
|
|
140
|
+
best = 1;
|
|
141
|
+
}
|
|
142
|
+
total += best;
|
|
143
|
+
if (best >= 0.3)
|
|
144
|
+
found += 1;
|
|
145
|
+
}
|
|
146
|
+
const score = total / words.length;
|
|
147
|
+
if (found * 2 >= words.length && score >= 0.3)
|
|
148
|
+
scored.push({ resource, score, at });
|
|
149
|
+
});
|
|
150
|
+
return scored
|
|
151
|
+
.sort((a, b) => b.score - a.score || a.at - b.at)
|
|
152
|
+
.map(({ resource, score }) => ({ resource, score: Math.round(score * 100) / 100 }));
|
|
153
|
+
}
|
package/rest-api.js
CHANGED
|
@@ -14,7 +14,14 @@ export const SLOTLOCK_REST_ROUTES = Object.freeze([
|
|
|
14
14
|
operation: 'slotlock_find_available_slots',
|
|
15
15
|
refusals: [404],
|
|
16
16
|
},
|
|
17
|
+
{
|
|
18
|
+
method: 'GET',
|
|
19
|
+
path: '/v1/available-ranges',
|
|
20
|
+
operation: 'slotlock_find_available_ranges',
|
|
21
|
+
refusals: [404, 409],
|
|
22
|
+
},
|
|
17
23
|
{ method: 'POST', path: '/v1/slot-holds', operation: 'slotlock_hold_slots', refusals: [409] },
|
|
24
|
+
{ method: 'POST', path: '/v1/slot-holds/together', operation: 'slotlock_hold_together', refusals: [404, 409] },
|
|
18
25
|
{ method: 'POST', path: '/v1/bundles', operation: 'slotlock_book_together', refusals: [404, 409] },
|
|
19
26
|
{ method: 'POST', path: '/v1/pool-bookings', operation: 'slotlock_book_pool', refusals: [404, 409] },
|
|
20
27
|
{
|
package/rules.d.ts
CHANGED
|
@@ -9,6 +9,8 @@ export declare const SLOTLOCK_CALENDAR_HORIZON_DAYS = 367;
|
|
|
9
9
|
* many days as a rule lasts, so a longer one is skipped, as the hours validator refuses it.
|
|
10
10
|
*/
|
|
11
11
|
export declare const SLOTLOCK_MAX_RULE_DURATION_MINUTES = 10080;
|
|
12
|
+
/** The most busy intervals one calendar source may report for one window (RFC 0004 C1). */
|
|
13
|
+
export declare const SLOTLOCK_SOURCE_BUSY_MAX = 5000;
|
|
12
14
|
export declare function invalidWindowError(message?: string): Error & {
|
|
13
15
|
code: 'invalid_window';
|
|
14
16
|
};
|
package/rules.js
CHANGED
|
@@ -17,6 +17,8 @@ const MAX_SEARCH_WINDOW_MS = SLOTLOCK_CALENDAR_HORIZON_DAYS * DAY_MS;
|
|
|
17
17
|
* many days as a rule lasts, so a longer one is skipped, as the hours validator refuses it.
|
|
18
18
|
*/
|
|
19
19
|
export const SLOTLOCK_MAX_RULE_DURATION_MINUTES = 10_080;
|
|
20
|
+
/** The most busy intervals one calendar source may report for one window (RFC 0004 C1). */
|
|
21
|
+
export const SLOTLOCK_SOURCE_BUSY_MAX = 5_000;
|
|
20
22
|
export function invalidWindowError(message = 'Slotlock window must contain two finite dates with end > start') {
|
|
21
23
|
return Object.assign(new Error(message), { code: 'invalid_window' });
|
|
22
24
|
}
|
package/self-host.d.ts
CHANGED
|
@@ -166,7 +166,7 @@ export declare function createSlotlockMaintenanceStore(sql: SlotlockSql, caps: S
|
|
|
166
166
|
}): SlotlockStore;
|
|
167
167
|
/** Serve MCP and A2A over HTTP from the Postgres store, as one token-authenticated tenant. */
|
|
168
168
|
export declare function startSlotlockServer(config: SlotlockServeConfig, log: SlotlockLogger): Promise<SlotlockRunningServer>;
|
|
169
|
-
export declare const SLOTLOCK_CLI_HELP = "Usage: slotlock <command> [options]\n\nCommands:\n migrate Apply the schema and row-level security, then grant the serving\n role when SLOTLOCK_MIGRATE_DATABASE_URL names another role\n serve [--migrate] Serve MCP, A2A and REST over HTTP (--migrate runs migrate first);\n maintains every tenant with data every hour\n maintain Run maintenance for the tenant now: extend recurring events to the\n rolling horizon and prune past the retention window (for cron)\n resource add <ref> [--timezone <IANA zone>]\n Create a calendar resource (a car, room, person or machine)\n resource list Print the tenant's resources, one JSON object per line\n resource archive <ref> Take a resource out of use: no new bookings, holds or events, and\n no longer listed; what is on it stays\n resource restore <ref> Put an archived resource back in use\n resource parent <ref> (<parent-ref> | none)\n Make a resource a part of a composite (a room of a hall): booking\n the whole then takes every part, and a part's booking keeps the\n whole from being booked; none makes it no part\n resource delete <ref> Delete a resource and its history; refused while a booking, hold\n or event on it has not ended\n hours show [--resource <ref>]\n Print each resource's bookable hours (null: the server's)\n hours set <json> (--resource <ref> | --all)\n Give one resource, or every one, weekly hours of its own; [] closes\n hours clear (--resource <ref> | --all)\n Hand one resource, or every one, back to the server's hours\n rules set <json> --resource <ref>\n Set what a booking on the resource must satisfy, like\n {\"minNoticeMinutes\":120,\"slotStepMinutes\":30,\"turnaroundMinutes\":30,\"enforceHours\":true}\n rules clear --resource <ref>\n Remove the resource's booking rules\n key create <name> [--scope read,write,admin] [--expires-in-days <n>]\n [--locations <ref,...>] [--groups <ref,...>]\n Create an API key for the tenant (default scope read,write, no\n expiry; admin adds the application API) and print it once; only its\n SHA-256 is stored. With --locations or --groups, the key reaches\n only the resources there (each location with those in it), read and\n write only\n [--max-holds <n>] [--max-held-hours <n>] [--requests-per-minute <n>]\n [--requires-approval]\n ... and hold the key below the server's limits: its live holds, the\n hours they hold, its requests a minute, and every booking it makes\n waiting for a person (the dashboard's Approvals)\n key limits <id> [--max-holds <n>] [--max-held-hours <n>] [--requests-per-minute <n>]\n [--requires-approval]\n Replace an active key's limits; none at all gives it the server's\n key list Print the tenant's API keys, one JSON object per line\n key rotate <id> Give an active key a new secret and print it once; the old one stops\n working at once, and the key keeps its id and what it booked\n key revoke <id> Revoke one of the tenant's API keys at once\n sandbox seed <car-rental|clinic|desks>\n Fill an empty test environment (SLOTLOCK_TENANT=<tenant>~test) with a\n scenario: locations, groups, resources, hours, rules and bookings\n sandbox reset Empty the test environment; its keys, webhooks and plan stay\n sandbox fast-forward <minutes>\n Make what would lapse in the test environment within that time lapse\n now (holds, offers, approvals), then run maintenance\n sandbox race <resource-ref> [--agents <n>] [--start <date-time>]\n Race agents (default 100) for one hour on a test resource and print who\n booked, who was refused and why, and how long the answers took\n partner create <id> <name> --contract <json | @file>\n Create a partner reselling Slotlock, or change its name and contract:\n its plans (limits and quotas), its accounts' key scopes and its prices\n (as the schema owner, SLOTLOCK_MIGRATE_DATABASE_URL)\n partner list Print the partners, one JSON object per line\n partner suspend <id> Suspend a partner: every account with it, and its keys stop\n partner unsuspend <id> Make a suspended partner active again\n partner key <id> <name> Create a partner key (slk_partner_\u2026) and print it once\n partner revoke-key <id> <key-id>\n Revoke one of a partner's keys at once\n partner customer <id> <cus_\u2026>\n The Stripe customer its monthly invoice goes to\n partner webhook <id> <https-url>\n Where usage.period_closed goes; prints its signing secret once\n billing daily Once a day (cron), as the schema owner: close last month for every\n active partner once it has been over for 6 hours, report overage\n when SLOTLOCK_OVERAGE is on (with STRIPE_SECRET_KEY), and list the\n partners' reconciliation mismatches of the last 7 days; exits 1 when\n anything waits or differs. Safe to run again\n overage report [--month <YYYY-MM>] [--event <name>]\n With SLOTLOCK_OVERAGE: send Stripe each paying Developer tenant's\n operations above its quota this month (or --month) not yet sent, as\n Billing Meter events (--event, default slotlock_operations), once\n (as the schema owner, with STRIPE_SECRET_KEY)\n partner close <id> [--month <YYYY-MM>]\n Close an ended month (default: last month): price its accounts' use\n with the contract, record the lines once, send usage.period_closed,\n and with STRIPE_SECRET_KEY add the lines to the partner's next Stripe\n invoice; safe to run again\n tenant status Print whether the tenant is suspended\n tenant suspend [--reason <text>]\n Suspend the tenant: it may read, and writes nothing; its keys get\n no new secret (as SLOTLOCK_MIGRATE_DATABASE_URL's role when set:\n only the schema owner suspends)\n tenant unsuspend Lift the tenant's suspension (as the schema owner, likewise)\n tenant erase --confirm <tenant>\n Delete everything the tenant has, its API keys included, for good;\n --confirm repeats SLOTLOCK_TENANT; refused while its hosted plan is\n paid for (cancel the subscription first)\n plan status Print the deployment's default plan and the tenant's own (hosted)\n plan default (free | developer | none) [--from <date>]\n Put every tenant without a plan of its own on this one from --from\n (an ISO date or time; default now), or none for no default; a new\n tenant gets it at once (as the schema owner, like tenant suspend)\n healthcheck Exit 0 when the local server reports ready\n\nOptions:\n -h, --help Show this help\n -v, --version Print the version\n\nEnvironment:\n DATABASE_URL postgres:// URL of the role Slotlock serves as (required); serve\n refuses a superuser or a role with BYPASSRLS\n SLOTLOCK_MIGRATE_DATABASE_URL postgres:// URL of the schema owner, for migrate (optional)\n SLOTLOCK_AUTH_TOKEN Optional bearer token for SLOTLOCK_TENANT with read and write\n access (never admin), 32+ random characters (serve); API keys\n work with or without it\n SLOTLOCK_CONFIRMATION_SECRET Signs pending write confirmations, 32+ random characters (serve;\n required when SLOTLOCK_CONFIRM_WRITES is not none)\n SLOTLOCK_METRICS_TOKEN Serves Prometheus metrics at /metrics to this bearer token, 32+\n random characters (serve; off without it)\n SLOTLOCK_CONFIRM_WRITES none (default), all, or a comma-separated list of write tools\n that wait for a person (MCP 2026-07-28 form elicitation; refused\n with confirmation_required on REST, A2A and older MCP)\n SLOTLOCK_PUBLIC_URL URL clients use (default http://localhost:$PORT)\n SLOTLOCK_TENANT Tenant of the token, resource and key commands (default \"default\")\n SLOTLOCK_AVAILABILITY JSON array of weekly bookable-hours rules (default: none)\n SLOTLOCK_REST_API on (default): also serve the tools as a REST API under /v1, and\n the application API for admin keys, described at /openapi.json;\n off: MCP and A2A only\n SLOTLOCK_RETENTION_DAYS Days of history kept: the change feed, ended waitlist entries,\n and retired answers (never under 30) (1-3650, default 30;\n serve and maintain)\n SLOTLOCK_USAGE on: count each tenant's operations, bookings and holds by day, API\n key and surface (slotlock.usage_daily, kept 400 days); off\n (default): record nothing\n SLOTLOCK_QUOTAS on (needs SLOTLOCK_USAGE=on): refuse a tenant on a plan past its\n operations or bookings this month (quota_exceeded, 429); off\n (default): no quota\n SLOTLOCK_OVERAGE off (default), or on (300) or a percent (111-1000): a paying\n Developer tenant is refused only past that share of its\n operations, and the rest above its quota is billed (overage\n report); needs SLOTLOCK_QUOTAS=on\n SLOTLOCK_SHARED_LIMITS on: tenant request budgets are shared by every server through the\n database, so N servers allow a tenant its rate once; off (default):\n each server keeps its own\n SLOTLOCK_WEBHOOKS on (default): deliver each tenant's changes to its webhooks,\n never to a private network address; private: those too, for\n testing; off: keep the change feed, send nothing\n SLOTLOCK_LIMITS Request limits, comma-separated name=value over the defaults:\n rates as <count>/<s|m|h>[:<burst>] or off for address (50/s:100,\n every request per client, before any key lookup), key_read,\n tenant_read (20/s:40), key_write, tenant_write (5/s:20),\n global_read (500/s:1000), global_write (50/s:100), app_read\n (20/s:40) and app_write (5/s:20) per tenant for the\n application API, sign_in (20/m:20); counts for\n tenant_series_writes and series_writes\n (1: writes that may expand a recurring series at once),\n maintenance_runs (1: application API maintenance runs at once),\n subscriptions (256), tenant_subscriptions (16),\n key_subscriptions (4, and fewer than tenant_subscriptions),\n key_lookups (2), key_lookup_queue (256),\n tenant_transactions (2: one tenant's database transactions at\n once, of the pool's 10); what one tenant may store:\n tenant_holds (10000), tenant_reservations, tenant_events and\n tenant_coverage (100000), holder_holds (20) and\n holder_held_hours (2160), which maintain keeps too\n SLOTLOCK_TRUSTED_PROXY_HEADER The request field a proxy in front sets to the client address,\n such as CF-Connecting-IP (default: the connection's address);\n set it only when every request comes through that proxy\n SLOTLOCK_TRUSTED_PROXY_SECRET A secret the proxy adds to every request in x-slotlock-proxy-proof,\n 32+ random characters: without it there, the field above is\n ignored and the connection's address counts\n SLOTLOCK_KNOWN_CLIENTS Comma-separated integrator names /metrics counts requests by, from\n their Slotlock-Client field (any other: other; none sent: none)\n OTEL_EXPORTER_OTLP_ENDPOINT An OpenTelemetry collector (OTLP over HTTP, JSON): a span per\n call is sent to <endpoint>/v1/traces (off without it)\n OTEL_EXPORTER_OTLP_TRACES_ENDPOINT The full traces URL instead of <endpoint>/v1/traces\n OTEL_EXPORTER_OTLP_METRICS_ENDPOINT The full metrics URL instead of <endpoint>/v1/metrics (sent each minute)\n OTEL_EXPORTER_OTLP_HEADERS name=value pairs sent with each export, such as an API key\n OTEL_SERVICE_NAME service.name of the spans (default slotlock)\n SLOTLOCK_GITHUB_CLIENT_ID Serve the dashboard at $SLOTLOCK_PUBLIC_URL/dashboard: the Client ID\n of a GitHub OAuth app with callback .../dashboard/callback\n SLOTLOCK_GITHUB_CLIENT_SECRET That app's client secret (dashboard)\n SLOTLOCK_SESSION_SECRET Signs dashboard sessions, 32+ random characters (dashboard)\n SLOTLOCK_ASSISTANT_SECRET Turns on the dashboard assistant and seals each organization's\n model API key with it, 32+ random characters (dashboard; off\n without it)\n SLOTLOCK_ASSISTANT_PREVIOUS_SECRETS Up to three earlier SLOTLOCK_ASSISTANT_SECRET values,\n comma-separated, newest first: keys sealed under one still open\n and are sealed again under the current secret when next used\n SLOTLOCK_ASSISTANT_PRIVATE_URLS off (default): refuse an assistant model URL whose host is, or\n resolves to, a loopback, private, link-local, CGNAT, unique-local\n or metadata address; on: allow them, for a self-hosted model\n (Ollama, vLLM) on this machine or network\n SLOTLOCK_GOOGLE_CLIENT_ID Sign-in with Google too: the client id of a Google OAuth client with\n redirect URI .../dashboard/callback (not with organization ids)\n SLOTLOCK_GOOGLE_CLIENT_SECRET Its client secret\n SLOTLOCK_DASHBOARD_USERS * or comma-separated GitHub user ids (and google:<subject>) that may sign in and whose keys work\n SLOTLOCK_DASHBOARD_ORGS comma-separated GitHub organization ids: each is one shared tenant;\n or enabled: each person's own tenant, plus the organizations an\n owner turns on from the dashboard's Teams page\n SLOTLOCK_DASHBOARD_NAME What the dashboard calls this deployment (default Slotlock)\n SLOTLOCK_DASHBOARD_TERMS_URL https URL of the terms people accept by signing in, linked on the\n sign-in page (none named without it)\n SLOTLOCK_DASHBOARD_PRIVACY_URL https URL of the privacy notice the sign-in page links\n HOST Listen address (default 127.0.0.1)\n PORT Listen port (default 8080)\n\nGenerate each secret with: openssl rand -hex 32\n";
|
|
169
|
+
export declare const SLOTLOCK_CLI_HELP = "Usage: slotlock <command> [options]\n\nCommands:\n migrate Apply the schema and row-level security, then grant the serving\n role when SLOTLOCK_MIGRATE_DATABASE_URL names another role\n serve [--migrate] Serve MCP, A2A and REST over HTTP (--migrate runs migrate first);\n maintains every tenant with data every hour\n maintain Run maintenance for the tenant now: extend recurring events to the\n rolling horizon and prune past the retention window (for cron)\n resource add <ref> [--timezone <IANA zone>]\n Create a calendar resource (a car, room, person or machine)\n resource list Print the tenant's resources, one JSON object per line\n resource archive <ref> Take a resource out of use: no new bookings, holds or events, and\n no longer listed; what is on it stays\n resource restore <ref> Put an archived resource back in use\n resource parent <ref> (<parent-ref> | none)\n Make a resource a part of a composite (a room of a hall): booking\n the whole then takes every part, and a part's booking keeps the\n whole from being booked; none makes it no part\n resource delete <ref> Delete a resource and its history; refused while a booking, hold\n or event on it has not ended\n hours show [--resource <ref>]\n Print each resource's bookable hours: its own (null when none) and\n what it inherits from its location or the tenant\n hours set <json> (--resource <ref> | --location <ref> | --all [--override | --matching])\n Give one resource, one location or the tenant weekly hours; a\n resource without its own inherits its location's, then the\n tenant's, then the server's; --override also clears resources'\n own hours, --matching only those the same as the tenant's were\n or as these (copies 0.8.0 made); [] closes\n hours clear (--resource <ref> | --location <ref> | --all)\n Clear those hours, so they inherit from further up\n closures list Print the tenant's closures and special days from yesterday on\n closures add <first-day> [<last-day>] --label <text> [--open HH:MM-HH:MM]\n [--resource <ref> | --location <ref>]\n Close whole days (YYYY-MM-DD), or with --open give them those hours\n instead, everywhere or for one location or resource\n closures delete <id> Remove a closure\n closures import <file> [--resource <ref> | --location <ref>] [--label <calendar name>]\n Close the days an iCalendar file of holidays names (each all-day\n event, for the next two years); importing the calendar again (by its\n name: --label, else its own) updates them and removes those it dropped\n rules set <json> --resource <ref>\n Set what a booking on the resource must satisfy, like\n {\"minNoticeMinutes\":120,\"slotStepMinutes\":30,\"turnaroundMinutes\":30,\"enforceHours\":true}\n rules clear --resource <ref>\n Remove the resource's booking rules\n key create <name> [--scope read,write,admin] [--expires-in-days <n>]\n [--locations <ref,...>] [--groups <ref,...>]\n Create an API key for the tenant (default scope read,write, no\n expiry; admin adds the application API) and print it once; only its\n SHA-256 is stored. With --locations or --groups, the key reaches\n only the resources there (each location with those in it), read and\n write only\n [--max-holds <n>] [--max-held-hours <n>] [--requests-per-minute <n>]\n [--requires-approval]\n ... and hold the key below the server's limits: its live holds, the\n hours they hold, its requests a minute, and every booking it makes\n waiting for a person (the dashboard's Approvals)\n key limits <id> [--max-holds <n>] [--max-held-hours <n>] [--requests-per-minute <n>]\n [--requires-approval]\n Replace an active key's limits; none at all gives it the server's\n key list Print the tenant's API keys, one JSON object per line\n key rotate <id> Give an active key a new secret and print it once; the old one stops\n working at once, and the key keeps its id and what it booked\n key revoke <id> Revoke one of the tenant's API keys at once\n sandbox seed <car-rental|clinic|desks>\n Fill an empty test environment (SLOTLOCK_TENANT=<tenant>~test) with a\n scenario: locations, groups, resources, hours, rules and bookings\n sandbox reset Empty the test environment; its keys, webhooks and plan stay\n sandbox fast-forward <minutes>\n Make what would lapse in the test environment within that time lapse\n now (holds, offers, approvals), then run maintenance\n sandbox race <resource-ref> [--agents <n>] [--start <date-time>]\n Race agents (default 100) for one hour on a test resource and print who\n booked, who was refused and why, and how long the answers took\n partner create <id> <name> --contract <json | @file>\n Create a partner reselling Slotlock, or change its name and contract:\n its plans (limits and quotas), its accounts' key scopes and its prices\n (as the schema owner, SLOTLOCK_MIGRATE_DATABASE_URL)\n partner list Print the partners, one JSON object per line\n partner suspend <id> Suspend a partner: every account with it, and its keys stop\n partner unsuspend <id> Make a suspended partner active again\n partner key <id> <name> Create a partner key (slk_partner_\u2026) and print it once\n partner revoke-key <id> <key-id>\n Revoke one of a partner's keys at once\n partner customer <id> <cus_\u2026>\n The Stripe customer its monthly invoice goes to\n partner webhook <id> <https-url>\n Where usage.period_closed goes; prints its signing secret once\n billing daily Once a day (cron), as the schema owner: close last month for every\n active partner once it has been over for 6 hours, report overage\n when SLOTLOCK_OVERAGE is on (with STRIPE_SECRET_KEY), and list the\n partners' reconciliation mismatches of the last 7 days; exits 1 when\n anything waits or differs. Safe to run again\n overage report [--month <YYYY-MM>] [--event <name>]\n With SLOTLOCK_OVERAGE: send Stripe each paying Developer tenant's\n operations above its quota this month (or --month) not yet sent, as\n Billing Meter events (--event, default slotlock_operations), once\n (as the schema owner, with STRIPE_SECRET_KEY)\n partner close <id> [--month <YYYY-MM>]\n Close an ended month (default: last month): price its accounts' use\n with the contract, record the lines once, send usage.period_closed,\n and with STRIPE_SECRET_KEY add the lines to the partner's next Stripe\n invoice; safe to run again\n tenant status Print whether the tenant is suspended\n tenant suspend [--reason <text>]\n Suspend the tenant: it may read, and writes nothing; its keys get\n no new secret (as SLOTLOCK_MIGRATE_DATABASE_URL's role when set:\n only the schema owner suspends)\n tenant unsuspend Lift the tenant's suspension (as the schema owner, likewise)\n tenant erase --confirm <tenant>\n Delete everything the tenant has, its API keys included, for good;\n --confirm repeats SLOTLOCK_TENANT; refused while its hosted plan is\n paid for (cancel the subscription first)\n plan status Print the deployment's default plan and the tenant's own (hosted)\n plan default (free | developer | none) [--from <date>]\n Put every tenant without a plan of its own on this one from --from\n (an ISO date or time; default now), or none for no default; a new\n tenant gets it at once (as the schema owner, like tenant suspend)\n healthcheck Exit 0 when the local server reports ready\n\nOptions:\n -h, --help Show this help\n -v, --version Print the version\n\nEnvironment:\n DATABASE_URL postgres:// URL of the role Slotlock serves as (required); serve\n refuses a superuser or a role with BYPASSRLS\n SLOTLOCK_MIGRATE_DATABASE_URL postgres:// URL of the schema owner, for migrate (optional)\n SLOTLOCK_AUTH_TOKEN Optional bearer token for SLOTLOCK_TENANT with read and write\n access (never admin), 32+ random characters (serve); API keys\n work with or without it\n SLOTLOCK_CONFIRMATION_SECRET Signs pending write confirmations, 32+ random characters (serve;\n required when SLOTLOCK_CONFIRM_WRITES is not none)\n SLOTLOCK_METRICS_TOKEN Serves Prometheus metrics at /metrics to this bearer token, 32+\n random characters (serve; off without it)\n SLOTLOCK_CONFIRM_WRITES none (default), all, or a comma-separated list of write tools\n that wait for a person (MCP 2026-07-28 form elicitation; refused\n with confirmation_required on REST, A2A and older MCP)\n SLOTLOCK_PUBLIC_URL URL clients use (default http://localhost:$PORT)\n SLOTLOCK_TENANT Tenant of the token, resource and key commands (default \"default\")\n SLOTLOCK_AVAILABILITY JSON array of weekly bookable-hours rules (default: none)\n SLOTLOCK_REST_API on (default): also serve the tools as a REST API under /v1, and\n the application API for admin keys, described at /openapi.json;\n off: MCP and A2A only\n SLOTLOCK_RETENTION_DAYS Days of history kept: the change feed, ended waitlist entries,\n and retired answers (never under 30) (1-3650, default 30;\n serve and maintain)\n SLOTLOCK_USAGE on: count each tenant's operations, bookings and holds by day, API\n key and surface (slotlock.usage_daily, kept 400 days); off\n (default): record nothing\n SLOTLOCK_QUOTAS on (needs SLOTLOCK_USAGE=on): refuse a tenant on a plan past its\n operations or bookings this month (quota_exceeded, 429); off\n (default): no quota\n SLOTLOCK_OVERAGE off (default), or on (300) or a percent (111-1000): a paying\n Developer tenant is refused only past that share of its\n operations, and the rest above its quota is billed (overage\n report); needs SLOTLOCK_QUOTAS=on\n SLOTLOCK_SHARED_LIMITS on: tenant request budgets are shared by every server through the\n database, so N servers allow a tenant its rate once; off (default):\n each server keeps its own\n SLOTLOCK_WEBHOOKS on (default): deliver each tenant's changes to its webhooks,\n never to a private network address; private: those too, for\n testing; off: keep the change feed, send nothing\n SLOTLOCK_LIMITS Request limits, comma-separated name=value over the defaults:\n rates as <count>/<s|m|h>[:<burst>] or off for address (50/s:100,\n every request per client, before any key lookup), key_read,\n tenant_read (20/s:40), key_write, tenant_write (5/s:20),\n global_read (500/s:1000), global_write (50/s:100), app_read\n (20/s:40) and app_write (5/s:20) per tenant for the\n application API, sign_in (20/m:20); counts for\n tenant_series_writes and series_writes\n (1: writes that may expand a recurring series at once),\n maintenance_runs (1: application API maintenance runs at once),\n subscriptions (256), tenant_subscriptions (16),\n key_subscriptions (4, and fewer than tenant_subscriptions),\n key_lookups (2), key_lookup_queue (256),\n tenant_transactions (2: one tenant's database transactions at\n once, of the pool's 10); what one tenant may store:\n tenant_holds (10000), tenant_reservations, tenant_events and\n tenant_coverage (100000), holder_holds (20) and\n holder_held_hours (2160), which maintain keeps too\n SLOTLOCK_TRUSTED_PROXY_HEADER The request field a proxy in front sets to the client address,\n such as CF-Connecting-IP (default: the connection's address);\n set it only when every request comes through that proxy\n SLOTLOCK_TRUSTED_PROXY_SECRET A secret the proxy adds to every request in x-slotlock-proxy-proof,\n 32+ random characters: without it there, the field above is\n ignored and the connection's address counts\n SLOTLOCK_KNOWN_CLIENTS Comma-separated integrator names /metrics counts requests by, from\n their Slotlock-Client field (any other: other; none sent: none)\n OTEL_EXPORTER_OTLP_ENDPOINT An OpenTelemetry collector (OTLP over HTTP, JSON): a span per\n call is sent to <endpoint>/v1/traces (off without it)\n OTEL_EXPORTER_OTLP_TRACES_ENDPOINT The full traces URL instead of <endpoint>/v1/traces\n OTEL_EXPORTER_OTLP_METRICS_ENDPOINT The full metrics URL instead of <endpoint>/v1/metrics (sent each minute)\n OTEL_EXPORTER_OTLP_HEADERS name=value pairs sent with each export, such as an API key\n OTEL_SERVICE_NAME service.name of the spans (default slotlock)\n SLOTLOCK_GITHUB_CLIENT_ID Serve the dashboard at $SLOTLOCK_PUBLIC_URL/dashboard: the Client ID\n of a GitHub OAuth app with callback .../dashboard/callback\n SLOTLOCK_GITHUB_CLIENT_SECRET That app's client secret (dashboard)\n SLOTLOCK_SESSION_SECRET Signs dashboard sessions, 32+ random characters (dashboard)\n SLOTLOCK_ASSISTANT_SECRET Turns on the dashboard assistant and seals each organization's\n model API key with it, 32+ random characters (dashboard; off\n without it)\n SLOTLOCK_ASSISTANT_PREVIOUS_SECRETS Up to three earlier SLOTLOCK_ASSISTANT_SECRET values,\n comma-separated, newest first: keys sealed under one still open\n and are sealed again under the current secret when next used\n SLOTLOCK_ASSISTANT_PRIVATE_URLS off (default): refuse an assistant model URL whose host is, or\n resolves to, a loopback, private, link-local, CGNAT, unique-local\n or metadata address; on: allow them, for a self-hosted model\n (Ollama, vLLM) on this machine or network\n SLOTLOCK_GOOGLE_CLIENT_ID Sign-in with Google too: the client id of a Google OAuth client with\n redirect URI .../dashboard/callback (not with organization ids)\n SLOTLOCK_GOOGLE_CLIENT_SECRET Its client secret\n SLOTLOCK_DASHBOARD_USERS * or comma-separated GitHub user ids (and google:<subject>) that may sign in and whose keys work\n SLOTLOCK_DASHBOARD_ORGS comma-separated GitHub organization ids: each is one shared tenant;\n or enabled: each person's own tenant, plus the organizations an\n owner turns on from the dashboard's Teams page\n SLOTLOCK_DASHBOARD_NAME What the dashboard calls this deployment (default Slotlock)\n SLOTLOCK_DASHBOARD_TERMS_URL https URL of the terms people accept by signing in, linked on the\n sign-in page (none named without it)\n SLOTLOCK_DASHBOARD_PRIVACY_URL https URL of the privacy notice the sign-in page links\n HOST Listen address (default 127.0.0.1)\n PORT Listen port (default 8080)\n\nGenerate each secret with: openssl rand -hex 32\n";
|
|
170
170
|
/**
|
|
171
171
|
* Run one `slotlock` command and resolve to its exit code: 0 success, 1 failure, 2 a usage or
|
|
172
172
|
* configuration error (nothing was started). `serve` resolves once `io.signal` aborts and it has
|