@nordbyte/nordrelay 0.5.2 → 0.6.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 (52) hide show
  1. package/.env.example +63 -11
  2. package/README.md +90 -19
  3. package/dist/access-control.js +1 -0
  4. package/dist/activity-events.js +44 -0
  5. package/dist/audit-log.js +40 -2
  6. package/dist/bot-rendering.js +10 -7
  7. package/dist/bot.js +458 -5
  8. package/dist/channel-actions.js +7 -2
  9. package/dist/channel-adapter.js +34 -7
  10. package/dist/channel-command-service.js +156 -0
  11. package/dist/channel-turn-service.js +237 -0
  12. package/dist/config-metadata.js +78 -13
  13. package/dist/config.js +77 -7
  14. package/dist/context-key.js +77 -5
  15. package/dist/discord-artifacts.js +165 -0
  16. package/dist/discord-bot.js +2014 -0
  17. package/dist/discord-channel-runtime.js +133 -0
  18. package/dist/discord-command-surface.js +119 -0
  19. package/dist/discord-rate-limit.js +141 -0
  20. package/dist/index.js +16 -5
  21. package/dist/job-store.js +127 -0
  22. package/dist/metrics.js +41 -0
  23. package/dist/relay-external-activity-monitor.js +47 -6
  24. package/dist/relay-runtime.js +986 -281
  25. package/dist/runtime-cache.js +57 -0
  26. package/dist/session-locks.js +10 -7
  27. package/dist/support-bundle.js +1 -0
  28. package/dist/telegram-access-commands.js +15 -2
  29. package/dist/telegram-access-middleware.js +16 -3
  30. package/dist/telegram-agent-commands.js +25 -0
  31. package/dist/telegram-artifact-commands.js +46 -0
  32. package/dist/telegram-diagnostics-command.js +5 -50
  33. package/dist/telegram-general-commands.js +2 -6
  34. package/dist/telegram-operational-commands.js +14 -6
  35. package/dist/telegram-queue-commands.js +74 -4
  36. package/dist/telegram-support-command.js +7 -0
  37. package/dist/telegram-update-commands.js +27 -0
  38. package/dist/user-management.js +208 -0
  39. package/dist/web-api-contract.js +9 -0
  40. package/dist/web-dashboard-access-routes.js +74 -1
  41. package/dist/web-dashboard-artifact-routes.js +3 -3
  42. package/dist/web-dashboard-assets.js +2 -0
  43. package/dist/web-dashboard-pages.js +97 -13
  44. package/dist/web-dashboard-runtime-routes.js +53 -8
  45. package/dist/web-dashboard-session-routes.js +27 -20
  46. package/dist/web-dashboard-ui.js +1 -0
  47. package/dist/web-dashboard.js +148 -6
  48. package/dist/web-state.js +33 -2
  49. package/dist/webui-assets/dashboard.css +75 -1
  50. package/dist/webui-assets/dashboard.js +358 -47
  51. package/package.json +3 -1
  52. package/plugins/nordrelay/scripts/nordrelay.mjs +210 -17
@@ -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,119 @@
1
+ import { permissionForCommand } from "./access-control.js";
2
+ export function parseDiscordMessageCommand(text) {
3
+ const match = text.match(/^\/([a-zA-Z0-9_-]+)(?:\s+([\s\S]*))?$/);
4
+ return match?.[1] ? { command: match[1].toLowerCase(), argument: match[2]?.trim() ?? "" } : null;
5
+ }
6
+ export function argumentFromDiscordInteraction(interaction) {
7
+ if (interaction.commandName === "prompt") {
8
+ return interaction.options.getString("text") ?? "";
9
+ }
10
+ if (interaction.commandName === "queue") {
11
+ return [interaction.options.getString("action"), interaction.options.getString("id")].filter(Boolean).join(" ");
12
+ }
13
+ if (interaction.commandName === "update") {
14
+ return [interaction.options.getString("target"), interaction.options.getString("agent"), interaction.options.getString("input")].filter(Boolean).join(" ");
15
+ }
16
+ return interaction.options.getString("value") ?? interaction.options.getString("query") ?? interaction.options.getString("thread_id") ?? "";
17
+ }
18
+ export function requiredPermissionForDiscordCommand(command, argument) {
19
+ if (command === "prompt")
20
+ return "prompt.send";
21
+ if (command === "queue")
22
+ return argument.trim() ? "queue.write" : "queue.read";
23
+ return permissionForCommand(command);
24
+ }
25
+ export function isUnauthenticatedDiscordCommandAllowed(command) {
26
+ return command === "link";
27
+ }
28
+ export function permissionForDiscordAction(action) {
29
+ if (action.startsWith("discord_queue_"))
30
+ return "queue.write";
31
+ if (action.startsWith("discord_abort:"))
32
+ return "prompt.abort";
33
+ if (action.startsWith("discord_pick:"))
34
+ return "sessions.write";
35
+ if (action.startsWith("discord_artifact_delete:"))
36
+ return "files.write";
37
+ if (action.startsWith("discord_artifact_"))
38
+ return "files.read";
39
+ if (action.startsWith("agent-update:"))
40
+ return "updates.run";
41
+ return null;
42
+ }
43
+ export function discordCommands() {
44
+ const textOption = (name = "value", description = "Value", required = false) => ({
45
+ type: 3,
46
+ name,
47
+ description,
48
+ required,
49
+ });
50
+ return [
51
+ command("start", "Start or inspect the current NordRelay context"),
52
+ command("help", "Show Discord adapter help"),
53
+ command("prompt", "Send a prompt to the selected agent", [textOption("text", "Prompt text", true)]),
54
+ command("agent", "Select or show the active agent", [textOption("value", "Agent id")]),
55
+ command("auth", "Show selected agent auth status"),
56
+ command("login", "Start selected agent login"),
57
+ command("logout", "Sign out of the selected agent"),
58
+ command("session", "Show the active session"),
59
+ command("sessions", "Browse recent sessions", [textOption("query", "Search query")]),
60
+ command("new", "Create a new session", [textOption("value", "Workspace path")]),
61
+ command("switch", "Switch to a session", [textOption("thread_id", "Thread id", true)]),
62
+ command("attach", "Attach a session", [textOption("thread_id", "Thread id", true)]),
63
+ command("handback", "Hand the active session back to the native CLI"),
64
+ command("model", "Select or show models", [textOption("value", "Model id")]),
65
+ command("reasoning", "Select reasoning effort", [textOption("value", "Reasoning value")]),
66
+ command("effort", "Select reasoning effort", [textOption("value", "Reasoning value")]),
67
+ command("fast", "Toggle fast mode", [textOption("value", "on/off")]),
68
+ command("launch", "Select launch profile", [textOption("value", "Launch profile id")]),
69
+ command("launch_profiles", "Select launch profile", [textOption("value", "Launch profile id")]),
70
+ command("workspaces", "List allowed workspaces"),
71
+ command("pin", "Pin current or given thread", [textOption("value", "Thread id")]),
72
+ command("unpin", "Unpin current or given thread", [textOption("value", "Thread id")]),
73
+ command("pinned", "Show pinned threads"),
74
+ command("queue", "Show or manage queue", [textOption("action", "pause/resume/clear/run/cancel/top/up/down"), textOption("id", "Queue id")]),
75
+ command("clearqueue", "Clear queue"),
76
+ command("cancel", "Cancel queued prompt", [textOption("value", "Queue id", true)]),
77
+ command("abort", "Abort the active task"),
78
+ command("stop", "Abort the active task"),
79
+ command("retry", "Retry the last prompt"),
80
+ command("sync", "Sync from local agent state"),
81
+ command("activity", "Show recent activity", [textOption("value", "Limit")]),
82
+ command("tasks", "Show recent tasks", [textOption("value", "Limit")]),
83
+ command("progress", "Show current turn progress"),
84
+ command("audit", "Show recent audit events", [textOption("value", "Limit")]),
85
+ command("artifacts", "List or send artifacts", [textOption("value", "zip <turn-id>")]),
86
+ command("logs", "Show logs", [textOption("value", "Target and line count")]),
87
+ command("version", "Show versions"),
88
+ command("status", "Show status"),
89
+ command("health", "Show health"),
90
+ command("diagnostics", "Show diagnostics"),
91
+ command("support", "Show support diagnostics"),
92
+ command("restart", "Restart NordRelay"),
93
+ command("update", "Update NordRelay or agents", [
94
+ textOption("target", "jobs, install, log, cancel, input, or agent id"),
95
+ textOption("agent", "Agent id or job id"),
96
+ textOption("input", "Text for update input"),
97
+ ]),
98
+ command("lock", "Lock this context"),
99
+ command("unlock", "Unlock this context"),
100
+ command("locks", "List locks"),
101
+ command("mirror", "Set mirror mode", [textOption("value", "off/status/final/full")]),
102
+ command("notify", "Set notification mode", [textOption("value", "off/minimal/all")]),
103
+ command("voice", "Show or change voice settings", [textOption("value", "transcribe-only on/off")]),
104
+ command("register_channel", "Enable this Discord channel for NordRelay"),
105
+ command("link", "Link this Discord account with a NordRelay code", [textOption("value", "Link code", true)]),
106
+ command("whoami", "Show linked NordRelay user"),
107
+ command("channels", "Show channel adapters"),
108
+ command("agents", "Show agent adapters"),
109
+ ];
110
+ }
111
+ function command(name, description, options = []) {
112
+ return {
113
+ name,
114
+ description,
115
+ type: 1,
116
+ dm_permission: true,
117
+ options,
118
+ };
119
+ }
@@ -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";
@@ -23,6 +24,7 @@ import { SessionRegistry } from "./session-registry.js";
23
24
  import { UserStore } from "./user-management.js";
24
25
  let registry;
25
26
  let bot;
27
+ let discordBridge;
26
28
  let webhookServer;
27
29
  let runtimeConfig;
28
30
  try {
@@ -31,8 +33,12 @@ try {
31
33
  configureRedaction(config.telegramRedactPatterns);
32
34
  installConsoleLogger(config.logFormat);
33
35
  registry = new SessionRegistry(config);
34
- bot = createBot(config, registry);
35
- await registerCommands(bot);
36
+ if (config.telegramEnabled) {
37
+ bot = createBot(config, registry);
38
+ await registerCommands(bot);
39
+ }
40
+ discordBridge = createDiscordBridge(config, registry);
41
+ await discordBridge?.start();
36
42
  console.log("NordRelay running");
37
43
  const userStore = new UserStore();
38
44
  if (userStore.hasAdminUser()) {
@@ -74,14 +80,15 @@ try {
74
80
  console.warn("Warning: Default launch profile uses danger-full-access.");
75
81
  }
76
82
  }
77
- console.log("Session mode: per Telegram context");
78
- console.log(`Telegram transport: ${config.telegramTransport}`);
83
+ console.log("Session mode: per chat context");
84
+ console.log(`Telegram: ${config.telegramEnabled ? config.telegramTransport : "disabled"}`);
85
+ console.log(`Discord: ${config.discordEnabled ? "enabled" : "disabled"}`);
79
86
  await writeConnectorState({
80
87
  status: "ready",
81
88
  pid: Number(process.env.NORDRELAY_WRAPPER_PID) || process.pid,
82
89
  appPid: process.pid,
83
90
  workspace: config.workspace,
84
- sessionMode: "per Telegram context",
91
+ sessionMode: "per chat context",
85
92
  authenticated: authStatus.authenticated,
86
93
  authMethod: authStatus.method,
87
94
  codexCli: describeCodexCli(codexCli),
@@ -91,6 +98,7 @@ try {
91
98
  claudeCodeCli: describeClaudeCodeCli(claudeCodeCli),
92
99
  openClawGateway: config.openClawGatewayUrl,
93
100
  telegramTransport: config.telegramTransport,
101
+ discordEnabled: config.discordEnabled,
94
102
  });
95
103
  }
96
104
  catch (error) {
@@ -137,6 +145,9 @@ const shutdown = (signal) => {
137
145
  console.log(`Received ${signal}, shutting down NordRelay...`);
138
146
  if (bot && runtimeConfig?.telegramTransport !== "webhook")
139
147
  bot.stop();
148
+ void discordBridge?.stop().catch((error) => {
149
+ console.warn("Failed to stop Discord bridge:", error instanceof Error ? error.message : String(error));
150
+ });
140
151
  webhookServer?.close();
141
152
  setTimeout(() => {
142
153
  registry?.disposeAll();
@@ -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,41 @@
1
+ import { getDiscordRateLimitMetrics } from "./discord-rate-limit.js";
2
+ import { getTelegramRateLimitMetrics } from "./telegram-rate-limit.js";
3
+ export function buildRuntimeMetrics(input) {
4
+ const completedPromptDurations = input.activity
5
+ .filter((event) => event.category === "prompt" && event.status === "completed" && typeof event.durationMs === "number")
6
+ .map((event) => event.durationMs);
7
+ return {
8
+ generatedAt: new Date().toISOString(),
9
+ queue: {
10
+ length: input.queueLength,
11
+ paused: input.queuePaused,
12
+ },
13
+ turns: {
14
+ active: input.activeTurnCount,
15
+ completed: countActivity(input.activity, "completed"),
16
+ failed: countActivity(input.activity, "failed"),
17
+ aborted: countActivity(input.activity, "aborted"),
18
+ averageDurationMs: completedPromptDurations.length
19
+ ? Math.round(completedPromptDurations.reduce((sum, value) => sum + value, 0) / completedPromptDurations.length)
20
+ : null,
21
+ },
22
+ jobs: {
23
+ total: input.jobs.length,
24
+ queued: countJobs(input.jobs, "queued"),
25
+ running: countJobs(input.jobs, "running"),
26
+ completed: countJobs(input.jobs, "completed"),
27
+ failed: countJobs(input.jobs, "failed"),
28
+ aborted: countJobs(input.jobs, "aborted"),
29
+ },
30
+ adapters: {
31
+ telegram: getTelegramRateLimitMetrics(),
32
+ discord: getDiscordRateLimitMetrics(),
33
+ },
34
+ };
35
+ }
36
+ function countJobs(jobs, status) {
37
+ return jobs.filter((job) => job.status === status).length;
38
+ }
39
+ function countActivity(events, status) {
40
+ return events.filter((event) => event.category === "prompt" && event.status === status).length;
41
+ }