@datalayer/agent-runtimes 1.5.45 → 1.5.46
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist-embed/datalayer-app-main.js +3 -3
- package/lib/portable/hosts/__tests__/fixtures/lock-with-jupyter.json +40 -0
- package/lib/portable/hosts/__tests__/fixtures/npm-ls-with-jupyter.json +21 -0
- package/lib/portable/hosts/agentClient.d.ts +167 -0
- package/lib/portable/hosts/agentClient.js +346 -0
- package/lib/portable/hosts/approvalRenderers.d.ts +43 -0
- package/lib/portable/hosts/approvalRenderers.js +180 -0
- package/lib/portable/hosts/client.d.ts +95 -0
- package/lib/portable/hosts/client.js +174 -0
- package/lib/portable/hosts/contracts.d.ts +199 -0
- package/lib/portable/hosts/contracts.js +235 -0
- package/lib/portable/hosts/contracts.json +381 -0
- package/lib/portable/hosts/deadLetters.d.ts +41 -0
- package/lib/portable/hosts/deadLetters.js +124 -0
- package/lib/portable/hosts/deliveries.d.ts +76 -0
- package/lib/portable/hosts/deliveries.js +147 -0
- package/lib/portable/hosts/forbiddenGraph.d.ts +77 -0
- package/lib/portable/hosts/forbiddenGraph.js +174 -0
- package/lib/portable/hosts/health.d.ts +35 -0
- package/lib/portable/hosts/health.js +92 -0
- package/lib/portable/hosts/index.d.ts +40 -0
- package/lib/portable/hosts/index.js +45 -0
- package/lib/portable/hosts/observability.d.ts +67 -0
- package/lib/portable/hosts/observability.js +171 -0
- package/lib/portable/hosts/postgresQueue.d.ts +55 -0
- package/lib/portable/hosts/postgresQueue.js +217 -0
- package/lib/portable/hosts/queue.d.ts +47 -0
- package/lib/portable/hosts/queue.js +63 -0
- package/lib/portable/hosts/quotas.d.ts +81 -0
- package/lib/portable/hosts/quotas.js +147 -0
- package/lib/portable/hosts/rateLimit.d.ts +13 -0
- package/lib/portable/hosts/rateLimit.js +8 -0
- package/lib/portable/hosts/resolution.d.ts +54 -0
- package/lib/portable/hosts/resolution.js +46 -0
- package/lib/portable/hosts/schema.d.ts +19 -0
- package/lib/portable/hosts/schema.js +81 -0
- package/lib/portable/hosts/secretCipher.d.ts +23 -0
- package/lib/portable/hosts/secretCipher.js +45 -0
- package/lib/portable/hosts/sessionUid.d.ts +7 -0
- package/lib/portable/hosts/sessionUid.js +28 -0
- package/lib/portable/hosts/turnState.d.ts +45 -0
- package/lib/portable/hosts/turnState.js +113 -0
- package/lib/portable/index.d.ts +2 -0
- package/lib/portable/index.js +3 -0
- package/lib/portable/semantic-events.json +480 -0
- package/lib/portable/semantic.d.ts +78 -0
- package/lib/portable/semantic.js +100 -0
- package/lib/portable/semanticOfAgUi.d.ts +41 -0
- package/lib/portable/semanticOfAgUi.js +108 -0
- package/lib/protocols/A2AAdapter.js +19 -0
- package/lib/test-setup.js +17 -14
- package/package.json +1 -1
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2025-2026 Datalayer, Inc.
|
|
3
|
+
* Distributed under the terms of the Modified BSD License.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* The durable work queue on Postgres: jobs claimed with `FOR UPDATE SKIP
|
|
7
|
+
* LOCKED`, leased and renewed while worked, retried with backoff, dead after
|
|
8
|
+
* five attempts, an ingress claim and its job kept in one transaction. Its
|
|
9
|
+
* tables are `<prefix>_jobs`, `<prefix>_event_claims` and
|
|
10
|
+
* `<prefix>_job_replays` (`hostSchemaSql`). Moved from the Slack service
|
|
11
|
+
* (plans/DISCORD.md DC-004).
|
|
12
|
+
*
|
|
13
|
+
* @module portable/hosts/postgresQueue
|
|
14
|
+
*/
|
|
15
|
+
import { tablePrefixOf, } from './queue';
|
|
16
|
+
export class PostgresWorkQueue {
|
|
17
|
+
database;
|
|
18
|
+
tablePrefix;
|
|
19
|
+
pollIntervalMs;
|
|
20
|
+
leaseRenewMs;
|
|
21
|
+
#handler;
|
|
22
|
+
#loop;
|
|
23
|
+
#stopping = false;
|
|
24
|
+
#jobs;
|
|
25
|
+
#claims;
|
|
26
|
+
#replays;
|
|
27
|
+
constructor(database,
|
|
28
|
+
/** The host's table prefix: `slack`, `discord`. */
|
|
29
|
+
tablePrefix, pollIntervalMs = 250,
|
|
30
|
+
/**
|
|
31
|
+
* How often a job being worked renews its lease (S-207): well within the
|
|
32
|
+
* five minutes after which `reclaimAbandonedJobs` takes a job back, so
|
|
33
|
+
* only a worker that died loses its job, not one answering a long turn.
|
|
34
|
+
*/
|
|
35
|
+
leaseRenewMs = 60_000) {
|
|
36
|
+
this.database = database;
|
|
37
|
+
this.tablePrefix = tablePrefix;
|
|
38
|
+
this.pollIntervalMs = pollIntervalMs;
|
|
39
|
+
this.leaseRenewMs = leaseRenewMs;
|
|
40
|
+
const prefix = tablePrefixOf(tablePrefix);
|
|
41
|
+
this.#jobs = `${prefix}_jobs`;
|
|
42
|
+
this.#claims = `${prefix}_event_claims`;
|
|
43
|
+
this.#replays = `${prefix}_job_replays`;
|
|
44
|
+
}
|
|
45
|
+
async enqueue(item, idempotencyKey) {
|
|
46
|
+
if (!idempotencyKey) {
|
|
47
|
+
await this.database.query(`INSERT INTO ${this.#jobs} (payload) VALUES ($1)`, [item]);
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
// The idempotency key and its job in one transaction (§7.1, 4).
|
|
51
|
+
const client = await this.database.connect();
|
|
52
|
+
try {
|
|
53
|
+
await client.query('BEGIN');
|
|
54
|
+
const claimed = await client.query(`INSERT INTO ${this.#claims} (event_id) VALUES ($1) ON CONFLICT DO NOTHING RETURNING event_id`, [`ingress:${idempotencyKey}`]);
|
|
55
|
+
if (claimed.rowCount === 1)
|
|
56
|
+
await client.query(`INSERT INTO ${this.#jobs} (payload) VALUES ($1)`, [
|
|
57
|
+
item,
|
|
58
|
+
]);
|
|
59
|
+
await client.query('COMMIT');
|
|
60
|
+
return claimed.rowCount === 1;
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
await client.query('ROLLBACK');
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
client.release();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
async ready() {
|
|
71
|
+
try {
|
|
72
|
+
await this.database.query(`SELECT 1 FROM ${this.#jobs} LIMIT 1`);
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** Jobs that failed five times, with their metadata only: what the dead-letter tool lists. */
|
|
80
|
+
async dead(limit = 50) {
|
|
81
|
+
const result = await this.database.query(`SELECT id::text, attempts, last_error, created_at,
|
|
82
|
+
CASE WHEN payload ? 'delivery' THEN 'delivery'
|
|
83
|
+
WHEN payload ? 'prompt' THEN 'prompt'
|
|
84
|
+
ELSE 'other' END AS kind
|
|
85
|
+
FROM ${this.#jobs} WHERE state = 'dead' ORDER BY id LIMIT $1`, [limit]);
|
|
86
|
+
return result.rows.map(row => ({
|
|
87
|
+
attempts: row.attempts,
|
|
88
|
+
createdAt: new Date(row.created_at).toISOString(),
|
|
89
|
+
id: row.id,
|
|
90
|
+
kind: row.kind ?? 'other',
|
|
91
|
+
lastError: row.last_error,
|
|
92
|
+
}));
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Put a dead job back to work: once, by id, the replay kept with who asked
|
|
96
|
+
* in the same transaction (`<prefix>_job_replays`, SLACK S-405), refused or not.
|
|
97
|
+
*/
|
|
98
|
+
async replay(id, operator) {
|
|
99
|
+
if (!/^\d+$/.test(id))
|
|
100
|
+
return false;
|
|
101
|
+
const client = await this.database.connect();
|
|
102
|
+
try {
|
|
103
|
+
await client.query('BEGIN');
|
|
104
|
+
const result = await client.query(`UPDATE ${this.#jobs} SET state = 'ready', attempts = 0, available_at = now(), last_error = NULL WHERE id = $1 AND state = 'dead'`, [id]);
|
|
105
|
+
const replayed = result.rowCount === 1;
|
|
106
|
+
await client.query(`INSERT INTO ${this.#replays} (job_id, operator, replayed) VALUES ($1, $2, $3)`, [id, operator, replayed]);
|
|
107
|
+
await client.query('COMMIT');
|
|
108
|
+
return replayed;
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
await client.query('ROLLBACK');
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
finally {
|
|
115
|
+
client.release();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* A dead job's payload, and only a dead one's (S-405): a live job, or an id
|
|
120
|
+
* that is no job, has none to read here.
|
|
121
|
+
*/
|
|
122
|
+
async payloadOf(id) {
|
|
123
|
+
if (!/^\d+$/.test(id))
|
|
124
|
+
return undefined;
|
|
125
|
+
const result = await this.database.query(`SELECT payload FROM ${this.#jobs} WHERE id = $1 AND state = 'dead'`, [id]);
|
|
126
|
+
return result.rows[0]?.payload;
|
|
127
|
+
}
|
|
128
|
+
start(handler) {
|
|
129
|
+
if (this.#loop)
|
|
130
|
+
throw new Error('The PostgreSQL work queue has already started.');
|
|
131
|
+
this.#handler = handler;
|
|
132
|
+
this.#stopping = false;
|
|
133
|
+
this.#loop = this.#run();
|
|
134
|
+
}
|
|
135
|
+
async stop() {
|
|
136
|
+
this.#stopping = true;
|
|
137
|
+
await this.#loop;
|
|
138
|
+
this.#loop = undefined;
|
|
139
|
+
this.#handler = undefined;
|
|
140
|
+
}
|
|
141
|
+
async #run() {
|
|
142
|
+
while (!this.#stopping) {
|
|
143
|
+
const job = await this.#claim();
|
|
144
|
+
if (!job) {
|
|
145
|
+
await delay(this.pollIntervalMs);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const lease = setInterval(() => {
|
|
149
|
+
void this.database
|
|
150
|
+
.query(`UPDATE ${this.#jobs} SET locked_at = now() WHERE id = $1 AND state = 'processing'`, [job.id])
|
|
151
|
+
.catch(() => undefined); // the next renewal, or the reclaim, decides
|
|
152
|
+
}, this.leaseRenewMs);
|
|
153
|
+
lease.unref?.();
|
|
154
|
+
try {
|
|
155
|
+
await this.#handler?.(job.payload);
|
|
156
|
+
await this.database.query(`DELETE FROM ${this.#jobs} WHERE id = $1`, [
|
|
157
|
+
job.id,
|
|
158
|
+
]);
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
await this.#fail(job, error);
|
|
162
|
+
}
|
|
163
|
+
finally {
|
|
164
|
+
clearInterval(lease);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
async #claim() {
|
|
169
|
+
const client = await this.database.connect();
|
|
170
|
+
try {
|
|
171
|
+
await client.query('BEGIN');
|
|
172
|
+
await reclaimAbandonedJobs(client, this.#jobs);
|
|
173
|
+
const selected = await client.query(`SELECT id::text, payload, attempts
|
|
174
|
+
FROM ${this.#jobs}
|
|
175
|
+
WHERE state = 'ready' AND available_at <= now()
|
|
176
|
+
ORDER BY id
|
|
177
|
+
FOR UPDATE SKIP LOCKED
|
|
178
|
+
LIMIT 1`);
|
|
179
|
+
const job = selected.rows[0];
|
|
180
|
+
if (!job) {
|
|
181
|
+
await client.query('COMMIT');
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
await client.query(`UPDATE ${this.#jobs}
|
|
185
|
+
SET state = 'processing', attempts = attempts + 1, locked_at = now()
|
|
186
|
+
WHERE id = $1`, [job.id]);
|
|
187
|
+
await client.query('COMMIT');
|
|
188
|
+
return { ...job, attempts: job.attempts + 1 };
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
await client.query('ROLLBACK');
|
|
192
|
+
throw error;
|
|
193
|
+
}
|
|
194
|
+
finally {
|
|
195
|
+
client.release();
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
async #fail(job, error) {
|
|
199
|
+
const message = error instanceof Error ? error.message : 'Unknown worker failure';
|
|
200
|
+
if (job.attempts >= 5) {
|
|
201
|
+
await this.database.query(`UPDATE ${this.#jobs} SET state = 'dead', last_error = $2, locked_at = NULL WHERE id = $1`, [job.id, message.slice(0, 2_000)]);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const delaySeconds = Math.min(300, 2 ** job.attempts);
|
|
205
|
+
await this.database.query(`UPDATE ${this.#jobs}
|
|
206
|
+
SET state = 'ready', available_at = now() + ($2 * interval '1 second'),
|
|
207
|
+
last_error = $3, locked_at = NULL
|
|
208
|
+
WHERE id = $1`, [job.id, delaySeconds, message.slice(0, 2_000)]);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
async function reclaimAbandonedJobs(client, jobs) {
|
|
212
|
+
await client.query(`UPDATE ${jobs}
|
|
213
|
+
SET state = 'ready', available_at = now(), locked_at = NULL,
|
|
214
|
+
last_error = 'Worker lease expired'
|
|
215
|
+
WHERE state = 'processing' AND locked_at < now() - interval '5 minutes'`);
|
|
216
|
+
}
|
|
217
|
+
const delay = async (milliseconds) => new Promise(resolve => setTimeout(resolve, milliseconds));
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A host's durable work queue, its in-memory development stand-in, and the
|
|
3
|
+
* structural Postgres connection every Postgres implementation here takes
|
|
4
|
+
* (`pg.Pool` and `pg.PoolClient` satisfy it: no `pg` import). Moved from the
|
|
5
|
+
* Slack service (plans/DISCORD.md DC-004).
|
|
6
|
+
*
|
|
7
|
+
* @module portable/hosts/queue
|
|
8
|
+
*/
|
|
9
|
+
/** What a query answers: its rows, and how many it changed. */
|
|
10
|
+
export type QueryAnswer<R> = {
|
|
11
|
+
rows: R[];
|
|
12
|
+
rowCount: number | null;
|
|
13
|
+
};
|
|
14
|
+
/** A Postgres connection as the hosts code uses it: `pg.Pool` or `pg.PoolClient`. */
|
|
15
|
+
export interface Queryable {
|
|
16
|
+
query<R extends {
|
|
17
|
+
[column: string]: any;
|
|
18
|
+
} = any>(text: string, values?: any[]): Promise<QueryAnswer<R>>;
|
|
19
|
+
}
|
|
20
|
+
/** A pool of connections, one taken for a transaction: `pg.Pool`. */
|
|
21
|
+
export interface ConnectionPool extends Queryable {
|
|
22
|
+
connect(): Promise<Queryable & {
|
|
23
|
+
release(): void;
|
|
24
|
+
}>;
|
|
25
|
+
}
|
|
26
|
+
/** A table prefix a host's tables carry (`slack`, `discord`): lowercase letters and digits. */
|
|
27
|
+
export declare function tablePrefixOf(prefix: string): string;
|
|
28
|
+
export interface WorkQueue<T> {
|
|
29
|
+
/**
|
|
30
|
+
* Persist one job. With an idempotency key (a host event or action id),
|
|
31
|
+
* the key and the job are kept in one transaction and a second job under
|
|
32
|
+
* the same key is not enqueued: `false`.
|
|
33
|
+
*/
|
|
34
|
+
enqueue(item: T, idempotencyKey?: string): Promise<boolean>;
|
|
35
|
+
/** Whether the queue can take durable work now: what readiness reports. */
|
|
36
|
+
ready(): Promise<boolean>;
|
|
37
|
+
start(handler: (item: T) => Promise<void>): void;
|
|
38
|
+
stop(): Promise<void>;
|
|
39
|
+
}
|
|
40
|
+
/** In-process development queue. A durable adapter is required in production. */
|
|
41
|
+
export declare class MemoryWorkQueue<T> implements WorkQueue<T> {
|
|
42
|
+
#private;
|
|
43
|
+
enqueue(item: T, idempotencyKey?: string): Promise<boolean>;
|
|
44
|
+
ready(): Promise<boolean>;
|
|
45
|
+
start(handler: (item: T) => Promise<void>): void;
|
|
46
|
+
stop(): Promise<void>;
|
|
47
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2025-2026 Datalayer, Inc.
|
|
3
|
+
* Distributed under the terms of the Modified BSD License.
|
|
4
|
+
*/
|
|
5
|
+
/** A table prefix a host's tables carry (`slack`, `discord`): lowercase letters and digits. */
|
|
6
|
+
export function tablePrefixOf(prefix) {
|
|
7
|
+
if (!/^[a-z][a-z0-9]{1,31}$/.test(prefix)) {
|
|
8
|
+
throw new Error(`A table prefix is lowercase letters and digits, a letter first: ${prefix}.`);
|
|
9
|
+
}
|
|
10
|
+
return prefix;
|
|
11
|
+
}
|
|
12
|
+
/** In-process development queue. A durable adapter is required in production. */
|
|
13
|
+
export class MemoryWorkQueue {
|
|
14
|
+
#items = [];
|
|
15
|
+
#keys = new Set();
|
|
16
|
+
#handler;
|
|
17
|
+
#running = false;
|
|
18
|
+
async enqueue(item, idempotencyKey) {
|
|
19
|
+
if (idempotencyKey) {
|
|
20
|
+
if (this.#keys.has(idempotencyKey)) {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
this.#keys.add(idempotencyKey);
|
|
24
|
+
}
|
|
25
|
+
this.#items.push(item);
|
|
26
|
+
queueMicrotask(() => void this.#drain());
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
async ready() {
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
start(handler) {
|
|
33
|
+
this.#handler = handler;
|
|
34
|
+
void this.#drain();
|
|
35
|
+
}
|
|
36
|
+
async stop() {
|
|
37
|
+
this.#handler = undefined;
|
|
38
|
+
while (this.#running) {
|
|
39
|
+
await new Promise(resolve => setTimeout(resolve, 5));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
async #drain() {
|
|
43
|
+
if (this.#running || !this.#handler) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
this.#running = true;
|
|
47
|
+
try {
|
|
48
|
+
while (this.#items.length > 0) {
|
|
49
|
+
const handler = this.#handler;
|
|
50
|
+
if (!handler) {
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
const item = this.#items.shift();
|
|
54
|
+
if (item !== undefined) {
|
|
55
|
+
await handler(item);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
this.#running = false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quotas before costly work (SLACK §9, S-404; DISCORD DC-402): questions an
|
|
3
|
+
* hour per person, installation (a Slack workspace, a Discord server),
|
|
4
|
+
* organization and application, taken atomically before a turn starts at
|
|
5
|
+
* Datalayer. A refusal says which limit and when it resets. Moved from the
|
|
6
|
+
* Slack service (plans/DISCORD.md DC-004).
|
|
7
|
+
*
|
|
8
|
+
* @module portable/hosts/quotas
|
|
9
|
+
*/
|
|
10
|
+
import { type ConnectionPool } from './queue';
|
|
11
|
+
export type QuotaScope = 'user' | 'installation' | 'organization' | 'application';
|
|
12
|
+
export type QuotaLimits = Readonly<Record<QuotaScope, number>>;
|
|
13
|
+
export declare const DEFAULT_QUOTAS: QuotaLimits;
|
|
14
|
+
export type QuotaEntry = Readonly<{
|
|
15
|
+
scope: QuotaScope;
|
|
16
|
+
key: string;
|
|
17
|
+
limit: number;
|
|
18
|
+
}>;
|
|
19
|
+
export type QuotaTaken = Readonly<{
|
|
20
|
+
ok: true;
|
|
21
|
+
}> | Readonly<{
|
|
22
|
+
ok: false;
|
|
23
|
+
scope: QuotaScope;
|
|
24
|
+
limit: number;
|
|
25
|
+
resetsAt: Date;
|
|
26
|
+
}>;
|
|
27
|
+
/** The hour a moment falls in: the period counts are kept by. */
|
|
28
|
+
export declare function windowOf(now: Date): string;
|
|
29
|
+
export declare function resetOf(now: Date): Date;
|
|
30
|
+
/** A person's quota key in an installation: what their usage line reads. */
|
|
31
|
+
export declare function userQuotaKey(installation: string, externalUserId: string): string;
|
|
32
|
+
/**
|
|
33
|
+
* What each quota counts, for one turn. `keyPrefix` names the installation's
|
|
34
|
+
* key (`installation:<id>` by default; Slack keeps `workspace:<id>`, the keys
|
|
35
|
+
* its counts were kept under).
|
|
36
|
+
*/
|
|
37
|
+
export declare function quotaEntries(limits: QuotaLimits, turn: {
|
|
38
|
+
installation: string;
|
|
39
|
+
externalUserId: string;
|
|
40
|
+
applicationId: string;
|
|
41
|
+
organizationId?: string;
|
|
42
|
+
}, keyPrefix?: string): QuotaEntry[];
|
|
43
|
+
/**
|
|
44
|
+
* Words for a refusal, said privately. `installation` names the host's
|
|
45
|
+
* installation as its people say it: "this workspace", "this server".
|
|
46
|
+
*/
|
|
47
|
+
export declare function quotaWords(taken: Extract<QuotaTaken, {
|
|
48
|
+
ok: false;
|
|
49
|
+
}>, installation: string): string;
|
|
50
|
+
export type QuotaTake = Readonly<{
|
|
51
|
+
ok: true;
|
|
52
|
+
}> | Readonly<{
|
|
53
|
+
ok: false;
|
|
54
|
+
entry: QuotaEntry;
|
|
55
|
+
}>;
|
|
56
|
+
/** Where quota counts are kept. */
|
|
57
|
+
export interface QuotaStore {
|
|
58
|
+
/**
|
|
59
|
+
* Take one of each quota for a period, all or none (S-404): refused when
|
|
60
|
+
* any is spent, naming it.
|
|
61
|
+
*/
|
|
62
|
+
takeQuota(entries: readonly QuotaEntry[], period: string): Promise<QuotaTake>;
|
|
63
|
+
/** How much of a quota is used in a period. */
|
|
64
|
+
quotaUsed(key: string, period: string): Promise<number>;
|
|
65
|
+
}
|
|
66
|
+
/** Counts in memory: development and tests. */
|
|
67
|
+
export declare class MemoryQuotaStore implements QuotaStore {
|
|
68
|
+
#private;
|
|
69
|
+
takeQuota(entries: readonly QuotaEntry[], period: string): Promise<QuotaTake>;
|
|
70
|
+
quotaUsed(key: string, period: string): Promise<number>;
|
|
71
|
+
}
|
|
72
|
+
/** Counts in `<prefix>_usage`, taken in one transaction, rows locked in a stable order. */
|
|
73
|
+
export declare class PostgresQuotaStore implements QuotaStore {
|
|
74
|
+
#private;
|
|
75
|
+
readonly database: ConnectionPool;
|
|
76
|
+
constructor(database: ConnectionPool, options: {
|
|
77
|
+
tablePrefix: string;
|
|
78
|
+
});
|
|
79
|
+
takeQuota(entries: readonly QuotaEntry[], period: string): Promise<QuotaTake>;
|
|
80
|
+
quotaUsed(key: string, period: string): Promise<number>;
|
|
81
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2025-2026 Datalayer, Inc.
|
|
3
|
+
* Distributed under the terms of the Modified BSD License.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Quotas before costly work (SLACK §9, S-404; DISCORD DC-402): questions an
|
|
7
|
+
* hour per person, installation (a Slack workspace, a Discord server),
|
|
8
|
+
* organization and application, taken atomically before a turn starts at
|
|
9
|
+
* Datalayer. A refusal says which limit and when it resets. Moved from the
|
|
10
|
+
* Slack service (plans/DISCORD.md DC-004).
|
|
11
|
+
*
|
|
12
|
+
* @module portable/hosts/quotas
|
|
13
|
+
*/
|
|
14
|
+
import { tablePrefixOf } from './queue';
|
|
15
|
+
export const DEFAULT_QUOTAS = {
|
|
16
|
+
application: 600,
|
|
17
|
+
installation: 600,
|
|
18
|
+
organization: 2_000,
|
|
19
|
+
user: 60,
|
|
20
|
+
};
|
|
21
|
+
/** The hour a moment falls in: the period counts are kept by. */
|
|
22
|
+
export function windowOf(now) {
|
|
23
|
+
return now.toISOString().slice(0, 13);
|
|
24
|
+
}
|
|
25
|
+
export function resetOf(now) {
|
|
26
|
+
const next = new Date(now);
|
|
27
|
+
next.setUTCMinutes(0, 0, 0);
|
|
28
|
+
next.setUTCHours(next.getUTCHours() + 1);
|
|
29
|
+
return next;
|
|
30
|
+
}
|
|
31
|
+
/** A person's quota key in an installation: what their usage line reads. */
|
|
32
|
+
export function userQuotaKey(installation, externalUserId) {
|
|
33
|
+
return `user:${installation}:${externalUserId}`;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* What each quota counts, for one turn. `keyPrefix` names the installation's
|
|
37
|
+
* key (`installation:<id>` by default; Slack keeps `workspace:<id>`, the keys
|
|
38
|
+
* its counts were kept under).
|
|
39
|
+
*/
|
|
40
|
+
export function quotaEntries(limits, turn, keyPrefix = 'installation') {
|
|
41
|
+
return [
|
|
42
|
+
{
|
|
43
|
+
key: userQuotaKey(turn.installation, turn.externalUserId),
|
|
44
|
+
limit: limits.user,
|
|
45
|
+
scope: 'user',
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
key: `${keyPrefix}:${turn.installation}`,
|
|
49
|
+
limit: limits.installation,
|
|
50
|
+
scope: 'installation',
|
|
51
|
+
},
|
|
52
|
+
...(turn.organizationId
|
|
53
|
+
? [
|
|
54
|
+
{
|
|
55
|
+
key: `organization:${turn.organizationId}`,
|
|
56
|
+
limit: limits.organization,
|
|
57
|
+
scope: 'organization',
|
|
58
|
+
},
|
|
59
|
+
]
|
|
60
|
+
: []),
|
|
61
|
+
{
|
|
62
|
+
key: `application:${turn.applicationId}`,
|
|
63
|
+
limit: limits.application,
|
|
64
|
+
scope: 'application',
|
|
65
|
+
},
|
|
66
|
+
];
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Words for a refusal, said privately. `installation` names the host's
|
|
70
|
+
* installation as its people say it: "this workspace", "this server".
|
|
71
|
+
*/
|
|
72
|
+
export function quotaWords(taken, installation) {
|
|
73
|
+
const whose = {
|
|
74
|
+
application: 'this application',
|
|
75
|
+
installation,
|
|
76
|
+
organization: 'your organization',
|
|
77
|
+
user: 'you',
|
|
78
|
+
};
|
|
79
|
+
const at = taken.resetsAt.toISOString().slice(11, 16);
|
|
80
|
+
return `@Datalayer has answered ${taken.limit} questions this hour for ${whose[taken.scope]}, its limit. Ask again after ${at} UTC.`;
|
|
81
|
+
}
|
|
82
|
+
/** Counts in memory: development and tests. */
|
|
83
|
+
export class MemoryQuotaStore {
|
|
84
|
+
#usage = new Map();
|
|
85
|
+
async takeQuota(entries, period) {
|
|
86
|
+
const spent = entries.find(entry => (this.#usage.get(`${period}:${entry.key}`) ?? 0) >= entry.limit);
|
|
87
|
+
if (spent) {
|
|
88
|
+
return { entry: spent, ok: false };
|
|
89
|
+
}
|
|
90
|
+
for (const entry of entries) {
|
|
91
|
+
this.#usage.set(`${period}:${entry.key}`, (this.#usage.get(`${period}:${entry.key}`) ?? 0) + 1);
|
|
92
|
+
}
|
|
93
|
+
return { ok: true };
|
|
94
|
+
}
|
|
95
|
+
async quotaUsed(key, period) {
|
|
96
|
+
return this.#usage.get(`${period}:${key}`) ?? 0;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** Counts in `<prefix>_usage`, taken in one transaction, rows locked in a stable order. */
|
|
100
|
+
export class PostgresQuotaStore {
|
|
101
|
+
database;
|
|
102
|
+
#usage;
|
|
103
|
+
constructor(database, options) {
|
|
104
|
+
this.database = database;
|
|
105
|
+
this.#usage = `${tablePrefixOf(options.tablePrefix)}_usage`;
|
|
106
|
+
}
|
|
107
|
+
async takeQuota(entries, period) {
|
|
108
|
+
const client = await this.database.connect();
|
|
109
|
+
try {
|
|
110
|
+
await client.query('BEGIN');
|
|
111
|
+
const taken = await this.#take(client, entries, period);
|
|
112
|
+
await client.query(taken.ok ? 'COMMIT' : 'ROLLBACK');
|
|
113
|
+
return taken;
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
await client.query('ROLLBACK');
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
client.release();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
async #take(client, entries, period) {
|
|
124
|
+
// Rows locked in a stable order: two turns never deadlock on them.
|
|
125
|
+
const sorted = [...entries].sort((a, b) => a.key.localeCompare(b.key));
|
|
126
|
+
for (const entry of sorted) {
|
|
127
|
+
await client.query(`INSERT INTO ${this.#usage} (usage_key, usage_window, used) VALUES ($1, $2, 0)
|
|
128
|
+
ON CONFLICT (usage_key, usage_window) DO NOTHING`, [entry.key, period]);
|
|
129
|
+
}
|
|
130
|
+
const keys = sorted.map(entry => entry.key);
|
|
131
|
+
const used = await client.query(`SELECT usage_key, used FROM ${this.#usage}
|
|
132
|
+
WHERE usage_window = $1 AND usage_key = ANY($2::text[])
|
|
133
|
+
ORDER BY usage_key FOR UPDATE`, [period, keys]);
|
|
134
|
+
const counts = new Map(used.rows.map(row => [row.usage_key, row.used]));
|
|
135
|
+
const spent = entries.find(entry => (counts.get(entry.key) ?? 0) >= entry.limit);
|
|
136
|
+
if (spent) {
|
|
137
|
+
return { entry: spent, ok: false };
|
|
138
|
+
}
|
|
139
|
+
await client.query(`UPDATE ${this.#usage} SET used = used + 1
|
|
140
|
+
WHERE usage_window = $1 AND usage_key = ANY($2::text[])`, [period, keys]);
|
|
141
|
+
return { ok: true };
|
|
142
|
+
}
|
|
143
|
+
async quotaUsed(key, period) {
|
|
144
|
+
const result = await this.database.query(`SELECT used FROM ${this.#usage} WHERE usage_key = $1 AND usage_window = $2`, [key, period]);
|
|
145
|
+
return result.rows[0]?.used ?? 0;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The clock a host's rate limiter waits on: the system's, or a test's fake.
|
|
3
|
+
* Each host keeps its own limiter (Slack's method spacing, Discord's
|
|
4
|
+
* buckets), since what a 429 says differs by host. Moved from the Slack
|
|
5
|
+
* service (plans/DISCORD.md DC-004).
|
|
6
|
+
*
|
|
7
|
+
* @module portable/hosts/rateLimit
|
|
8
|
+
*/
|
|
9
|
+
export interface Clock {
|
|
10
|
+
now(): number;
|
|
11
|
+
sleep(milliseconds: number): Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
export declare const systemClock: Clock;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which application a host's turn runs (SLACK §8, S-202; DISCORD §8): the
|
|
3
|
+
* thread's own, else an explicit choice, else the one chosen for the
|
|
4
|
+
* thread's next session, else the channel's, else the installation's
|
|
5
|
+
* default; then that application allowed here and in this kind of
|
|
6
|
+
* conversation. Datalayer's exchange checks the person may open it at every
|
|
7
|
+
* turn: nothing here authorizes alone. The host says each refusal in its own
|
|
8
|
+
* words. Moved from the Slack service (plans/DISCORD.md DC-004).
|
|
9
|
+
*
|
|
10
|
+
* @module portable/hosts/resolution
|
|
11
|
+
*/
|
|
12
|
+
/** An application the installation allows, as a host keeps it to show and choose from. */
|
|
13
|
+
export type AllowanceView = Readonly<{
|
|
14
|
+
applicationId: string;
|
|
15
|
+
name: string;
|
|
16
|
+
enabled: boolean;
|
|
17
|
+
/** Whether it answers in channels, or in direct messages only. */
|
|
18
|
+
channelPolicy: 'channels' | 'direct-only';
|
|
19
|
+
}>;
|
|
20
|
+
/** A channel's own rule: denied, or its default application. */
|
|
21
|
+
export type ChannelRule = Readonly<{
|
|
22
|
+
state: 'allow' | 'deny';
|
|
23
|
+
applicationId?: string;
|
|
24
|
+
}>;
|
|
25
|
+
export type ApplicationRefusal = 'channel-denied' | 'no-application' | 'not-allowed' | 'direct-only';
|
|
26
|
+
export type ApplicationResolution<A extends AllowanceView> = Readonly<{
|
|
27
|
+
ok: true;
|
|
28
|
+
applicationId: string;
|
|
29
|
+
allowance?: A;
|
|
30
|
+
}> | Readonly<{
|
|
31
|
+
ok: false;
|
|
32
|
+
refusal: ApplicationRefusal;
|
|
33
|
+
allowance?: A;
|
|
34
|
+
}>;
|
|
35
|
+
/**
|
|
36
|
+
* The application a turn runs, or why none does. The lookups are read only
|
|
37
|
+
* when the order reaches them. With no allowance kept, any application is
|
|
38
|
+
* taken (Datalayer's exchange still decides).
|
|
39
|
+
*/
|
|
40
|
+
export declare function resolveApplication<A extends AllowanceView>(input: {
|
|
41
|
+
visibility: 'channel' | 'direct';
|
|
42
|
+
/** The thread's application once it has a session: switching is explicit (S-208). */
|
|
43
|
+
threadApplication?: string;
|
|
44
|
+
/** The application chosen with this very prompt. */
|
|
45
|
+
explicit?: string;
|
|
46
|
+
/** The application chosen for the thread's next session. */
|
|
47
|
+
next: () => Promise<string | undefined>;
|
|
48
|
+
/** The channel's own rule; never read in a direct conversation. */
|
|
49
|
+
channel: () => Promise<ChannelRule | undefined>;
|
|
50
|
+
defaultApplication: () => Promise<string | undefined>;
|
|
51
|
+
allowances: readonly A[];
|
|
52
|
+
}): Promise<ApplicationResolution<A>>;
|
|
53
|
+
/** The applications a person may choose from here: enabled, and answering in this kind of conversation. */
|
|
54
|
+
export declare function usableAllowances<A extends AllowanceView>(allowances: readonly A[], visibility: 'channel' | 'direct'): A[];
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2025-2026 Datalayer, Inc.
|
|
3
|
+
* Distributed under the terms of the Modified BSD License.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* The application a turn runs, or why none does. The lookups are read only
|
|
7
|
+
* when the order reaches them. With no allowance kept, any application is
|
|
8
|
+
* taken (Datalayer's exchange still decides).
|
|
9
|
+
*/
|
|
10
|
+
export async function resolveApplication(input) {
|
|
11
|
+
let applicationId;
|
|
12
|
+
if (input.threadApplication) {
|
|
13
|
+
applicationId = input.threadApplication;
|
|
14
|
+
}
|
|
15
|
+
else if (input.explicit) {
|
|
16
|
+
applicationId = input.explicit;
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
applicationId = await input.next();
|
|
20
|
+
if (!applicationId) {
|
|
21
|
+
const channel = input.visibility === 'channel' ? await input.channel() : undefined;
|
|
22
|
+
if (channel?.state === 'deny') {
|
|
23
|
+
return { ok: false, refusal: 'channel-denied' };
|
|
24
|
+
}
|
|
25
|
+
applicationId =
|
|
26
|
+
channel?.applicationId ?? (await input.defaultApplication());
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (!applicationId) {
|
|
30
|
+
return { ok: false, refusal: 'no-application' };
|
|
31
|
+
}
|
|
32
|
+
const allowance = input.allowances.find(one => one.applicationId === applicationId && one.enabled);
|
|
33
|
+
if (input.allowances.length > 0 && !allowance) {
|
|
34
|
+
return { ok: false, refusal: 'not-allowed' };
|
|
35
|
+
}
|
|
36
|
+
if (allowance?.channelPolicy === 'direct-only' &&
|
|
37
|
+
input.visibility === 'channel') {
|
|
38
|
+
return { ok: false, refusal: 'direct-only', allowance };
|
|
39
|
+
}
|
|
40
|
+
return { ok: true, applicationId, allowance };
|
|
41
|
+
}
|
|
42
|
+
/** The applications a person may choose from here: enabled, and answering in this kind of conversation. */
|
|
43
|
+
export function usableAllowances(allowances, visibility) {
|
|
44
|
+
return allowances.filter(one => one.enabled &&
|
|
45
|
+
(one.channelPolicy === 'channels' || visibility === 'direct'));
|
|
46
|
+
}
|