@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,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
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tables every host service keeps the same way, under its own prefix:
|
|
3
|
+
* ingress claims and the work queue (`<prefix>_event_claims`,
|
|
4
|
+
* `<prefix>_jobs`), quota counts (`<prefix>_usage`), privileged payload reads
|
|
5
|
+
* (`<prefix>_payload_access`) and dead-job replays (`<prefix>_job_replays`).
|
|
6
|
+
* Their final shapes, as idempotent statements: a service applies them
|
|
7
|
+
* before its own migrations, every time. TypeScript strings, since the npm
|
|
8
|
+
* tarball ships no `.sql`. Moved from the Slack service's migrations 001,
|
|
9
|
+
* 004 and 007 (plans/DISCORD.md DC-004).
|
|
10
|
+
*
|
|
11
|
+
* @module portable/hosts/schema
|
|
12
|
+
*/
|
|
13
|
+
import { type Queryable } from './queue';
|
|
14
|
+
/** The shared tables of one host, as one idempotent script. */
|
|
15
|
+
export declare function hostSchemaSql(tablePrefix: string): string;
|
|
16
|
+
/** The tables `hostSchemaSql` makes, by name: what a test and an operator check. */
|
|
17
|
+
export declare function hostTables(tablePrefix: string): string[];
|
|
18
|
+
/** Apply the shared tables of one host: idempotent, before the service's own migrations. */
|
|
19
|
+
export declare function applyHostSchema(database: Queryable, tablePrefix: string): Promise<void>;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2025-2026 Datalayer, Inc.
|
|
3
|
+
* Distributed under the terms of the Modified BSD License.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* The tables every host service keeps the same way, under its own prefix:
|
|
7
|
+
* ingress claims and the work queue (`<prefix>_event_claims`,
|
|
8
|
+
* `<prefix>_jobs`), quota counts (`<prefix>_usage`), privileged payload reads
|
|
9
|
+
* (`<prefix>_payload_access`) and dead-job replays (`<prefix>_job_replays`).
|
|
10
|
+
* Their final shapes, as idempotent statements: a service applies them
|
|
11
|
+
* before its own migrations, every time. TypeScript strings, since the npm
|
|
12
|
+
* tarball ships no `.sql`. Moved from the Slack service's migrations 001,
|
|
13
|
+
* 004 and 007 (plans/DISCORD.md DC-004).
|
|
14
|
+
*
|
|
15
|
+
* @module portable/hosts/schema
|
|
16
|
+
*/
|
|
17
|
+
import { tablePrefixOf } from './queue';
|
|
18
|
+
/** The shared tables of one host, as one idempotent script. */
|
|
19
|
+
export function hostSchemaSql(tablePrefix) {
|
|
20
|
+
const p = tablePrefixOf(tablePrefix);
|
|
21
|
+
return `BEGIN;
|
|
22
|
+
|
|
23
|
+
CREATE TABLE IF NOT EXISTS ${p}_event_claims (
|
|
24
|
+
event_id text PRIMARY KEY,
|
|
25
|
+
claimed_at timestamptz NOT NULL DEFAULT now()
|
|
26
|
+
);
|
|
27
|
+
CREATE INDEX IF NOT EXISTS ${p}_event_claims_claimed_at ON ${p}_event_claims (claimed_at);
|
|
28
|
+
|
|
29
|
+
CREATE TABLE IF NOT EXISTS ${p}_jobs (
|
|
30
|
+
id bigserial PRIMARY KEY,
|
|
31
|
+
payload jsonb NOT NULL,
|
|
32
|
+
state text NOT NULL DEFAULT 'ready' CHECK (state IN ('ready', 'processing', 'dead')),
|
|
33
|
+
attempts integer NOT NULL DEFAULT 0,
|
|
34
|
+
available_at timestamptz NOT NULL DEFAULT now(),
|
|
35
|
+
locked_at timestamptz,
|
|
36
|
+
last_error text,
|
|
37
|
+
created_at timestamptz NOT NULL DEFAULT now()
|
|
38
|
+
);
|
|
39
|
+
CREATE INDEX IF NOT EXISTS ${p}_jobs_ready ON ${p}_jobs (state, available_at, id);
|
|
40
|
+
|
|
41
|
+
CREATE TABLE IF NOT EXISTS ${p}_usage (
|
|
42
|
+
usage_key text NOT NULL,
|
|
43
|
+
usage_window text NOT NULL,
|
|
44
|
+
used integer NOT NULL DEFAULT 0,
|
|
45
|
+
PRIMARY KEY (usage_key, usage_window)
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
CREATE TABLE IF NOT EXISTS ${p}_payload_access (
|
|
49
|
+
id bigserial PRIMARY KEY,
|
|
50
|
+
job_id bigint NOT NULL,
|
|
51
|
+
operator text NOT NULL,
|
|
52
|
+
reason text NOT NULL,
|
|
53
|
+
read_at timestamptz NOT NULL DEFAULT now()
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
CREATE TABLE IF NOT EXISTS ${p}_job_replays (
|
|
57
|
+
id bigserial PRIMARY KEY,
|
|
58
|
+
job_id bigint NOT NULL,
|
|
59
|
+
operator text NOT NULL,
|
|
60
|
+
replayed boolean NOT NULL,
|
|
61
|
+
replayed_at timestamptz NOT NULL DEFAULT now()
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
COMMIT;
|
|
65
|
+
`;
|
|
66
|
+
}
|
|
67
|
+
/** The tables `hostSchemaSql` makes, by name: what a test and an operator check. */
|
|
68
|
+
export function hostTables(tablePrefix) {
|
|
69
|
+
const p = tablePrefixOf(tablePrefix);
|
|
70
|
+
return [
|
|
71
|
+
`${p}_event_claims`,
|
|
72
|
+
`${p}_jobs`,
|
|
73
|
+
`${p}_usage`,
|
|
74
|
+
`${p}_payload_access`,
|
|
75
|
+
`${p}_job_replays`,
|
|
76
|
+
];
|
|
77
|
+
}
|
|
78
|
+
/** Apply the shared tables of one host: idempotent, before the service's own migrations. */
|
|
79
|
+
export async function applyHostSchema(database, tablePrefix) {
|
|
80
|
+
await database.query(hostSchemaSql(tablePrefix));
|
|
81
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secrets at rest (SLACK §9, DISCORD §9): AES-256-GCM, each value bound to
|
|
3
|
+
* what it belongs to (its associated data, an installation's key), with the
|
|
4
|
+
* key's version kept beside it. Moved from the Slack service
|
|
5
|
+
* (plans/DISCORD.md DC-004).
|
|
6
|
+
*
|
|
7
|
+
* @module portable/hosts/secretCipher
|
|
8
|
+
*/
|
|
9
|
+
export type EncryptedSecret = Readonly<{
|
|
10
|
+
ciphertext: string;
|
|
11
|
+
iv: string;
|
|
12
|
+
keyVersion: number;
|
|
13
|
+
tag: string;
|
|
14
|
+
}>;
|
|
15
|
+
export declare class SecretCipher {
|
|
16
|
+
#private;
|
|
17
|
+
readonly keyVersion: number;
|
|
18
|
+
constructor(keyBase64: string, keyVersion: number,
|
|
19
|
+
/** The setting the key is read from, named when it is refused. */
|
|
20
|
+
keyName?: string);
|
|
21
|
+
encrypt(value: string, associatedData: string): EncryptedSecret;
|
|
22
|
+
decrypt(secret: EncryptedSecret, associatedData: string): string;
|
|
23
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2025-2026 Datalayer, Inc.
|
|
3
|
+
* Distributed under the terms of the Modified BSD License.
|
|
4
|
+
*/
|
|
5
|
+
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
|
6
|
+
export class SecretCipher {
|
|
7
|
+
keyVersion;
|
|
8
|
+
#key;
|
|
9
|
+
constructor(keyBase64, keyVersion,
|
|
10
|
+
/** The setting the key is read from, named when it is refused. */
|
|
11
|
+
keyName = 'The encryption key') {
|
|
12
|
+
this.keyVersion = keyVersion;
|
|
13
|
+
this.#key = Buffer.from(keyBase64, 'base64');
|
|
14
|
+
if (this.#key.length !== 32) {
|
|
15
|
+
throw new Error(`${keyName} must be a base64-encoded 32-byte key.`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
encrypt(value, associatedData) {
|
|
19
|
+
const iv = randomBytes(12);
|
|
20
|
+
const cipher = createCipheriv('aes-256-gcm', this.#key, iv);
|
|
21
|
+
cipher.setAAD(Buffer.from(associatedData));
|
|
22
|
+
const ciphertext = Buffer.concat([
|
|
23
|
+
cipher.update(value, 'utf8'),
|
|
24
|
+
cipher.final(),
|
|
25
|
+
]);
|
|
26
|
+
return {
|
|
27
|
+
ciphertext: ciphertext.toString('base64'),
|
|
28
|
+
iv: iv.toString('base64'),
|
|
29
|
+
keyVersion: this.keyVersion,
|
|
30
|
+
tag: cipher.getAuthTag().toString('base64'),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
decrypt(secret, associatedData) {
|
|
34
|
+
if (secret.keyVersion !== this.keyVersion) {
|
|
35
|
+
throw new Error(`No decryption key is configured for version ${secret.keyVersion}.`);
|
|
36
|
+
}
|
|
37
|
+
const decipher = createDecipheriv('aes-256-gcm', this.#key, Buffer.from(secret.iv, 'base64'));
|
|
38
|
+
decipher.setAAD(Buffer.from(associatedData));
|
|
39
|
+
decipher.setAuthTag(Buffer.from(secret.tag, 'base64'));
|
|
40
|
+
return Buffer.concat([
|
|
41
|
+
decipher.update(Buffer.from(secret.ciphertext, 'base64')),
|
|
42
|
+
decipher.final(),
|
|
43
|
+
]).toString('utf8');
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<kind>-<40 hex>`: the SHA-256 of the thread's parts joined by `:`, and
|
|
3
|
+
* `:<generation>` once the thread started over or switched application. The
|
|
4
|
+
* same parts give the same uid (Slack: app, enterprise or `-`, team,
|
|
5
|
+
* channel, thread ts; Discord: application, guild, channel, thread).
|
|
6
|
+
*/
|
|
7
|
+
export declare function hostSessionUid(kind: string, parts: readonly string[], generation?: number): string;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2025-2026 Datalayer, Inc.
|
|
3
|
+
* Distributed under the terms of the Modified BSD License.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* One host thread is one Datalayer session: its uid, derived from where the
|
|
7
|
+
* thread is, so that a retry names it again (SLACK §2 rule 2, DISCORD §2
|
|
8
|
+
* rule 2). Moved from the Slack service (plans/DISCORD.md DC-004).
|
|
9
|
+
*
|
|
10
|
+
* @module portable/hosts/sessionUid
|
|
11
|
+
*/
|
|
12
|
+
import { createHash } from 'node:crypto';
|
|
13
|
+
/**
|
|
14
|
+
* `<kind>-<40 hex>`: the SHA-256 of the thread's parts joined by `:`, and
|
|
15
|
+
* `:<generation>` once the thread started over or switched application. The
|
|
16
|
+
* same parts give the same uid (Slack: app, enterprise or `-`, team,
|
|
17
|
+
* channel, thread ts; Discord: application, guild, channel, thread).
|
|
18
|
+
*/
|
|
19
|
+
export function hostSessionUid(kind, parts, generation) {
|
|
20
|
+
if (!/^[a-z][a-z0-9-]{1,31}$/.test(kind)) {
|
|
21
|
+
throw new Error(`A host kind is lowercase letters and digits: ${kind}.`);
|
|
22
|
+
}
|
|
23
|
+
if (parts.length === 0 || parts.some(part => part === '')) {
|
|
24
|
+
throw new Error('A thread is named by its parts, none of them empty.');
|
|
25
|
+
}
|
|
26
|
+
const key = `${parts.join(':')}${generation ? `:${generation}` : ''}`;
|
|
27
|
+
return `${kind}-${createHash('sha256').update(key).digest('hex').slice(0, 40)}`;
|
|
28
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A turn's state as a chat host shows it, from its semantic events: the
|
|
3
|
+
* answer so far, the tools, the status line, the approvals waited on, the
|
|
4
|
+
* question it ends on, and how it ended. Each host renders it in its own
|
|
5
|
+
* blocks (Slack's Block Kit, Discord's components); nothing here is a
|
|
6
|
+
* host's. Moved from the Slack service (plans/DISCORD.md DC-004).
|
|
7
|
+
*
|
|
8
|
+
* @module portable/hosts/turnState
|
|
9
|
+
*/
|
|
10
|
+
import type { AskQuestion, SemanticEvent } from '../semantic';
|
|
11
|
+
export type TurnEnding = 'completion' | 'stop' | 'error';
|
|
12
|
+
export type TurnTool = Readonly<{
|
|
13
|
+
name: string;
|
|
14
|
+
status: string;
|
|
15
|
+
}>;
|
|
16
|
+
export type TurnFile = Readonly<{
|
|
17
|
+
mediaType?: string;
|
|
18
|
+
name: string;
|
|
19
|
+
url: string;
|
|
20
|
+
}>;
|
|
21
|
+
export declare class TurnState {
|
|
22
|
+
#private;
|
|
23
|
+
/** Whether the turn finished while it streamed: else a `turn.completed` delivery finishes it. */
|
|
24
|
+
get complete(): boolean;
|
|
25
|
+
/** Whether the turn ended (answered, stopped, failed) or waits on a question. */
|
|
26
|
+
get ended(): boolean;
|
|
27
|
+
/** Whether the turn still runs: not ended, and asking nothing. */
|
|
28
|
+
get running(): boolean;
|
|
29
|
+
/** The answer so far, its tables and citations as Markdown. */
|
|
30
|
+
get answer(): string;
|
|
31
|
+
/** The question the turn ends on, if any. */
|
|
32
|
+
get asking(): AskQuestion | undefined;
|
|
33
|
+
/** The tools whose approval the turn waited on, in order. */
|
|
34
|
+
get approvals(): readonly string[];
|
|
35
|
+
/** How the turn ended, if it did. */
|
|
36
|
+
get ending(): TurnEnding | undefined;
|
|
37
|
+
/** What the run said when it failed. */
|
|
38
|
+
get error(): string | undefined;
|
|
39
|
+
get files(): readonly TurnFile[];
|
|
40
|
+
/** The status line: what the turn is doing now. */
|
|
41
|
+
get status(): string;
|
|
42
|
+
/** Each tool the turn used, by its call, in the order first seen. */
|
|
43
|
+
get tools(): ReadonlyMap<string, TurnTool>;
|
|
44
|
+
apply(event: SemanticEvent): void;
|
|
45
|
+
}
|