@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.
Files changed (52) hide show
  1. package/dist-embed/datalayer-app-main.js +3 -3
  2. package/lib/portable/hosts/__tests__/fixtures/lock-with-jupyter.json +40 -0
  3. package/lib/portable/hosts/__tests__/fixtures/npm-ls-with-jupyter.json +21 -0
  4. package/lib/portable/hosts/agentClient.d.ts +167 -0
  5. package/lib/portable/hosts/agentClient.js +346 -0
  6. package/lib/portable/hosts/approvalRenderers.d.ts +43 -0
  7. package/lib/portable/hosts/approvalRenderers.js +180 -0
  8. package/lib/portable/hosts/client.d.ts +95 -0
  9. package/lib/portable/hosts/client.js +174 -0
  10. package/lib/portable/hosts/contracts.d.ts +199 -0
  11. package/lib/portable/hosts/contracts.js +235 -0
  12. package/lib/portable/hosts/contracts.json +381 -0
  13. package/lib/portable/hosts/deadLetters.d.ts +41 -0
  14. package/lib/portable/hosts/deadLetters.js +124 -0
  15. package/lib/portable/hosts/deliveries.d.ts +76 -0
  16. package/lib/portable/hosts/deliveries.js +147 -0
  17. package/lib/portable/hosts/forbiddenGraph.d.ts +77 -0
  18. package/lib/portable/hosts/forbiddenGraph.js +174 -0
  19. package/lib/portable/hosts/health.d.ts +35 -0
  20. package/lib/portable/hosts/health.js +92 -0
  21. package/lib/portable/hosts/index.d.ts +40 -0
  22. package/lib/portable/hosts/index.js +45 -0
  23. package/lib/portable/hosts/observability.d.ts +67 -0
  24. package/lib/portable/hosts/observability.js +171 -0
  25. package/lib/portable/hosts/postgresQueue.d.ts +55 -0
  26. package/lib/portable/hosts/postgresQueue.js +217 -0
  27. package/lib/portable/hosts/queue.d.ts +47 -0
  28. package/lib/portable/hosts/queue.js +63 -0
  29. package/lib/portable/hosts/quotas.d.ts +81 -0
  30. package/lib/portable/hosts/quotas.js +147 -0
  31. package/lib/portable/hosts/rateLimit.d.ts +13 -0
  32. package/lib/portable/hosts/rateLimit.js +8 -0
  33. package/lib/portable/hosts/resolution.d.ts +54 -0
  34. package/lib/portable/hosts/resolution.js +46 -0
  35. package/lib/portable/hosts/schema.d.ts +19 -0
  36. package/lib/portable/hosts/schema.js +81 -0
  37. package/lib/portable/hosts/secretCipher.d.ts +23 -0
  38. package/lib/portable/hosts/secretCipher.js +45 -0
  39. package/lib/portable/hosts/sessionUid.d.ts +7 -0
  40. package/lib/portable/hosts/sessionUid.js +28 -0
  41. package/lib/portable/hosts/turnState.d.ts +45 -0
  42. package/lib/portable/hosts/turnState.js +113 -0
  43. package/lib/portable/index.d.ts +2 -0
  44. package/lib/portable/index.js +3 -0
  45. package/lib/portable/semantic-events.json +480 -0
  46. package/lib/portable/semantic.d.ts +78 -0
  47. package/lib/portable/semantic.js +100 -0
  48. package/lib/portable/semanticOfAgUi.d.ts +41 -0
  49. package/lib/portable/semanticOfAgUi.js +108 -0
  50. package/lib/protocols/A2AAdapter.js +19 -0
  51. package/lib/test-setup.js +17 -14
  52. package/package.json +1 -1
@@ -0,0 +1,124 @@
1
+ /*
2
+ * Copyright (c) 2025-2026 Datalayer, Inc.
3
+ * Distributed under the terms of the Modified BSD License.
4
+ */
5
+ /**
6
+ * Dead letters (SLACK §9, S-405; DISCORD DC-403): jobs that failed five
7
+ * times. By default an operator sees their metadata and a hash of their
8
+ * payload — never the words of a prompt or a delivery. Replaying puts one
9
+ * back to work, once. Reading a payload is privileged: a dead job's only, it
10
+ * needs a reason, is recorded in `<prefix>_payload_access` and audited, and
11
+ * its credentials are still redacted. Every replay is kept in
12
+ * `<prefix>_job_replays` with who asked. Moved from the Slack service
13
+ * (plans/DISCORD.md DC-004); each service keeps only the bin that opens its
14
+ * database and calls `runDeadLettersCli`.
15
+ *
16
+ * @module portable/hosts/deadLetters
17
+ */
18
+ import { createHash } from 'node:crypto';
19
+ import { Logger, redact, redactCredentials } from './observability';
20
+ import { PostgresWorkQueue } from './postgresQueue';
21
+ import { tablePrefixOf } from './queue';
22
+ export function payloadHash(payload) {
23
+ return createHash('sha256').update(JSON.stringify(payload)).digest('hex');
24
+ }
25
+ export class DeadLetters {
26
+ database;
27
+ queue;
28
+ logger;
29
+ #access;
30
+ #queueName;
31
+ constructor(database, queue, logger = new Logger()) {
32
+ this.database = database;
33
+ this.queue = queue;
34
+ this.logger = logger;
35
+ const prefix = tablePrefixOf(queue.tablePrefix);
36
+ this.#access = `${prefix}_payload_access`;
37
+ this.#queueName = `${prefix}-queue`;
38
+ }
39
+ async list(limit = 50) {
40
+ const dead = await this.queue.dead(limit);
41
+ return Promise.all(dead.map(async (one) => ({
42
+ attempts: one.attempts,
43
+ createdAt: one.createdAt,
44
+ id: one.id,
45
+ kind: one.kind,
46
+ lastError: String(redact(one.lastError ?? '')),
47
+ payloadHash: payloadHash(await this.queue.payloadOf(one.id)),
48
+ })));
49
+ }
50
+ async replay(id, operator) {
51
+ if (!operator.trim()) {
52
+ throw new Error('Name who replays the job.');
53
+ }
54
+ const replayed = await this.queue.replay(id, operator);
55
+ this.logger.audit('admin.changed', {
56
+ by: operator,
57
+ installation: this.#queueName,
58
+ job: id,
59
+ reason: replayed ? 'dead job replayed' : 'not a dead job',
60
+ });
61
+ return replayed;
62
+ }
63
+ /** A payload, for a stated reason, recorded and audited; secrets still redacted. */
64
+ async payload(id, operator, reason) {
65
+ if (reason.trim().length < 10) {
66
+ throw new Error('Say why the payload is read: ten characters at least.');
67
+ }
68
+ if (!operator.trim()) {
69
+ throw new Error('Name who reads the payload.');
70
+ }
71
+ // A dead job's only: a live job or an unknown id is refused, and no read
72
+ // of it is recorded.
73
+ const payload = await this.queue.payloadOf(id);
74
+ if (payload === undefined) {
75
+ throw new Error(`Job ${id} is not a dead job: there is no payload to read.`);
76
+ }
77
+ await this.database.query(`INSERT INTO ${this.#access} (job_id, operator, reason) VALUES ($1, $2, $3)`, [id, operator, reason.slice(0, 1_000)]);
78
+ this.logger.audit('payload.read', {
79
+ by: operator,
80
+ installation: this.#queueName,
81
+ job: id,
82
+ reason: 'privileged payload access',
83
+ });
84
+ return redactCredentials(payload);
85
+ }
86
+ }
87
+ /** The dead letters of a host's queue (`<prefix>_jobs`). */
88
+ export function deadLetters(database, tablePrefix, logger) {
89
+ return new DeadLetters(database, new PostgresWorkQueue(database, tablePrefix), logger);
90
+ }
91
+ export const DEAD_LETTERS_USAGE = 'Usage: dead-letters list | replay <id> | payload <id> <reason>';
92
+ /**
93
+ * `dead-letters list | replay <id> | payload <id> <reason…>`: metadata and
94
+ * hashes by default (S-405). Who acts is said in `operator`
95
+ * (`DATALAYER_OPERATOR`), never taken from the container's user: a replay
96
+ * and a payload read are kept with this name. Answers the exit code.
97
+ */
98
+ export async function runDeadLettersCli(letters, argv, operator, out = {
99
+ table: rows => console.table(rows),
100
+ info: line => console.info(line),
101
+ error: line => console.error(line),
102
+ }) {
103
+ const [command, id, ...rest] = argv;
104
+ if (!operator.trim()) {
105
+ out.error('DATALAYER_OPERATOR is required: the name kept with every replay and payload read.');
106
+ return 1;
107
+ }
108
+ if (command === 'list') {
109
+ out.table(await letters.list());
110
+ return 0;
111
+ }
112
+ if (command === 'replay' && id) {
113
+ out.info((await letters.replay(id, operator))
114
+ ? `Job ${id} is back to work.`
115
+ : `Job ${id} is not a dead job.`);
116
+ return 0;
117
+ }
118
+ if (command === 'payload' && id) {
119
+ out.info(JSON.stringify(await letters.payload(id, operator, rest.join(' ')), null, 2));
120
+ return 0;
121
+ }
122
+ out.error(DEAD_LETTERS_USAGE);
123
+ return 1;
124
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Datalayer's deliveries to a chat host (SLACK §4.3, 3; DISCORD §3.8): the
3
+ * signed webhook ai-agents pushes to, the outbox read when a push was
4
+ * missed, and each delivery handled once then acknowledged. What a delivery
5
+ * shows is the host's own (its `DeliveryHandler`); where its installations
6
+ * and claims are kept is its store, seen here as a `DeliverySink` and a
7
+ * `DeliveryClaimStore`. Moved from the Slack service (plans/DISCORD.md
8
+ * DC-004).
9
+ *
10
+ * @module portable/hosts/deliveries
11
+ */
12
+ import type { IncomingMessage, ServerResponse } from 'node:http';
13
+ import type { DatalayerHostClient } from './client';
14
+ import { type HostDelivery } from './contracts';
15
+ /** Where Datalayer pushes an installation's deliveries, under the service's public address. */
16
+ export declare const DELIVERY_PATH = "/datalayer/deliveries";
17
+ export declare const DELIVERY_ROUTE = "/datalayer/deliveries/:hostUid";
18
+ export type DeliveryJob = Readonly<{
19
+ delivery: HostDelivery;
20
+ hostUid: string;
21
+ }>;
22
+ /** A host installation bound at Datalayer: its uid and key. */
23
+ export type BoundHostKey = Readonly<{
24
+ hostKey: string;
25
+ hostUid: string;
26
+ }>;
27
+ /** Where a host keeps its installations, as deliveries read them. */
28
+ export interface DeliverySink {
29
+ /** What Datalayer signs an installation's pushes with, or `undefined` for an unknown one. */
30
+ deliverySecretOf(hostUid: string): Promise<string | undefined>;
31
+ /** The installation's key, or `undefined` once it was removed (late work is dropped). */
32
+ hostKeyOf(hostUid: string): Promise<string | undefined>;
33
+ /** Every installation bound at Datalayer: whose outbox is read. */
34
+ boundHosts(): Promise<readonly BoundHostKey[]>;
35
+ }
36
+ /** Where a host keeps what it handled once: a claim taken, or released when handling failed. */
37
+ export interface DeliveryClaimStore {
38
+ claimEvent(id: string): Promise<boolean>;
39
+ releaseEvent(id: string): Promise<void>;
40
+ }
41
+ /**
42
+ * Handle one delivery of an installation once (claimed by its uid), then
43
+ * acknowledge it at Datalayer. A removed installation's delivery is dropped;
44
+ * a handling that fails releases its claim, so the queue tries again.
45
+ */
46
+ export declare function receiveDelivery(job: DeliveryJob, input: {
47
+ sink: DeliverySink;
48
+ claims: DeliveryClaimStore;
49
+ client: Pick<DatalayerHostClient, 'acknowledge'>;
50
+ handle: (delivery: HostDelivery) => Promise<void>;
51
+ }): Promise<void>;
52
+ /**
53
+ * The signed webhook Datalayer pushes to (`POST /datalayer/deliveries/{host}`,
54
+ * registered with `PUT …/self/delivery`): verified with that installation's
55
+ * secret — HMAC over `<timestamp>.<body>`, five minutes at most — then queued
56
+ * and acknowledged with 2xx at once; the work runs from the queue.
57
+ */
58
+ export declare function deliveryRoute(sink: Pick<DeliverySink, 'deliverySecretOf'>, enqueue: (job: DeliveryJob) => Promise<void>, nowSeconds?: () => number): (request: IncomingMessage & {
59
+ params?: Record<string, string | string[]>;
60
+ }, response: ServerResponse) => Promise<void>;
61
+ /**
62
+ * The outbox, read when a push was missed or no address is registered
63
+ * (`GET …/self/deliveries`): each delivery of every bound installation
64
+ * queued, handled once, then acknowledged.
65
+ */
66
+ export declare class DeliveryPoller {
67
+ #private;
68
+ readonly sink: Pick<DeliverySink, 'boundHosts'>;
69
+ readonly client: Pick<DatalayerHostClient, 'outbox'>;
70
+ readonly enqueue: (job: DeliveryJob) => Promise<void>;
71
+ readonly logger: Pick<Console, 'warn'>;
72
+ constructor(sink: Pick<DeliverySink, 'boundHosts'>, client: Pick<DatalayerHostClient, 'outbox'>, enqueue: (job: DeliveryJob) => Promise<void>, logger?: Pick<Console, 'warn'>);
73
+ pollOnce(): Promise<number>;
74
+ start(intervalSeconds: number): void;
75
+ stop(): void;
76
+ }
@@ -0,0 +1,147 @@
1
+ /*
2
+ * Copyright (c) 2025-2026 Datalayer, Inc.
3
+ * Distributed under the terms of the Modified BSD License.
4
+ */
5
+ import { DELIVERY_HEADER, SIGNATURE_HEADER, TIMESTAMP_HEADER, verifyDelivery, } from './contracts';
6
+ /** Where Datalayer pushes an installation's deliveries, under the service's public address. */
7
+ export const DELIVERY_PATH = '/datalayer/deliveries';
8
+ export const DELIVERY_ROUTE = `${DELIVERY_PATH}/:hostUid`;
9
+ const DELIVERY_BODY_LIMIT = 64 * 1024;
10
+ /**
11
+ * Handle one delivery of an installation once (claimed by its uid), then
12
+ * acknowledge it at Datalayer. A removed installation's delivery is dropped;
13
+ * a handling that fails releases its claim, so the queue tries again.
14
+ */
15
+ export async function receiveDelivery(job, input) {
16
+ const hostKey = await input.sink.hostKeyOf(job.hostUid);
17
+ if (!hostKey) {
18
+ return;
19
+ }
20
+ const claim = `delivery:${job.hostUid}:${job.delivery.delivery}`;
21
+ if (await input.claims.claimEvent(claim)) {
22
+ try {
23
+ await input.handle(job.delivery);
24
+ }
25
+ catch (error) {
26
+ await input.claims.releaseEvent(claim);
27
+ throw error;
28
+ }
29
+ }
30
+ await input.client.acknowledge(hostKey, job.delivery.delivery);
31
+ }
32
+ async function rawBodyOf(request) {
33
+ const chunks = [];
34
+ let size = 0;
35
+ for await (const chunk of request) {
36
+ size += chunk.length;
37
+ if (size > DELIVERY_BODY_LIMIT) {
38
+ throw new Error('The delivery is too large.');
39
+ }
40
+ chunks.push(chunk);
41
+ }
42
+ return Buffer.concat(chunks);
43
+ }
44
+ /**
45
+ * The signed webhook Datalayer pushes to (`POST /datalayer/deliveries/{host}`,
46
+ * registered with `PUT …/self/delivery`): verified with that installation's
47
+ * secret — HMAC over `<timestamp>.<body>`, five minutes at most — then queued
48
+ * and acknowledged with 2xx at once; the work runs from the queue.
49
+ */
50
+ export function deliveryRoute(sink, enqueue, nowSeconds) {
51
+ return async (request, response) => {
52
+ const answer = (status, body) => {
53
+ response.writeHead(status, { 'content-type': 'application/json' });
54
+ response.end(JSON.stringify(body));
55
+ };
56
+ try {
57
+ const named = request.params?.hostUid;
58
+ const hostUid = typeof named === 'string' ? named : '';
59
+ const secret = hostUid ? await sink.deliverySecretOf(hostUid) : undefined;
60
+ if (!secret) {
61
+ return answer(404, { ok: false, error: 'unknown installation' });
62
+ }
63
+ const header = (name) => {
64
+ const value = request.headers[name.toLowerCase()];
65
+ return Array.isArray(value) ? value[0] : value;
66
+ };
67
+ const rawBody = await rawBodyOf(request);
68
+ let delivery;
69
+ try {
70
+ delivery = verifyDelivery({
71
+ nowSeconds: nowSeconds?.(),
72
+ rawBody,
73
+ secret,
74
+ signature: header(SIGNATURE_HEADER),
75
+ timestamp: header(TIMESTAMP_HEADER),
76
+ });
77
+ }
78
+ catch {
79
+ return answer(401, { ok: false, error: 'not verified' });
80
+ }
81
+ if (header(DELIVERY_HEADER) &&
82
+ header(DELIVERY_HEADER) !== delivery.delivery) {
83
+ return answer(400, { ok: false, error: 'delivery mismatch' });
84
+ }
85
+ await enqueue({ delivery, hostUid });
86
+ return answer(200, { ok: true });
87
+ }
88
+ catch {
89
+ return answer(500, { ok: false });
90
+ }
91
+ };
92
+ }
93
+ /**
94
+ * The outbox, read when a push was missed or no address is registered
95
+ * (`GET …/self/deliveries`): each delivery of every bound installation
96
+ * queued, handled once, then acknowledged.
97
+ */
98
+ export class DeliveryPoller {
99
+ sink;
100
+ client;
101
+ enqueue;
102
+ logger;
103
+ #timer;
104
+ #running = false;
105
+ constructor(sink, client, enqueue, logger = console) {
106
+ this.sink = sink;
107
+ this.client = client;
108
+ this.enqueue = enqueue;
109
+ this.logger = logger;
110
+ }
111
+ async pollOnce() {
112
+ let queued = 0;
113
+ for (const { hostKey, hostUid } of await this.sink.boundHosts()) {
114
+ try {
115
+ for (const delivery of await this.client.outbox(hostKey)) {
116
+ await this.enqueue({ delivery, hostUid });
117
+ queued += 1;
118
+ }
119
+ }
120
+ catch (error) {
121
+ this.logger.warn(`The outbox of one installation was not read: ${error.message}`);
122
+ }
123
+ }
124
+ return queued;
125
+ }
126
+ start(intervalSeconds) {
127
+ if (intervalSeconds <= 0 || this.#timer) {
128
+ return;
129
+ }
130
+ this.#timer = setInterval(() => {
131
+ if (this.#running) {
132
+ return;
133
+ }
134
+ this.#running = true;
135
+ void this.pollOnce().finally(() => {
136
+ this.#running = false;
137
+ });
138
+ }, intervalSeconds * 1000);
139
+ this.#timer.unref?.();
140
+ }
141
+ stop() {
142
+ if (this.#timer) {
143
+ clearInterval(this.#timer);
144
+ }
145
+ this.#timer = undefined;
146
+ }
147
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * What may never reach a host service's production code (SLACK §4.2, S-005;
3
+ * DISCORD §4.2): Jupyter, JupyterLab, Lumino, Datalayer's Jupyter packages,
4
+ * React, Primer, Reactor and the other host services. Checked over the
5
+ * service's source imports, its esbuild bundle's module graph
6
+ * (`forbiddenInBundle`, over the metafile) and the installed graph of the
7
+ * bundle's externals (`npm ls --omit=dev --omit=peer --all`). Pure functions:
8
+ * each service reads its files and runs them. Moved from the Slack service
9
+ * (plans/DISCORD.md DC-004).
10
+ *
11
+ * @module portable/hosts/forbiddenGraph
12
+ */
13
+ export declare const FORBIDDEN: readonly RegExp[];
14
+ /** A package reached, with the chain of dependencies that reached it. */
15
+ export type Reached = Readonly<{
16
+ chain: string[];
17
+ name: string;
18
+ }>;
19
+ type LockPackage = {
20
+ dependencies?: Record<string, string>;
21
+ optionalDependencies?: Record<string, string>;
22
+ peerDependencies?: Record<string, string>;
23
+ peerDependenciesMeta?: Record<string, {
24
+ optional?: boolean;
25
+ }>;
26
+ };
27
+ /** A lockfile, v2 or v3: its `packages`. */
28
+ export type Lockfile = {
29
+ packages?: Record<string, LockPackage>;
30
+ };
31
+ /** What `npm ls --json` answers: each package and its own dependencies. */
32
+ export type InstalledTree = {
33
+ dependencies?: Record<string, InstalledTree | undefined>;
34
+ };
35
+ export declare const isForbidden: (name: string) => boolean;
36
+ /** The package name an import specifier names: `@scope/name/deep` → `@scope/name`. */
37
+ export declare function packageOf(specifier: string): string | undefined;
38
+ /** Every bare import of one source file. */
39
+ export declare function importsOf(source: string): string[];
40
+ /**
41
+ * The production graph of a lockfile (v2/v3 `packages`): from the root's
42
+ * `dependencies` and `optionalDependencies` (never `devDependencies`), each
43
+ * package's own dependencies resolved the way Node resolves them — the
44
+ * nearest `node_modules` up from the dependent. Answers each package reached
45
+ * with the chain that reached it.
46
+ */
47
+ export declare function productionGraph(lock: Lockfile): Reached[];
48
+ /**
49
+ * The production graph as `npm ls --omit=dev --all --json` answers it, from
50
+ * what is installed: what the service runs with, whether a lockfile is kept
51
+ * or not (the monorepo keeps none). Each package with the chain reaching it.
52
+ */
53
+ export declare function installedGraph(tree: InstalledTree): Reached[];
54
+ /** The forbidden packages an installed graph reaches, each with its chain. */
55
+ export declare function forbiddenInInstalled(tree: InstalledTree): string[];
56
+ /** The forbidden packages a lockfile's production graph reaches, each with its chain. */
57
+ export declare function forbiddenInGraph(lock: Lockfile): string[];
58
+ /** The parts of an esbuild metafile the bundle gate reads. */
59
+ export type BundleMetafile = {
60
+ inputs: Record<string, unknown>;
61
+ outputs: Record<string, {
62
+ imports?: ReadonlyArray<{
63
+ path: string;
64
+ external?: boolean;
65
+ }>;
66
+ }>;
67
+ };
68
+ /**
69
+ * What a service's esbuild bundle may hold (DISCORD §4.2): its own sources,
70
+ * and agent-runtimes' `lib/portable` (by its real path: the monorepo's
71
+ * workspace link resolves to `ai/agent-runtimes/lib/portable/…`). Every
72
+ * other module is a package's, and refused; every external is a `node:`
73
+ * builtin or a name in the service's `dependencies`; nothing matches
74
+ * `FORBIDDEN`. Answers each violation in words.
75
+ */
76
+ export declare function forbiddenInBundle(metafile: BundleMetafile, dependencies: readonly string[]): string[];
77
+ export {};
@@ -0,0 +1,174 @@
1
+ /*
2
+ * Copyright (c) 2025-2026 Datalayer, Inc.
3
+ * Distributed under the terms of the Modified BSD License.
4
+ */
5
+ /**
6
+ * What may never reach a host service's production code (SLACK §4.2, S-005;
7
+ * DISCORD §4.2): Jupyter, JupyterLab, Lumino, Datalayer's Jupyter packages,
8
+ * React, Primer, Reactor and the other host services. Checked over the
9
+ * service's source imports, its esbuild bundle's module graph
10
+ * (`forbiddenInBundle`, over the metafile) and the installed graph of the
11
+ * bundle's externals (`npm ls --omit=dev --omit=peer --all`). Pure functions:
12
+ * each service reads its files and runs them. Moved from the Slack service
13
+ * (plans/DISCORD.md DC-004).
14
+ *
15
+ * @module portable/hosts/forbiddenGraph
16
+ */
17
+ export const FORBIDDEN = [
18
+ /^@jupyter\//,
19
+ /^@jupyterlab\//,
20
+ /^@jupyter-widgets\//,
21
+ /^@lumino\//,
22
+ /^@datalayer\/jupyter-/,
23
+ /^react-dom$/,
24
+ /^react$/,
25
+ /^@primer\//,
26
+ /^@datalayer\/reactor$/,
27
+ /^@datalayer\/slack$/,
28
+ /^@datalayer\/discord$/,
29
+ ];
30
+ export const isForbidden = (name) => FORBIDDEN.some(pattern => pattern.test(name));
31
+ /** The package name an import specifier names: `@scope/name/deep` → `@scope/name`. */
32
+ export function packageOf(specifier) {
33
+ if (specifier.startsWith('.') || specifier.startsWith('node:'))
34
+ return undefined;
35
+ const parts = specifier.split('/');
36
+ return specifier.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0];
37
+ }
38
+ /** Every bare import of one source file. */
39
+ export function importsOf(source) {
40
+ const found = new Set();
41
+ const patterns = [
42
+ /\bimport\s+(?:type\s+)?(?:[^'"]*?\sfrom\s+)?["']([^"']+)["']/g,
43
+ /\bexport\s+(?:type\s+)?[^'"]*?\sfrom\s+["']([^"']+)["']/g,
44
+ /\bimport\(\s*["']([^"']+)["']\s*\)/g,
45
+ /\brequire\(\s*["']([^"']+)["']\s*\)/g,
46
+ ];
47
+ for (const pattern of patterns)
48
+ for (const match of source.matchAll(pattern)) {
49
+ const name = packageOf(match[1] ?? '');
50
+ if (name)
51
+ found.add(name);
52
+ }
53
+ return [...found];
54
+ }
55
+ /**
56
+ * The production graph of a lockfile (v2/v3 `packages`): from the root's
57
+ * `dependencies` and `optionalDependencies` (never `devDependencies`), each
58
+ * package's own dependencies resolved the way Node resolves them — the
59
+ * nearest `node_modules` up from the dependent. Answers each package reached
60
+ * with the chain that reached it.
61
+ */
62
+ export function productionGraph(lock) {
63
+ const packages = lock.packages ?? {};
64
+ const root = packages[''] ?? {};
65
+ const resolve = (from, name) => {
66
+ let base = from;
67
+ while (true) {
68
+ const candidate = `${base ? `${base}/` : ''}node_modules/${name}`;
69
+ if (packages[candidate])
70
+ return candidate;
71
+ if (!base)
72
+ return undefined;
73
+ const index = base.lastIndexOf('/node_modules/');
74
+ base = index >= 0 ? base.slice(0, index) : '';
75
+ }
76
+ };
77
+ const reached = new Map();
78
+ const queue = [];
79
+ for (const name of Object.keys({
80
+ ...root.dependencies,
81
+ ...root.optionalDependencies,
82
+ }))
83
+ queue.push({ chain: [name], from: '', name });
84
+ while (queue.length > 0) {
85
+ const { chain, from, name } = queue.shift();
86
+ const path = resolve(from, name);
87
+ if (!path || reached.has(path))
88
+ continue;
89
+ reached.set(path, { chain, name });
90
+ const meta = packages[path] ?? {};
91
+ for (const next of Object.keys({
92
+ ...meta.dependencies,
93
+ ...meta.optionalDependencies,
94
+ ...meta.peerDependencies,
95
+ }))
96
+ if (!(meta.peerDependenciesMeta?.[next]?.optional && !resolve(path, next)))
97
+ queue.push({ chain: [...chain, next], from: path, name: next });
98
+ }
99
+ return [...reached.values()];
100
+ }
101
+ /**
102
+ * The production graph as `npm ls --omit=dev --all --json` answers it, from
103
+ * what is installed: what the service runs with, whether a lockfile is kept
104
+ * or not (the monorepo keeps none). Each package with the chain reaching it.
105
+ */
106
+ export function installedGraph(tree) {
107
+ const reached = [];
108
+ const seen = new Set();
109
+ const walk = (dependencies, chain) => {
110
+ for (const [name, node] of Object.entries(dependencies ?? {})) {
111
+ const id = `${chain.join('>')}>${name}`;
112
+ if (seen.has(id) || chain.includes(name))
113
+ continue;
114
+ seen.add(id);
115
+ reached.push({ chain: [...chain, name], name });
116
+ walk(node?.dependencies, [...chain, name]);
117
+ }
118
+ };
119
+ walk(tree.dependencies, []);
120
+ return reached;
121
+ }
122
+ /** The forbidden packages an installed graph reaches, each with its chain. */
123
+ export function forbiddenInInstalled(tree) {
124
+ return installedGraph(tree)
125
+ .filter(one => isForbidden(one.name))
126
+ .map(one => one.chain.join(' → '));
127
+ }
128
+ /** The forbidden packages a lockfile's production graph reaches, each with its chain. */
129
+ export function forbiddenInGraph(lock) {
130
+ return productionGraph(lock)
131
+ .filter(one => isForbidden(one.name))
132
+ .map(one => one.chain.join(' → '));
133
+ }
134
+ /** Where agent-runtimes' portable code lies, by the real path esbuild reads it from. */
135
+ const PORTABLE = /(^|\/)agent-runtimes\/lib\/portable\//;
136
+ /**
137
+ * What a service's esbuild bundle may hold (DISCORD §4.2): its own sources,
138
+ * and agent-runtimes' `lib/portable` (by its real path: the monorepo's
139
+ * workspace link resolves to `ai/agent-runtimes/lib/portable/…`). Every
140
+ * other module is a package's, and refused; every external is a `node:`
141
+ * builtin or a name in the service's `dependencies`; nothing matches
142
+ * `FORBIDDEN`. Answers each violation in words.
143
+ */
144
+ export function forbiddenInBundle(metafile, dependencies) {
145
+ const violations = [];
146
+ for (const input of Object.keys(metafile.inputs)) {
147
+ const portable = PORTABLE.test(input);
148
+ const modules = input.lastIndexOf('node_modules/');
149
+ const name = modules >= 0
150
+ ? packageOf(input.slice(modules + 'node_modules/'.length))
151
+ : undefined;
152
+ if (name && isForbidden(name))
153
+ violations.push(`${input}: ${name} is forbidden`);
154
+ else if (!portable && (modules >= 0 || input.startsWith('../')))
155
+ violations.push(`${input}: bundled from outside the service and agent-runtimes' lib/portable`);
156
+ }
157
+ const externals = new Set();
158
+ for (const output of Object.values(metafile.outputs))
159
+ for (const one of output.imports ?? [])
160
+ if (one.external)
161
+ externals.add(one.path);
162
+ for (const path of externals) {
163
+ if (path.startsWith('node:'))
164
+ continue;
165
+ const name = packageOf(path);
166
+ if (!name)
167
+ violations.push(`${path}: a relative external`);
168
+ else if (isForbidden(name))
169
+ violations.push(`${path}: ${name} is forbidden`);
170
+ else if (!dependencies.includes(name))
171
+ violations.push(`${path}: external, but ${name} is not one of the service's dependencies`);
172
+ }
173
+ return violations;
174
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Liveness and readiness (SLACK §10, S-101; DISCORD §10): `/healthz` says the
3
+ * process answers; `/readyz` says it can take durable work now — its store
4
+ * and its queue answer — and never asks the host or Datalayer, so a
5
+ * transient outage of either does not take the pods out of service: accepted
6
+ * work waits in the queue instead. And the window a signed host request's
7
+ * timestamp must fall in. Moved from the Slack service (plans/DISCORD.md
8
+ * DC-004).
9
+ *
10
+ * @module portable/hosts/health
11
+ */
12
+ import { type IncomingMessage, type Server, type ServerResponse } from 'node:http';
13
+ export type ReadinessChecks = Readonly<Record<string, () => Promise<boolean>>>;
14
+ /** Each check, at most `timeoutMs`: a check that throws or is late is not ready. */
15
+ export declare function readiness(checks: ReadinessChecks, timeoutMs?: number): Promise<{
16
+ ready: boolean;
17
+ checks: Record<string, boolean>;
18
+ }>;
19
+ /** The health paths, and what each answers. */
20
+ export declare const HEALTH_PATHS: readonly ["/healthz", "/readyz"];
21
+ /**
22
+ * Answer `GET /healthz` or `GET /readyz`; `false` for any other request,
23
+ * which the caller answers.
24
+ */
25
+ export declare function answerHealth(checks: ReadinessChecks, request: IncomingMessage, response: ServerResponse): boolean;
26
+ /** A worker's own server: its health and readiness only (no host route). */
27
+ export declare function healthServer(checks: ReadinessChecks): Server;
28
+ /** How far a signed host request's timestamp may be from now, either way (SLACK §7.1, 1; DISCORD §7.1, 1). */
29
+ export declare const REQUEST_WINDOW_SECONDS: number;
30
+ /**
31
+ * Whether a request's timestamp header (Slack's `X-Slack-Request-Timestamp`,
32
+ * Discord's `X-Signature-Timestamp`) is within five minutes of now, in the
33
+ * past or the future: a replay with a skewed clock is refused too.
34
+ */
35
+ export declare function timestampWithinWindow(header: string | string[] | undefined, nowSeconds?: number): boolean;