@nordbyte/nordrelay 0.5.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/.env.example +80 -11
  2. package/README.md +154 -22
  3. package/dist/access-control.js +7 -1
  4. package/dist/activity-events.js +44 -0
  5. package/dist/audit-log.js +40 -2
  6. package/dist/bot-preferences.js +1 -0
  7. package/dist/bot-rendering.js +10 -7
  8. package/dist/bot.js +535 -11
  9. package/dist/channel-actions.js +7 -2
  10. package/dist/channel-adapter.js +40 -7
  11. package/dist/channel-command-catalog.js +88 -0
  12. package/dist/channel-command-service.js +369 -0
  13. package/dist/channel-mirror-registry.js +77 -0
  14. package/dist/channel-peer-prompt.js +95 -0
  15. package/dist/channel-runtime.js +12 -5
  16. package/dist/channel-turn-service.js +237 -0
  17. package/dist/codex-state.js +114 -78
  18. package/dist/config-metadata.js +93 -13
  19. package/dist/config.js +103 -8
  20. package/dist/context-key.js +87 -5
  21. package/dist/discord-artifacts.js +165 -0
  22. package/dist/discord-bot.js +2073 -0
  23. package/dist/discord-channel-runtime.js +133 -0
  24. package/dist/discord-command-surface.js +57 -0
  25. package/dist/discord-rate-limit.js +141 -0
  26. package/dist/index.js +36 -5
  27. package/dist/job-store.js +127 -0
  28. package/dist/metrics.js +87 -0
  29. package/dist/peer-auth.js +85 -0
  30. package/dist/peer-client.js +256 -0
  31. package/dist/peer-context.js +21 -0
  32. package/dist/peer-identity.js +127 -0
  33. package/dist/peer-runtime-service.js +636 -0
  34. package/dist/peer-server.js +220 -0
  35. package/dist/peer-store.js +294 -0
  36. package/dist/peer-types.js +52 -0
  37. package/dist/relay-external-activity-monitor.js +47 -6
  38. package/dist/relay-runtime-helpers.js +208 -0
  39. package/dist/relay-runtime.js +897 -394
  40. package/dist/remote-prompt.js +98 -0
  41. package/dist/runtime-cache.js +57 -0
  42. package/dist/session-locks.js +10 -7
  43. package/dist/support-bundle.js +1 -0
  44. package/dist/telegram-access-commands.js +15 -2
  45. package/dist/telegram-access-middleware.js +16 -3
  46. package/dist/telegram-agent-commands.js +25 -0
  47. package/dist/telegram-artifact-commands.js +46 -0
  48. package/dist/telegram-command-menu.js +3 -53
  49. package/dist/telegram-diagnostics-command.js +5 -50
  50. package/dist/telegram-general-commands.js +16 -6
  51. package/dist/telegram-operational-commands.js +14 -6
  52. package/dist/telegram-preference-commands.js +23 -127
  53. package/dist/telegram-queue-commands.js +74 -4
  54. package/dist/telegram-support-command.js +7 -0
  55. package/dist/telegram-update-commands.js +27 -0
  56. package/dist/user-management.js +208 -0
  57. package/dist/web-api-contract.js +17 -0
  58. package/dist/web-dashboard-access-routes.js +74 -1
  59. package/dist/web-dashboard-artifact-routes.js +3 -3
  60. package/dist/web-dashboard-assets.js +2 -0
  61. package/dist/web-dashboard-pages.js +109 -13
  62. package/dist/web-dashboard-peer-routes.js +204 -0
  63. package/dist/web-dashboard-runtime-routes.js +53 -8
  64. package/dist/web-dashboard-session-routes.js +27 -20
  65. package/dist/web-dashboard-ui.js +2 -0
  66. package/dist/web-dashboard.js +160 -6
  67. package/dist/web-state.js +33 -2
  68. package/dist/webui-assets/dashboard.css +75 -1
  69. package/dist/webui-assets/dashboard.js +779 -55
  70. package/package.json +5 -2
  71. package/plugins/nordrelay/scripts/nordrelay.mjs +578 -19
@@ -0,0 +1,133 @@
1
+ import { ActionRowBuilder, AttachmentBuilder, ButtonBuilder, ButtonStyle, Client, } from "discord.js";
2
+ import { DiscordChannelAdapter, } from "./channel-adapter.js";
3
+ import { discordRateLimiter } from "./discord-rate-limit.js";
4
+ import { redactText } from "./redaction.js";
5
+ const DISCORD_MESSAGE_LIMIT = 2000;
6
+ const DISCORD_SAFE_MESSAGE_LIMIT = 1900;
7
+ export const DISCORD_ACTION_PREFIX = "nr:";
8
+ export class DiscordBotChannelRuntime {
9
+ client;
10
+ id = "discord";
11
+ label = "Discord";
12
+ capabilities = new DiscordChannelAdapter().capabilities;
13
+ constructor(client) {
14
+ this.client = client;
15
+ }
16
+ describe() {
17
+ return new DiscordChannelAdapter().describe();
18
+ }
19
+ async sendMessage(context, message) {
20
+ const channel = await this.resolveChannel(context, message.threadId);
21
+ const content = discordMessageText(message);
22
+ const chunks = splitDiscordMessage(content);
23
+ let first = null;
24
+ for (const [index, chunk] of chunks.entries()) {
25
+ const sent = await discordRateLimiter.run(discordBucket(context), "sendMessage", () => channel.send({
26
+ content: chunk,
27
+ components: index === chunks.length - 1 ? discordActionRows(message.buttons) : [],
28
+ allowedMentions: { parse: [] },
29
+ }));
30
+ first ??= sent;
31
+ }
32
+ return { messageId: first?.id ?? "" };
33
+ }
34
+ async editMessage(context, messageId, message) {
35
+ const channel = await this.resolveChannel(context, message.threadId);
36
+ const existing = await channel.messages.fetch(messageId).catch(() => null);
37
+ if (!existing) {
38
+ await this.sendMessage(context, message);
39
+ return;
40
+ }
41
+ await discordRateLimiter.run(discordBucket(context), "editMessage", () => existing.edit({
42
+ content: trimDiscordMessage(discordMessageText(message)),
43
+ components: discordActionRows(message.buttons),
44
+ allowedMentions: { parse: [] },
45
+ }));
46
+ }
47
+ async sendTyping(context) {
48
+ const channel = await this.resolveChannel(context);
49
+ await discordRateLimiter.run(discordBucket(context), "typing", () => channel.sendTyping());
50
+ }
51
+ async sendFile(context, file) {
52
+ const channel = await this.resolveChannel(context, file.threadId);
53
+ const sent = await discordRateLimiter.run(discordBucket(context), "sendFile", () => channel.send({
54
+ content: file.caption ? trimDiscordMessage(redactText(file.caption)) : undefined,
55
+ files: [new AttachmentBuilder(file.localPath, { name: file.name })],
56
+ allowedMentions: { parse: [] },
57
+ }));
58
+ return { messageId: sent.id };
59
+ }
60
+ async resolveChannel(context, overrideThreadId) {
61
+ const id = overrideThreadId ?? context.topicId ?? context.chatId;
62
+ const channel = await this.client.channels.fetch(id);
63
+ if (!channel?.isTextBased() || !("send" in channel) || !("messages" in channel)) {
64
+ throw new Error(`Discord channel is not text-capable: ${id}`);
65
+ }
66
+ return channel;
67
+ }
68
+ }
69
+ function discordBucket(context) {
70
+ return context.topicId ?? context.chatId;
71
+ }
72
+ export function discordMessageText(message) {
73
+ return message.fallbackText?.trim() || stripTelegramHtml(message.text).trim() || ".";
74
+ }
75
+ export function discordActionRows(rows) {
76
+ if (!rows?.length) {
77
+ return [];
78
+ }
79
+ return rows.slice(0, 5).map((row) => new ActionRowBuilder()
80
+ .addComponents(row.slice(0, 5).map((button) => new ButtonBuilder()
81
+ .setCustomId(discordActionId(button.action))
82
+ .setLabel(trimButtonLabel(button.label))
83
+ .setStyle(ButtonStyle.Secondary)))
84
+ .toJSON());
85
+ }
86
+ export function discordActionId(action) {
87
+ const raw = `${DISCORD_ACTION_PREFIX}${action}`;
88
+ return raw.length <= 100 ? raw : `${DISCORD_ACTION_PREFIX}${action.slice(0, 97 - DISCORD_ACTION_PREFIX.length)}`;
89
+ }
90
+ export function actionFromDiscordCustomId(customId) {
91
+ return customId.startsWith(DISCORD_ACTION_PREFIX) ? customId.slice(DISCORD_ACTION_PREFIX.length) : null;
92
+ }
93
+ export function splitDiscordMessage(text) {
94
+ const normalized = text || ".";
95
+ if (normalized.length <= DISCORD_SAFE_MESSAGE_LIMIT) {
96
+ return [normalized];
97
+ }
98
+ const chunks = [];
99
+ let remaining = normalized;
100
+ while (remaining.length > 0) {
101
+ const slice = remaining.slice(0, DISCORD_SAFE_MESSAGE_LIMIT);
102
+ const breakAt = Math.max(slice.lastIndexOf("\n"), slice.lastIndexOf(" "));
103
+ const length = breakAt > 400 ? breakAt : DISCORD_SAFE_MESSAGE_LIMIT;
104
+ chunks.push(remaining.slice(0, length).trimEnd() || ".");
105
+ remaining = remaining.slice(length).trimStart();
106
+ }
107
+ return chunks;
108
+ }
109
+ export function trimDiscordMessage(text) {
110
+ return text.length <= DISCORD_MESSAGE_LIMIT ? text : `${text.slice(0, DISCORD_MESSAGE_LIMIT - 1)}…`;
111
+ }
112
+ export function discordMessageOptions(message) {
113
+ return {
114
+ content: trimDiscordMessage(discordMessageText(message)),
115
+ components: discordActionRows(message.buttons),
116
+ allowedMentions: { parse: [] },
117
+ };
118
+ }
119
+ function stripTelegramHtml(text) {
120
+ return text
121
+ .replace(/<br\s*\/?>/gi, "\n")
122
+ .replace(/<\/(p|div|li|pre)>/gi, "\n")
123
+ .replace(/<[^>]+>/g, "")
124
+ .replace(/&lt;/g, "<")
125
+ .replace(/&gt;/g, ">")
126
+ .replace(/&amp;/g, "&")
127
+ .replace(/&quot;/g, "\"")
128
+ .replace(/&#39;/g, "'");
129
+ }
130
+ function trimButtonLabel(label) {
131
+ const trimmed = label.trim() || "Action";
132
+ return trimmed.length <= 80 ? trimmed : trimmed.slice(0, 80);
133
+ }
@@ -0,0 +1,57 @@
1
+ import { permissionForCommand } from "./access-control.js";
2
+ import { discordCommandCatalog } from "./channel-command-catalog.js";
3
+ import { normalizeChannelCommandName, parseChannelCommand } from "./channel-runtime.js";
4
+ export function parseDiscordMessageCommand(text) {
5
+ return parseChannelCommand(text, { allowBotMention: false });
6
+ }
7
+ export function argumentFromDiscordInteraction(interaction) {
8
+ if (interaction.commandName === "prompt") {
9
+ return interaction.options.getString("text") ?? "";
10
+ }
11
+ if (interaction.commandName === "queue") {
12
+ return [interaction.options.getString("action"), interaction.options.getString("id")].filter(Boolean).join(" ");
13
+ }
14
+ if (interaction.commandName === "update") {
15
+ return [interaction.options.getString("target"), interaction.options.getString("agent"), interaction.options.getString("input")].filter(Boolean).join(" ");
16
+ }
17
+ return interaction.options.getString("value") ?? interaction.options.getString("query") ?? interaction.options.getString("thread_id") ?? "";
18
+ }
19
+ export function requiredPermissionForDiscordCommand(command, argument) {
20
+ const normalized = normalizeChannelCommandName(command);
21
+ if (normalized === "prompt")
22
+ return "prompt.send";
23
+ if (normalized === "queue")
24
+ return argument.trim() ? "queue.write" : "queue.read";
25
+ return permissionForCommand(normalized);
26
+ }
27
+ export function isUnauthenticatedDiscordCommandAllowed(command) {
28
+ return normalizeChannelCommandName(command) === "link";
29
+ }
30
+ export function permissionForDiscordAction(action) {
31
+ if (action.startsWith("discord_queue_") || action.startsWith("discord_peer_queue_"))
32
+ return "queue.write";
33
+ if (action.startsWith("discord_abort:"))
34
+ return "prompt.abort";
35
+ if (action.startsWith("discord_pick:"))
36
+ return "sessions.write";
37
+ if (action.startsWith("discord_artifact_delete:"))
38
+ return "files.write";
39
+ if (action.startsWith("discord_artifact_"))
40
+ return "files.read";
41
+ if (action.startsWith("agent-update:"))
42
+ return "updates.run";
43
+ return null;
44
+ }
45
+ export function discordCommands() {
46
+ return discordCommandCatalog()
47
+ .map((entry) => command(entry.name, entry.description, entry.options));
48
+ }
49
+ function command(name, description, options = []) {
50
+ return {
51
+ name,
52
+ description,
53
+ type: 1,
54
+ dm_permission: true,
55
+ options,
56
+ };
57
+ }
@@ -0,0 +1,141 @@
1
+ const DEFAULT_OPTIONS = {
2
+ minIntervalMs: 250,
3
+ editMinIntervalMs: 1_500,
4
+ typingMinIntervalMs: 4_500,
5
+ maxRetries: 5,
6
+ };
7
+ export class DiscordRateLimiter {
8
+ options;
9
+ buckets = new Map();
10
+ queued = 0;
11
+ running = 0;
12
+ completed = 0;
13
+ failed = 0;
14
+ retries = 0;
15
+ rateLimitHits = 0;
16
+ lastRateLimitAt;
17
+ lastRetryAfterSeconds;
18
+ constructor(options = DEFAULT_OPTIONS) {
19
+ this.options = options;
20
+ }
21
+ configure(options) {
22
+ this.options = { ...this.options, ...options };
23
+ }
24
+ async run(bucket, method, operation) {
25
+ const normalizedBucket = `${method}:${bucket}`;
26
+ const state = this.getBucket(normalizedBucket);
27
+ this.queued += 1;
28
+ let releasePrevious;
29
+ const previous = state.chain;
30
+ state.chain = new Promise((resolve) => {
31
+ releasePrevious = resolve;
32
+ });
33
+ await previous.catch(() => { });
34
+ this.queued = Math.max(0, this.queued - 1);
35
+ this.running += 1;
36
+ try {
37
+ await this.waitForBucket(state, method);
38
+ const result = await this.runWithRetries(operation);
39
+ this.completed += 1;
40
+ state.lastRunAtMs = Date.now();
41
+ state.queuedUntilMs = Math.max(state.queuedUntilMs, state.lastRunAtMs + this.intervalForMethod(method));
42
+ return result;
43
+ }
44
+ catch (error) {
45
+ this.failed += 1;
46
+ throw error;
47
+ }
48
+ finally {
49
+ this.running = Math.max(0, this.running - 1);
50
+ releasePrevious();
51
+ }
52
+ }
53
+ getMetrics() {
54
+ return {
55
+ queued: this.queued,
56
+ running: this.running,
57
+ completed: this.completed,
58
+ failed: this.failed,
59
+ retries: this.retries,
60
+ rateLimitHits: this.rateLimitHits,
61
+ lastRateLimitAt: this.lastRateLimitAt,
62
+ lastRetryAfterSeconds: this.lastRetryAfterSeconds,
63
+ buckets: [...this.buckets.entries()]
64
+ .filter(([, state]) => state.queuedUntilMs > Date.now() || state.lastRunAtMs > 0)
65
+ .slice(0, 12)
66
+ .map(([bucket, state]) => ({
67
+ bucket,
68
+ queuedUntilMs: state.queuedUntilMs,
69
+ lastRunAtMs: state.lastRunAtMs,
70
+ })),
71
+ };
72
+ }
73
+ getBucket(bucket) {
74
+ let state = this.buckets.get(bucket);
75
+ if (!state) {
76
+ state = {
77
+ chain: Promise.resolve(),
78
+ queuedUntilMs: 0,
79
+ lastRunAtMs: 0,
80
+ };
81
+ this.buckets.set(bucket, state);
82
+ }
83
+ return state;
84
+ }
85
+ async waitForBucket(state, method) {
86
+ const interval = this.intervalForMethod(method);
87
+ const now = Date.now();
88
+ const earliest = Math.max(state.queuedUntilMs, state.lastRunAtMs + interval);
89
+ if (earliest > now) {
90
+ await delay(earliest - now);
91
+ }
92
+ }
93
+ async runWithRetries(operation) {
94
+ let attempt = 0;
95
+ while (true) {
96
+ try {
97
+ return await operation();
98
+ }
99
+ catch (error) {
100
+ const retryAfterSeconds = getRetryAfterSeconds(error);
101
+ if (retryAfterSeconds === undefined || attempt >= this.options.maxRetries) {
102
+ throw error;
103
+ }
104
+ attempt += 1;
105
+ this.retries += 1;
106
+ this.rateLimitHits += 1;
107
+ this.lastRateLimitAt = new Date().toISOString();
108
+ this.lastRetryAfterSeconds = retryAfterSeconds;
109
+ await delay((retryAfterSeconds * 1000) + 250);
110
+ }
111
+ }
112
+ }
113
+ intervalForMethod(method) {
114
+ if (method.startsWith("edit"))
115
+ return this.options.editMinIntervalMs;
116
+ if (method.startsWith("typing"))
117
+ return this.options.typingMinIntervalMs;
118
+ return this.options.minIntervalMs;
119
+ }
120
+ }
121
+ export const discordRateLimiter = new DiscordRateLimiter();
122
+ export function getDiscordRateLimitMetrics() {
123
+ return discordRateLimiter.getMetrics();
124
+ }
125
+ function getRetryAfterSeconds(error) {
126
+ const candidate = error;
127
+ const direct = candidate.retryAfter ?? candidate.rawError?.retry_after ?? candidate.data?.retry_after;
128
+ if (typeof direct === "number" && Number.isFinite(direct)) {
129
+ return Math.max(1, direct > 100 ? direct / 1000 : direct);
130
+ }
131
+ const text = typeof candidate.message === "string" ? candidate.message : "";
132
+ const match = text.match(/retry[_ -]?after["':\s]+(\d+(?:\.\d+)?)/i);
133
+ if (!match) {
134
+ return undefined;
135
+ }
136
+ const parsed = Number(match[1]);
137
+ return Number.isFinite(parsed) ? Math.max(1, parsed) : undefined;
138
+ }
139
+ function delay(ms) {
140
+ return new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
141
+ }
package/dist/index.js CHANGED
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import { webhookCallback } from "grammy";
5
5
  import { agentLabel } from "./agent.js";
6
6
  import { createBot, registerCommands } from "./bot.js";
7
+ import { createDiscordBridge } from "./discord-bot.js";
7
8
  import { checkAuthStatus } from "./codex-auth.js";
8
9
  import { describeCodexCli, resolveCodexCli } from "./codex-cli.js";
9
10
  import { checkClaudeCodeAuthStatus } from "./claude-code-auth.js";
@@ -18,12 +19,17 @@ import { describeOpenClawCli, resolveOpenClawCli } from "./openclaw-cli.js";
18
19
  import { installConsoleLogger } from "./logger.js";
19
20
  import { checkPiAuthStatus } from "./pi-auth.js";
20
21
  import { describePiCli, resolvePiCli } from "./pi-cli.js";
22
+ import { startPeerServer } from "./peer-server.js";
23
+ import { RelayRuntime } from "./relay-runtime.js";
21
24
  import { configureRedaction } from "./redaction.js";
22
25
  import { SessionRegistry } from "./session-registry.js";
23
26
  import { UserStore } from "./user-management.js";
24
27
  let registry;
25
28
  let bot;
29
+ let discordBridge;
26
30
  let webhookServer;
31
+ let peerServer;
32
+ let peerRuntime;
27
33
  let runtimeConfig;
28
34
  try {
29
35
  const config = loadConfig();
@@ -31,8 +37,16 @@ try {
31
37
  configureRedaction(config.telegramRedactPatterns);
32
38
  installConsoleLogger(config.logFormat);
33
39
  registry = new SessionRegistry(config);
34
- bot = createBot(config, registry);
35
- await registerCommands(bot);
40
+ if (config.telegramEnabled) {
41
+ bot = createBot(config, registry);
42
+ await registerCommands(bot);
43
+ }
44
+ discordBridge = createDiscordBridge(config, registry);
45
+ await discordBridge?.start();
46
+ if (config.peerEnabled) {
47
+ peerRuntime = new RelayRuntime(config);
48
+ peerServer = await startPeerServer({ config, runtime: peerRuntime });
49
+ }
36
50
  console.log("NordRelay running");
37
51
  const userStore = new UserStore();
38
52
  if (userStore.hasAdminUser()) {
@@ -46,6 +60,9 @@ try {
46
60
  if (!authStatus.authenticated) {
47
61
  console.warn(`Warning: ${agentLabel(config.defaultAgent)} is not authenticated. ${authStatus.detail}`);
48
62
  }
63
+ for (const warning of config.adapterWarnings ?? []) {
64
+ console.warn(`Warning: ${warning}`);
65
+ }
49
66
  console.log(`Workspace: ${config.workspace}`);
50
67
  console.log(`Enabled agents: ${enabledAgents(config).join(", ")} (default: ${config.defaultAgent})`);
51
68
  if (config.codexModel) {
@@ -74,14 +91,16 @@ try {
74
91
  console.warn("Warning: Default launch profile uses danger-full-access.");
75
92
  }
76
93
  }
77
- console.log("Session mode: per Telegram context");
78
- console.log(`Telegram transport: ${config.telegramTransport}`);
94
+ console.log("Session mode: per chat context");
95
+ console.log(`Telegram: ${config.telegramEnabled ? config.telegramTransport : "disabled"}`);
96
+ console.log(`Discord: ${config.discordEnabled ? "enabled" : "disabled"}`);
97
+ console.log(`Peers: ${peerServer ? peerServer.url : "disabled"}`);
79
98
  await writeConnectorState({
80
99
  status: "ready",
81
100
  pid: Number(process.env.NORDRELAY_WRAPPER_PID) || process.pid,
82
101
  appPid: process.pid,
83
102
  workspace: config.workspace,
84
- sessionMode: "per Telegram context",
103
+ sessionMode: "per chat context",
85
104
  authenticated: authStatus.authenticated,
86
105
  authMethod: authStatus.method,
87
106
  codexCli: describeCodexCli(codexCli),
@@ -91,6 +110,11 @@ try {
91
110
  claudeCodeCli: describeClaudeCodeCli(claudeCodeCli),
92
111
  openClawGateway: config.openClawGatewayUrl,
93
112
  telegramTransport: config.telegramTransport,
113
+ discordEnabled: config.discordEnabled,
114
+ peerEnabled: config.peerEnabled,
115
+ peerUrl: peerServer?.url,
116
+ peerTlsFingerprint: peerServer?.tlsFingerprint,
117
+ adapterWarnings: config.adapterWarnings ?? [],
94
118
  });
95
119
  }
96
120
  catch (error) {
@@ -137,9 +161,16 @@ const shutdown = (signal) => {
137
161
  console.log(`Received ${signal}, shutting down NordRelay...`);
138
162
  if (bot && runtimeConfig?.telegramTransport !== "webhook")
139
163
  bot.stop();
164
+ void discordBridge?.stop().catch((error) => {
165
+ console.warn("Failed to stop Discord bridge:", error instanceof Error ? error.message : String(error));
166
+ });
140
167
  webhookServer?.close();
168
+ void peerServer?.close().catch((error) => {
169
+ console.warn("Failed to stop peer server:", error instanceof Error ? error.message : String(error));
170
+ });
141
171
  setTimeout(() => {
142
172
  registry?.disposeAll();
173
+ peerRuntime?.dispose();
143
174
  void writeConnectorState({
144
175
  status: "stopped",
145
176
  pid: Number(process.env.NORDRELAY_WRAPPER_PID) || process.pid,
@@ -0,0 +1,127 @@
1
+ import { createDocumentStore } from "./state-backend.js";
2
+ const DEFAULT_MAX_JOBS = 1000;
3
+ export class UnifiedJobStore {
4
+ maxJobs;
5
+ store;
6
+ constructor(workspace, backend = "json", maxJobs = DEFAULT_MAX_JOBS) {
7
+ this.maxJobs = maxJobs;
8
+ this.store = createDocumentStore({
9
+ workspace,
10
+ backend,
11
+ fileName: "unified-jobs.json",
12
+ sqliteKey: "unified-jobs",
13
+ });
14
+ }
15
+ list(limit = 200) {
16
+ return this.readPayload().jobs
17
+ .sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt))
18
+ .slice(0, Math.max(1, Math.min(this.maxJobs, limit)));
19
+ }
20
+ get(id) {
21
+ return this.readPayload().jobs.find((job) => job.id === id) ?? null;
22
+ }
23
+ upsert(job) {
24
+ const payload = this.readPayload();
25
+ const normalized = normalizeJob(job);
26
+ const index = payload.jobs.findIndex((candidate) => candidate.id === normalized.id);
27
+ if (index >= 0) {
28
+ payload.jobs[index] = {
29
+ ...payload.jobs[index],
30
+ ...normalized,
31
+ };
32
+ }
33
+ else {
34
+ payload.jobs.push(normalized);
35
+ }
36
+ payload.jobs = trimJobs(payload.jobs, this.maxJobs);
37
+ this.store.write(payload);
38
+ return normalized;
39
+ }
40
+ upsertMany(jobs) {
41
+ if (jobs.length === 0) {
42
+ return this.list();
43
+ }
44
+ const payload = this.readPayload();
45
+ const byId = new Map(payload.jobs.map((job) => [job.id, job]));
46
+ for (const job of jobs) {
47
+ const normalized = normalizeJob(job);
48
+ byId.set(normalized.id, {
49
+ ...byId.get(normalized.id),
50
+ ...normalized,
51
+ });
52
+ }
53
+ payload.jobs = trimJobs([...byId.values()], this.maxJobs);
54
+ this.store.write(payload);
55
+ return payload.jobs;
56
+ }
57
+ patch(id, patch) {
58
+ const payload = this.readPayload();
59
+ const index = payload.jobs.findIndex((job) => job.id === id);
60
+ if (index < 0) {
61
+ return null;
62
+ }
63
+ payload.jobs[index] = normalizeJob({
64
+ ...payload.jobs[index],
65
+ ...patch,
66
+ id,
67
+ updatedAt: patch.updatedAt ?? new Date().toISOString(),
68
+ });
69
+ this.store.write(payload);
70
+ return payload.jobs[index];
71
+ }
72
+ remove(id) {
73
+ const payload = this.readPayload();
74
+ const next = payload.jobs.filter((job) => job.id !== id);
75
+ if (next.length === payload.jobs.length) {
76
+ return false;
77
+ }
78
+ payload.jobs = next;
79
+ this.store.write(payload);
80
+ return true;
81
+ }
82
+ readPayload() {
83
+ const payload = this.store.read();
84
+ if (!payload || payload.version !== 1 || !Array.isArray(payload.jobs)) {
85
+ return { version: 1, jobs: [] };
86
+ }
87
+ return {
88
+ version: 1,
89
+ jobs: trimJobs(payload.jobs.filter(isUnifiedJobDto).map(normalizeJob), this.maxJobs),
90
+ };
91
+ }
92
+ }
93
+ function trimJobs(jobs, maxJobs) {
94
+ return jobs
95
+ .sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt))
96
+ .slice(0, maxJobs);
97
+ }
98
+ function normalizeJob(job) {
99
+ return {
100
+ ...job,
101
+ startedAt: validDateString(job.startedAt) ?? new Date().toISOString(),
102
+ updatedAt: validDateString(job.updatedAt) ?? new Date().toISOString(),
103
+ finishedAt: validDateString(job.finishedAt),
104
+ canCancel: Boolean(job.canCancel),
105
+ canRetry: Boolean(job.canRetry),
106
+ canReadLog: Boolean(job.canReadLog),
107
+ };
108
+ }
109
+ function validDateString(value) {
110
+ if (!value) {
111
+ return undefined;
112
+ }
113
+ return Number.isFinite(Date.parse(value)) ? value : undefined;
114
+ }
115
+ function isUnifiedJobDto(value) {
116
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
117
+ return false;
118
+ }
119
+ const candidate = value;
120
+ return typeof candidate.id === "string" &&
121
+ typeof candidate.kind === "string" &&
122
+ typeof candidate.title === "string" &&
123
+ typeof candidate.status === "string" &&
124
+ typeof candidate.source === "string" &&
125
+ typeof candidate.startedAt === "string" &&
126
+ typeof candidate.updatedAt === "string";
127
+ }
@@ -0,0 +1,87 @@
1
+ import { monitorEventLoopDelay } from "node:perf_hooks";
2
+ import { getDiscordRateLimitMetrics } from "./discord-rate-limit.js";
3
+ import { getTelegramRateLimitMetrics } from "./telegram-rate-limit.js";
4
+ const startedAt = Date.now();
5
+ const eventLoopDelay = monitorEventLoopDelay({ resolution: 20 });
6
+ eventLoopDelay.enable();
7
+ export function buildRuntimeMetrics(input) {
8
+ const completedPromptDurations = input.activity
9
+ .filter((event) => event.category === "prompt" && event.status === "completed" && typeof event.durationMs === "number")
10
+ .map((event) => event.durationMs);
11
+ return {
12
+ generatedAt: new Date().toISOString(),
13
+ queue: {
14
+ length: input.queueLength,
15
+ paused: input.queuePaused,
16
+ },
17
+ turns: {
18
+ active: input.activeTurnCount,
19
+ completed: countActivity(input.activity, "completed"),
20
+ failed: countActivity(input.activity, "failed"),
21
+ aborted: countActivity(input.activity, "aborted"),
22
+ averageDurationMs: completedPromptDurations.length
23
+ ? Math.round(completedPromptDurations.reduce((sum, value) => sum + value, 0) / completedPromptDurations.length)
24
+ : null,
25
+ },
26
+ jobs: {
27
+ total: input.jobs.length,
28
+ queued: countJobs(input.jobs, "queued"),
29
+ running: countJobs(input.jobs, "running"),
30
+ completed: countJobs(input.jobs, "completed"),
31
+ failed: countJobs(input.jobs, "failed"),
32
+ aborted: countJobs(input.jobs, "aborted"),
33
+ },
34
+ process: processMetrics(),
35
+ adapters: {
36
+ telegram: getTelegramRateLimitMetrics(),
37
+ discord: getDiscordRateLimitMetrics(),
38
+ },
39
+ };
40
+ }
41
+ function processMetrics() {
42
+ const memory = process.memoryUsage();
43
+ const cpu = process.cpuUsage();
44
+ const uptimeMs = Math.max(0, Math.round(process.uptime() * 1000));
45
+ const totalMs = Math.round((cpu.user + cpu.system) / 1000);
46
+ return {
47
+ pid: process.pid,
48
+ nodeVersion: process.version,
49
+ platform: process.platform,
50
+ arch: process.arch,
51
+ uptimeMs,
52
+ startedAt: new Date(startedAt).toISOString(),
53
+ memory: {
54
+ rssBytes: memory.rss,
55
+ heapTotalBytes: memory.heapTotal,
56
+ heapUsedBytes: memory.heapUsed,
57
+ externalBytes: memory.external,
58
+ arrayBuffersBytes: memory.arrayBuffers,
59
+ },
60
+ cpu: {
61
+ userMs: Math.round(cpu.user / 1000),
62
+ systemMs: Math.round(cpu.system / 1000),
63
+ totalMs,
64
+ percentSinceStart: uptimeMs > 0 ? roundMetric((totalMs / uptimeMs) * 100) : null,
65
+ },
66
+ eventLoop: {
67
+ delayMeanMs: nanosecondsToMilliseconds(eventLoopDelay.mean),
68
+ delayMaxMs: nanosecondsToMilliseconds(eventLoopDelay.max),
69
+ delayP95Ms: nanosecondsToMilliseconds(eventLoopDelay.percentile(95)),
70
+ },
71
+ };
72
+ }
73
+ function nanosecondsToMilliseconds(value) {
74
+ if (!Number.isFinite(value) || value <= 0) {
75
+ return null;
76
+ }
77
+ return roundMetric(value / 1_000_000);
78
+ }
79
+ function roundMetric(value) {
80
+ return Math.round(value * 100) / 100;
81
+ }
82
+ function countJobs(jobs, status) {
83
+ return jobs.filter((job) => job.status === status).length;
84
+ }
85
+ function countActivity(events, status) {
86
+ return events.filter((event) => event.category === "prompt" && event.status === status).length;
87
+ }