@datalayer/agent-runtimes 1.5.44 → 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/chunks/{ChatView-kCmBbnDa.js → ChatView-Dy-IgGFL.js} +1 -1
- package/dist-embed/datalayer-app-main.js +99 -94
- package/lib/apps/plugins/chat/ChatView.js +2 -2
- package/lib/chat/ChatFloating.js +1 -1
- package/lib/chat/base/ChatBase.js +23 -8
- package/lib/chat/messages/ChatMessageList.d.ts +7 -1
- package/lib/chat/messages/ChatMessageList.js +8 -2
- 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/protocols/VercelAIAdapter.js +11 -0
- package/lib/specs/voices.js +62 -0
- package/lib/test-setup.js +17 -14
- package/lib/types/chat.d.ts +10 -0
- package/lib/voice/ComposerVoice.js +12 -3
- package/lib/voice/captions.d.ts +31 -0
- package/lib/voice/captions.js +156 -0
- package/lib/voice/handsFree.d.ts +12 -0
- package/lib/voice/handsFree.js +18 -0
- package/lib/voice/serverHearing.d.ts +29 -0
- package/lib/voice/serverHearing.js +78 -2
- package/package.json +1 -1
- package/scripts/voice/measure.py +77 -17
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credentials by their shape, for every host at once (redacting another
|
|
3
|
+
* host's shape too is never wrong): Slack's bot, user and app tokens,
|
|
4
|
+
* Discord's bot tokens (`<base64 id>.<6>.<27+>`), Datalayer's host keys,
|
|
5
|
+
* link and handoff codes, JWTs (which a Discord interaction token is not, so
|
|
6
|
+
* it is named by its field), and bearer credentials.
|
|
7
|
+
*/
|
|
8
|
+
export declare const TOKEN_PATTERNS: readonly RegExp[];
|
|
9
|
+
export declare const REDACTED = "[redacted]";
|
|
10
|
+
/** A copy of a value with every secret field and credential-like string redacted. */
|
|
11
|
+
export declare function redact(value: unknown, depth?: number): unknown;
|
|
12
|
+
/**
|
|
13
|
+
* A copy with credentials redacted but words kept: what an audited,
|
|
14
|
+
* privileged payload read shows (S-405).
|
|
15
|
+
*/
|
|
16
|
+
export declare function redactCredentials(value: unknown, depth?: number): unknown;
|
|
17
|
+
/**
|
|
18
|
+
* A stable pseudonym of an id: the same id and salt, the same 16 hex
|
|
19
|
+
* characters; never reversible. Each host salts with its own name.
|
|
20
|
+
*/
|
|
21
|
+
export declare function pseudonym(value: string, salt: string): string;
|
|
22
|
+
export type LogSink = (line: string) => void;
|
|
23
|
+
/** A JSON logger whose every field is redacted. */
|
|
24
|
+
export declare class Logger {
|
|
25
|
+
#private;
|
|
26
|
+
readonly sink: LogSink;
|
|
27
|
+
readonly clock: () => Date;
|
|
28
|
+
/** What the audit's pseudonyms are salted with: the host's own. */
|
|
29
|
+
readonly salt: string;
|
|
30
|
+
constructor(sink?: LogSink, clock?: () => Date,
|
|
31
|
+
/** What the audit's pseudonyms are salted with: the host's own. */
|
|
32
|
+
salt?: string);
|
|
33
|
+
info(event: string, fields?: Record<string, unknown>): void;
|
|
34
|
+
warn(event: string, fields?: Record<string, unknown>): void;
|
|
35
|
+
error(event: string, fields?: Record<string, unknown>): void;
|
|
36
|
+
/**
|
|
37
|
+
* An identity audit event (SLACK S-103): who (pseudonymous), which installation
|
|
38
|
+
* (its hash), what happened, why. Never a code, a token or a principal's
|
|
39
|
+
* name.
|
|
40
|
+
*/
|
|
41
|
+
audit(event: 'identity.linked' | 'identity.unlinked' | 'identity.refused' | 'admin.changed' | 'payload.read', fields: {
|
|
42
|
+
installation: string;
|
|
43
|
+
/** The host user it is about. */
|
|
44
|
+
user?: string;
|
|
45
|
+
reason?: string;
|
|
46
|
+
by?: string;
|
|
47
|
+
[name: string]: unknown;
|
|
48
|
+
}): void;
|
|
49
|
+
}
|
|
50
|
+
/** Tracing: one span per job, named by what it is, its ids pseudonymous. */
|
|
51
|
+
export interface Tracer {
|
|
52
|
+
span<T>(name: string, attributes: Record<string, string | number | boolean>, work: () => Promise<T>): Promise<T>;
|
|
53
|
+
}
|
|
54
|
+
/** No tracing: what tests and development use. */
|
|
55
|
+
export declare const noTracer: Tracer;
|
|
56
|
+
/**
|
|
57
|
+
* Spans as log lines with their duration and outcome, until the service runs
|
|
58
|
+
* beside the plane's OTEL collector (`OTEL_EXPORTER_OTLP_TRACES_ENDPOINT`,
|
|
59
|
+
* the Datalayer convention); each host names its attributes
|
|
60
|
+
* (`datalayer.slack.*`, `datalayer.discord.*`), pseudonymous by construction.
|
|
61
|
+
*/
|
|
62
|
+
export declare class LogTracer implements Tracer {
|
|
63
|
+
readonly logger: Logger;
|
|
64
|
+
readonly now: () => number;
|
|
65
|
+
constructor(logger: Logger, now?: () => number);
|
|
66
|
+
span<T>(name: string, attributes: Record<string, string | number | boolean>, work: () => Promise<T>): Promise<T>;
|
|
67
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2025-2026 Datalayer, Inc.
|
|
3
|
+
* Distributed under the terms of the Modified BSD License.
|
|
4
|
+
*/
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
/**
|
|
7
|
+
* Logs, audit events and traces without secrets or words (SLACK §9, §12.2;
|
|
8
|
+
* DISCORD §9, §12.2): every routine line is JSON whose fields pass `redact`;
|
|
9
|
+
* host and Datalayer ids are pseudonymous (`pseudonym`), so a trace can be
|
|
10
|
+
* followed `host event → job → installation hash → session → host message`
|
|
11
|
+
* without naming an installation, a person or what they wrote. Moved from
|
|
12
|
+
* the Slack service (plans/DISCORD.md DC-004).
|
|
13
|
+
*
|
|
14
|
+
* @module portable/hosts/observability
|
|
15
|
+
*/
|
|
16
|
+
/** Field names whose values are never logged: credentials, codes and words. */
|
|
17
|
+
const SECRET_NAME = /(token|secret|password|authorization|cookie|signature|^code$|_code$|host_?key|api_?key|private|^text$|^body$|prompt|message|markdown|arguments|payload|value)/i;
|
|
18
|
+
/**
|
|
19
|
+
* Credentials by their shape, for every host at once (redacting another
|
|
20
|
+
* host's shape too is never wrong): Slack's bot, user and app tokens,
|
|
21
|
+
* Discord's bot tokens (`<base64 id>.<6>.<27+>`), Datalayer's host keys,
|
|
22
|
+
* link and handoff codes, JWTs (which a Discord interaction token is not, so
|
|
23
|
+
* it is named by its field), and bearer credentials.
|
|
24
|
+
*/
|
|
25
|
+
export const TOKEN_PATTERNS = [
|
|
26
|
+
/xox[abprse]-[A-Za-z0-9-]{8,}/,
|
|
27
|
+
/xapp-[A-Za-z0-9-]{8,}/,
|
|
28
|
+
/[A-Za-z0-9_-]{23,28}\.[A-Za-z0-9_-]{6}\.[A-Za-z0-9_-]{27,40}/,
|
|
29
|
+
/dlh[kco]_[A-Za-z0-9_-]{8,}/,
|
|
30
|
+
/eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{5,}/,
|
|
31
|
+
/Bearer\s+[A-Za-z0-9._~+/=-]{8,}/,
|
|
32
|
+
];
|
|
33
|
+
/** Values that look like credentials wherever they appear. */
|
|
34
|
+
const SECRET_VALUE = new RegExp(`\\b(${TOKEN_PATTERNS.map(pattern => pattern.source).join('|')})`, 'g');
|
|
35
|
+
export const REDACTED = '[redacted]';
|
|
36
|
+
/** A copy of a value with every secret field and credential-like string redacted. */
|
|
37
|
+
export function redact(value, depth = 0) {
|
|
38
|
+
if (depth > 6)
|
|
39
|
+
return REDACTED;
|
|
40
|
+
if (typeof value === 'string')
|
|
41
|
+
return value.replace(SECRET_VALUE, REDACTED);
|
|
42
|
+
if (Array.isArray(value))
|
|
43
|
+
return value.map(one => redact(one, depth + 1));
|
|
44
|
+
if (value instanceof Error)
|
|
45
|
+
return { name: value.name, message: redact(value.message, depth + 1) };
|
|
46
|
+
if (value && typeof value === 'object') {
|
|
47
|
+
const out = {};
|
|
48
|
+
for (const [name, one] of Object.entries(value))
|
|
49
|
+
out[name] = SECRET_NAME.test(name) ? REDACTED : redact(one, depth + 1);
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
/** Field names of credentials only: what even a privileged reader never sees. */
|
|
55
|
+
const CREDENTIAL_NAME = /(token|secret|password|authorization|cookie|signature|host_?key|api_?key|private_?key)/i;
|
|
56
|
+
/**
|
|
57
|
+
* A copy with credentials redacted but words kept: what an audited,
|
|
58
|
+
* privileged payload read shows (S-405).
|
|
59
|
+
*/
|
|
60
|
+
export function redactCredentials(value, depth = 0) {
|
|
61
|
+
if (depth > 8)
|
|
62
|
+
return REDACTED;
|
|
63
|
+
if (typeof value === 'string')
|
|
64
|
+
return value.replace(SECRET_VALUE, REDACTED);
|
|
65
|
+
if (Array.isArray(value))
|
|
66
|
+
return value.map(one => redactCredentials(one, depth + 1));
|
|
67
|
+
if (value && typeof value === 'object') {
|
|
68
|
+
const out = {};
|
|
69
|
+
for (const [name, one] of Object.entries(value))
|
|
70
|
+
out[name] = CREDENTIAL_NAME.test(name)
|
|
71
|
+
? REDACTED
|
|
72
|
+
: redactCredentials(one, depth + 1);
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* A stable pseudonym of an id: the same id and salt, the same 16 hex
|
|
79
|
+
* characters; never reversible. Each host salts with its own name.
|
|
80
|
+
*/
|
|
81
|
+
export function pseudonym(value, salt) {
|
|
82
|
+
return createHash('sha256')
|
|
83
|
+
.update(`${salt}:${value}`)
|
|
84
|
+
.digest('hex')
|
|
85
|
+
.slice(0, 16);
|
|
86
|
+
}
|
|
87
|
+
/** A JSON logger whose every field is redacted. */
|
|
88
|
+
export class Logger {
|
|
89
|
+
sink;
|
|
90
|
+
clock;
|
|
91
|
+
salt;
|
|
92
|
+
constructor(sink = line => process.stdout.write(`${line}\n`), clock = () => new Date(),
|
|
93
|
+
/** What the audit's pseudonyms are salted with: the host's own. */
|
|
94
|
+
salt = 'datalayer-host') {
|
|
95
|
+
this.sink = sink;
|
|
96
|
+
this.clock = clock;
|
|
97
|
+
this.salt = salt;
|
|
98
|
+
}
|
|
99
|
+
#write(level, event, fields) {
|
|
100
|
+
this.sink(JSON.stringify({
|
|
101
|
+
at: this.clock().toISOString(),
|
|
102
|
+
level,
|
|
103
|
+
event,
|
|
104
|
+
...redact(fields),
|
|
105
|
+
}));
|
|
106
|
+
}
|
|
107
|
+
info(event, fields = {}) {
|
|
108
|
+
this.#write('info', event, fields);
|
|
109
|
+
}
|
|
110
|
+
warn(event, fields = {}) {
|
|
111
|
+
this.#write('warn', event, fields);
|
|
112
|
+
}
|
|
113
|
+
error(event, fields = {}) {
|
|
114
|
+
this.#write('error', event, fields);
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* An identity audit event (SLACK S-103): who (pseudonymous), which installation
|
|
118
|
+
* (its hash), what happened, why. Never a code, a token or a principal's
|
|
119
|
+
* name.
|
|
120
|
+
*/
|
|
121
|
+
audit(event, fields) {
|
|
122
|
+
const { installation, user, by, ...rest } = fields;
|
|
123
|
+
this.#write('audit', event, {
|
|
124
|
+
...rest,
|
|
125
|
+
installation: pseudonym(installation, this.salt),
|
|
126
|
+
...(user ? { user: pseudonym(user, this.salt) } : {}),
|
|
127
|
+
...(by ? { by: pseudonym(by, this.salt) } : {}),
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/** No tracing: what tests and development use. */
|
|
132
|
+
export const noTracer = {
|
|
133
|
+
span: async (_name, _attributes, work) => work(),
|
|
134
|
+
};
|
|
135
|
+
/**
|
|
136
|
+
* Spans as log lines with their duration and outcome, until the service runs
|
|
137
|
+
* beside the plane's OTEL collector (`OTEL_EXPORTER_OTLP_TRACES_ENDPOINT`,
|
|
138
|
+
* the Datalayer convention); each host names its attributes
|
|
139
|
+
* (`datalayer.slack.*`, `datalayer.discord.*`), pseudonymous by construction.
|
|
140
|
+
*/
|
|
141
|
+
export class LogTracer {
|
|
142
|
+
logger;
|
|
143
|
+
now;
|
|
144
|
+
constructor(logger, now = () => performance.now()) {
|
|
145
|
+
this.logger = logger;
|
|
146
|
+
this.now = now;
|
|
147
|
+
}
|
|
148
|
+
async span(name, attributes, work) {
|
|
149
|
+
const started = this.now();
|
|
150
|
+
try {
|
|
151
|
+
const result = await work();
|
|
152
|
+
this.logger.info('span', {
|
|
153
|
+
name,
|
|
154
|
+
...attributes,
|
|
155
|
+
duration_ms: Math.round(this.now() - started),
|
|
156
|
+
outcome: 'ok',
|
|
157
|
+
});
|
|
158
|
+
return result;
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
this.logger.info('span', {
|
|
162
|
+
name,
|
|
163
|
+
...attributes,
|
|
164
|
+
duration_ms: Math.round(this.now() - started),
|
|
165
|
+
outcome: 'error',
|
|
166
|
+
error_name: error instanceof Error ? error.name : 'unknown',
|
|
167
|
+
});
|
|
168
|
+
throw error;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The durable work queue on Postgres: jobs claimed with `FOR UPDATE SKIP
|
|
3
|
+
* LOCKED`, leased and renewed while worked, retried with backoff, dead after
|
|
4
|
+
* five attempts, an ingress claim and its job kept in one transaction. Its
|
|
5
|
+
* tables are `<prefix>_jobs`, `<prefix>_event_claims` and
|
|
6
|
+
* `<prefix>_job_replays` (`hostSchemaSql`). Moved from the Slack service
|
|
7
|
+
* (plans/DISCORD.md DC-004).
|
|
8
|
+
*
|
|
9
|
+
* @module portable/hosts/postgresQueue
|
|
10
|
+
*/
|
|
11
|
+
import { type ConnectionPool, type WorkQueue } from './queue';
|
|
12
|
+
export declare class PostgresWorkQueue<T> implements WorkQueue<T> {
|
|
13
|
+
#private;
|
|
14
|
+
readonly database: ConnectionPool;
|
|
15
|
+
/** The host's table prefix: `slack`, `discord`. */
|
|
16
|
+
readonly tablePrefix: string;
|
|
17
|
+
readonly pollIntervalMs: number;
|
|
18
|
+
/**
|
|
19
|
+
* How often a job being worked renews its lease (S-207): well within the
|
|
20
|
+
* five minutes after which `reclaimAbandonedJobs` takes a job back, so
|
|
21
|
+
* only a worker that died loses its job, not one answering a long turn.
|
|
22
|
+
*/
|
|
23
|
+
readonly leaseRenewMs: number;
|
|
24
|
+
constructor(database: ConnectionPool,
|
|
25
|
+
/** The host's table prefix: `slack`, `discord`. */
|
|
26
|
+
tablePrefix: string, pollIntervalMs?: number,
|
|
27
|
+
/**
|
|
28
|
+
* How often a job being worked renews its lease (S-207): well within the
|
|
29
|
+
* five minutes after which `reclaimAbandonedJobs` takes a job back, so
|
|
30
|
+
* only a worker that died loses its job, not one answering a long turn.
|
|
31
|
+
*/
|
|
32
|
+
leaseRenewMs?: number);
|
|
33
|
+
enqueue(item: T, idempotencyKey?: string): Promise<boolean>;
|
|
34
|
+
ready(): Promise<boolean>;
|
|
35
|
+
/** Jobs that failed five times, with their metadata only: what the dead-letter tool lists. */
|
|
36
|
+
dead(limit?: number): Promise<Array<{
|
|
37
|
+
id: string;
|
|
38
|
+
attempts: number;
|
|
39
|
+
lastError: string | null;
|
|
40
|
+
createdAt: string;
|
|
41
|
+
kind: string;
|
|
42
|
+
}>>;
|
|
43
|
+
/**
|
|
44
|
+
* Put a dead job back to work: once, by id, the replay kept with who asked
|
|
45
|
+
* in the same transaction (`<prefix>_job_replays`, SLACK S-405), refused or not.
|
|
46
|
+
*/
|
|
47
|
+
replay(id: string, operator: string): Promise<boolean>;
|
|
48
|
+
/**
|
|
49
|
+
* A dead job's payload, and only a dead one's (S-405): a live job, or an id
|
|
50
|
+
* that is no job, has none to read here.
|
|
51
|
+
*/
|
|
52
|
+
payloadOf(id: string): Promise<unknown>;
|
|
53
|
+
start(handler: (item: T) => Promise<void>): void;
|
|
54
|
+
stop(): Promise<void>;
|
|
55
|
+
}
|
|
@@ -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
|
+
}
|