@nordbyte/nordrelay 0.8.2 → 0.8.3

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 (39) hide show
  1. package/README.md +4 -0
  2. package/dist/access/audit-log.js +30 -13
  3. package/dist/channels/discord/discord-bot.js +12 -27
  4. package/dist/channels/shared/channel-bridge-controller.js +1 -1
  5. package/dist/channels/shared/channel-prompt-queue.js +37 -0
  6. package/dist/channels/shared/channel-turn-service.js +23 -9
  7. package/dist/channels/slack/slack-bot.js +12 -15
  8. package/dist/channels/telegram/bot.js +18 -4
  9. package/dist/core/pagination.js +22 -0
  10. package/dist/peers/peer-store.js +16 -0
  11. package/dist/peers/peer-types.js +19 -0
  12. package/dist/peers/peer-web-proxy-contract.js +2 -0
  13. package/dist/runtime/relay-external-activity-monitor.js +15 -0
  14. package/dist/runtime/relay-queue-service.js +1 -0
  15. package/dist/runtime/relay-runtime-dashboard.js +3 -0
  16. package/dist/runtime/relay-runtime-helpers.js +3 -0
  17. package/dist/runtime/relay-runtime-prompt-queue-artifacts.js +14 -10
  18. package/dist/runtime/relay-runtime-sessions.js +8 -0
  19. package/dist/runtime/relay-runtime-trace.js +92 -0
  20. package/dist/runtime/relay-runtime-updates-jobs.js +11 -5
  21. package/dist/runtime/relay-runtime.js +16 -6
  22. package/dist/state/prompt-store.js +13 -1
  23. package/dist/web/web-api-contract.js +2 -0
  24. package/dist/web/web-dashboard-access-routes.js +15 -12
  25. package/dist/web/web-dashboard-artifact-routes.js +6 -2
  26. package/dist/web/web-dashboard-assets.js +1 -0
  27. package/dist/web/web-dashboard-pages.js +58 -20
  28. package/dist/web/web-dashboard-peer-routes.js +19 -0
  29. package/dist/web/web-dashboard-runtime-routes.js +8 -1
  30. package/dist/web/web-dashboard-session-routes.js +17 -12
  31. package/dist/web/web-dashboard-ui.js +46 -10
  32. package/dist/web/web-performance.js +2 -0
  33. package/dist/web/web-state.js +33 -4
  34. package/dist/webui-assets/dashboard.css +227 -39
  35. package/dist/webui-assets/dashboard.js +728 -58
  36. package/package.json +4 -2
  37. package/plugins/nordrelay/scripts/nordrelay.mjs +333 -8
  38. package/plugins/nordrelay/scripts/service-installer.mjs +183 -0
  39. package/scripts/postinstall.mjs +122 -0
@@ -97,6 +97,7 @@ export function taskToUnifiedJob(id, kind, title, task, options) {
97
97
  startedAt: task.startedAt,
98
98
  updatedAt: task.updatedAt,
99
99
  durationMs: task.durationMs,
100
+ correlationId: task.correlationId,
100
101
  summary: task.prompt || task.detail,
101
102
  logTail: task.currentTool || task.lastTool ? `Current tool: ${task.currentTool ?? "-"}\nLast tool: ${task.lastTool ?? "-"}` : undefined,
102
103
  ...options,
@@ -117,6 +118,7 @@ export function activityToUnifiedJob(event, kind, title, options) {
117
118
  updatedAt: event.timestamp,
118
119
  finishedAt: event.timestamp,
119
120
  durationMs: event.durationMs,
121
+ correlationId: event.correlationId,
120
122
  summary: event.prompt || event.detail,
121
123
  logPath: event.detail,
122
124
  logTail: event.detail,
@@ -145,6 +147,7 @@ export function promptActivityToUnifiedJob(event) {
145
147
  threadId: event.threadId,
146
148
  workspace: event.workspace,
147
149
  owner: event.actor,
150
+ correlationId: event.correlationId,
148
151
  startedAt: event.timestamp,
149
152
  updatedAt: event.timestamp,
150
153
  finishedAt: status === "running" || status === "queued" ? undefined : event.timestamp,
@@ -14,7 +14,7 @@ import { activeSessionSourceForContextKey, ChannelMirrorRegistry } from "../chan
14
14
  import { listThreads as listCodexThreads } from "../agents/codex/codex-state.js";
15
15
  import { friendlyErrorText } from "../core/error-messages.js";
16
16
  import { clearLogFile, getAgentUpdateLogPath, getConnectorHealth, getConnectorLogPath, getPackageVersion, getUpdateLogPath, getVersionChecks, readConnectorState, readFormattedLogTail, spawnConnectorRestart, spawnSelfUpdate } from "../support/operations.js";
17
- import { PromptStore, toPromptEnvelope } from "../state/prompt-store.js";
17
+ import { ensurePromptCorrelationId, PromptStore, toPromptEnvelope } from "../state/prompt-store.js";
18
18
  import { UnifiedJobStore } from "../state/job-store.js";
19
19
  import { buildRuntimeMetrics } from "./metrics.js";
20
20
  import { RelayArtifactService } from "./relay-artifact-service.js";
@@ -134,10 +134,11 @@ export async function relayRuntimeSendUploadPrompt(runtime, options, actor) {
134
134
  }
135
135
  export async function relayRuntimeSendEnvelope(runtime, envelope, actor) {
136
136
  const activityActor = envelope.activityActor ?? actor;
137
+ const correlated = ensurePromptCorrelationId({ ...envelope, activityActor });
137
138
  const session = await runtime.getSession(false);
138
139
  const external = getExternalSnapshotForSession(session, runtime.config, { maxEvents: 0 });
139
140
  if (session.isProcessing() || external?.activity.active) {
140
- const queued = runtime.queueService.enqueue(envelope);
141
+ const queued = runtime.queueService.enqueue(correlated);
141
142
  const info = runtime.publicInfo(session);
142
143
  runtime.appendActivity({
143
144
  source: "web",
@@ -147,7 +148,8 @@ export async function relayRuntimeSendEnvelope(runtime, envelope, actor) {
147
148
  workspace: info.workspace,
148
149
  agentId: info.agentId,
149
150
  actor: activityActor,
150
- prompt: envelope.description,
151
+ correlationId: correlated.correlationId,
152
+ prompt: correlated.description,
151
153
  detail: external?.activity.active
152
154
  ? `Queued because ${external.agentLabel} CLI is still processing another task.`
153
155
  : `Queued at position ${runtime.queueService.length()}.`,
@@ -161,18 +163,19 @@ export async function relayRuntimeSendEnvelope(runtime, envelope, actor) {
161
163
  workspace: info.workspace,
162
164
  promptId: queued.id,
163
165
  actor: activityActor,
164
- description: envelope.description,
166
+ correlationId: correlated.correlationId,
167
+ description: correlated.description,
165
168
  });
166
169
  if (external?.activity.active) {
167
170
  runtime.broadcastStatus(`Waiting for ${external.agentLabel} CLI task... ${runtime.queueService.length()} queued.`, "info");
168
171
  }
169
172
  runtime.broadcastQueue();
170
- return { queued: true, queueId: queued.id };
173
+ return { queued: true, queueId: queued.id, correlationId: correlated.correlationId };
171
174
  }
172
- void runtime.runPrompt(session, { ...envelope, activityActor }).catch((error) => {
173
- runtime.broadcast({ type: "turn_error", id: runtime.currentTurnId ?? "turn", error: friendlyErrorText(error), at: new Date().toISOString() });
175
+ void runtime.runPrompt(session, correlated).catch((error) => {
176
+ runtime.broadcast({ type: "turn_error", id: runtime.currentTurnId ?? "turn", error: friendlyErrorText(error), at: new Date().toISOString(), correlationId: correlated.correlationId });
174
177
  });
175
- return { queued: false };
178
+ return { queued: false, correlationId: correlated.correlationId };
176
179
  }
177
180
  export function relayRuntimeQueue(runtime) {
178
181
  return runtime.queueService.list();
@@ -194,6 +197,7 @@ export function relayRuntimeQueueAction(runtime, action, id, actor) {
194
197
  threadId: null,
195
198
  workspace: runtime.config.workspace,
196
199
  actor,
200
+ correlationId: affected?.correlationId,
197
201
  prompt: affected?.description,
198
202
  detail: id ? `${action}: ${id}` : `${action}: ${before.length} queued`,
199
203
  });
@@ -207,9 +211,9 @@ export function relayRuntimeQueueAction(runtime, action, id, actor) {
207
211
  runtime.broadcastQueue();
208
212
  return runtime.queue();
209
213
  }
210
- export async function relayRuntimeArtifacts(runtime) {
214
+ export async function relayRuntimeArtifacts(runtime, limit = 20) {
211
215
  const session = await runtime.getSession(true);
212
- return runtime.artifactService.list(session.getInfo().workspace, 20);
216
+ return runtime.artifactService.list(session.getInfo().workspace, limit);
213
217
  }
214
218
  export async function relayRuntimeArtifact(runtime, turnId) {
215
219
  const session = await runtime.getSession(true);
@@ -348,6 +348,14 @@ export function relayRuntimeActivity(runtime, options = {}) {
348
348
  const currentInfo = runtime.registry.get(runtime.contextKey)?.getInfo();
349
349
  return runtime.activityStore.list(options).map((event) => runtime.enrichActivityEvent(event, currentInfo));
350
350
  }
351
+ export function relayRuntimeActivityPage(runtime, options = {}) {
352
+ const currentInfo = runtime.registry.get(runtime.contextKey)?.getInfo();
353
+ const page = runtime.activityStore.listPage(options);
354
+ return {
355
+ ...page,
356
+ items: page.items.map((event) => runtime.enrichActivityEvent(event, currentInfo)),
357
+ };
358
+ }
351
359
  export async function relayRuntimeRetry(runtime, actor) {
352
360
  const cached = runtime.queueService.getLastPrompt();
353
361
  if (!cached) {
@@ -0,0 +1,92 @@
1
+ export async function relayRuntimeTrace(runtime, correlationId) {
2
+ const id = correlationId.trim();
3
+ if (!id) {
4
+ throw new Error("correlationId is required.");
5
+ }
6
+ const activity = runtime.activityStore.findByCorrelationId(id, 200).map((event) => runtime.enrichActivityEvent(event));
7
+ const audit = runtime.auditStore.findByCorrelationId(id, 200);
8
+ const chat = runtime.chatStore.findByCorrelationId(id, 200);
9
+ const queue = runtime.queue().filter((item) => item.correlationId === id);
10
+ const jobs = (await runtime.jobs({ limit: 500 })).jobs.filter((job) => job.correlationId === id);
11
+ const timeline = traceTimeline({ activity, audit, chat, queue, jobs });
12
+ const timestamps = timeline.map((item) => Date.parse(item.at)).filter(Number.isFinite);
13
+ const lastStatus = [...activity].reverse().find((event) => event.status)?.status ??
14
+ jobs.find((job) => job.status)?.status ??
15
+ (queue.length ? "queued" : "unknown");
16
+ return {
17
+ correlationId: id,
18
+ summary: {
19
+ startedAt: timestamps.length ? new Date(Math.min(...timestamps)).toISOString() : null,
20
+ updatedAt: timestamps.length ? new Date(Math.max(...timestamps)).toISOString() : null,
21
+ status: lastStatus,
22
+ sources: [...new Set(timeline.map((item) => item.source))],
23
+ threadId: activity.find((event) => event.threadId)?.threadId ?? chat.find((message) => message.threadId)?.threadId ?? jobs.find((job) => job.threadId)?.threadId,
24
+ workspace: activity.find((event) => event.workspace)?.workspace ?? jobs.find((job) => job.workspace)?.workspace,
25
+ agentId: activity.find((event) => event.agentId)?.agentId ?? jobs.find((job) => job.agentId)?.agentId,
26
+ },
27
+ activity,
28
+ audit,
29
+ chat,
30
+ queue,
31
+ jobs,
32
+ timeline,
33
+ };
34
+ }
35
+ function traceTimeline(input) {
36
+ return [
37
+ ...input.activity.map((event) => ({
38
+ id: event.id,
39
+ at: event.timestamp,
40
+ source: "activity",
41
+ status: event.status,
42
+ type: event.type,
43
+ title: `${event.source} ${event.type}`,
44
+ detail: event.prompt || event.detail,
45
+ threadId: event.threadId,
46
+ workspace: event.workspace,
47
+ agentId: event.agentId,
48
+ })),
49
+ ...input.audit.map((event) => ({
50
+ id: event.id,
51
+ at: event.timestamp,
52
+ source: "audit",
53
+ status: event.status,
54
+ type: event.action,
55
+ title: `${event.channelId} ${event.action}`,
56
+ detail: event.description || event.detail,
57
+ threadId: event.threadId,
58
+ workspace: event.workspace,
59
+ agentId: event.agentId,
60
+ })),
61
+ ...input.chat.map((message) => ({
62
+ id: message.id,
63
+ at: message.timestamp,
64
+ source: "chat",
65
+ type: message.role,
66
+ title: `${message.source} ${message.role}`,
67
+ detail: message.text,
68
+ threadId: message.threadId,
69
+ })),
70
+ ...input.queue.map((item) => ({
71
+ id: item.id,
72
+ at: item.createdAt,
73
+ source: "queue",
74
+ status: item.lastError ? "failed" : "queued",
75
+ type: "queued_prompt",
76
+ title: `Queued prompt ${item.id}`,
77
+ detail: item.lastError || item.description,
78
+ })),
79
+ ...input.jobs.map((job) => ({
80
+ id: job.id,
81
+ at: job.updatedAt,
82
+ source: "job",
83
+ status: job.status,
84
+ type: job.kind,
85
+ title: job.title,
86
+ detail: job.summary || job.logTail,
87
+ threadId: job.threadId,
88
+ workspace: job.workspace,
89
+ agentId: job.agentId,
90
+ })),
91
+ ].sort((left, right) => Date.parse(left.at) - Date.parse(right.at));
92
+ }
@@ -13,6 +13,7 @@ import { ChannelTurnService } from "../channels/shared/channel-turn-service.js";
13
13
  import { activeSessionSourceForContextKey, ChannelMirrorRegistry } from "../channels/shared/channel-mirror-registry.js";
14
14
  import { listThreads as listCodexThreads } from "../agents/codex/codex-state.js";
15
15
  import { friendlyErrorText } from "../core/error-messages.js";
16
+ import { cursorPage, normalizeCursorLimit } from "../core/pagination.js";
16
17
  import { clearLogFile, getAgentUpdateLogPath, getConnectorHealth, getConnectorLogPath, getPackageVersion, getUpdateLogPath, getVersionChecks, readConnectorState, readFormattedLogTail, spawnConnectorRestart, spawnSelfUpdate } from "../support/operations.js";
17
18
  import { PromptStore, toPromptEnvelope } from "../state/prompt-store.js";
18
19
  import { UnifiedJobStore } from "../state/job-store.js";
@@ -161,7 +162,7 @@ export function relayRuntimeTasks(runtime) {
161
162
  recent: runtime.activity({ limit: 20 }),
162
163
  };
163
164
  }
164
- export async function relayRuntimeJobs(runtime) {
165
+ export async function relayRuntimeJobs(runtime, options = {}) {
165
166
  const jobs = [];
166
167
  const current = runtime.currentProgress;
167
168
  if (current) {
@@ -190,6 +191,7 @@ export async function relayRuntimeJobs(runtime) {
190
191
  threadId: null,
191
192
  workspace: runtime.config.workspace,
192
193
  owner: item.activityActor,
194
+ correlationId: item.correlationId,
193
195
  startedAt: createdAt,
194
196
  updatedAt: createdAt,
195
197
  summary: item.description,
@@ -245,8 +247,12 @@ export async function relayRuntimeJobs(runtime) {
245
247
  }
246
248
  const liveJobs = dedupeJobs(jobs);
247
249
  const storedJobs = runtime.jobStore.upsertMany(liveJobs);
250
+ const sortedJobs = dedupeJobs([...liveJobs, ...storedJobs]).sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt));
251
+ const limit = normalizeCursorLimit(options.limit, 100, 500);
252
+ const page = cursorPage(sortedJobs, options.cursor, limit, (job) => job.id);
248
253
  return {
249
- jobs: dedupeJobs([...liveJobs, ...storedJobs]).sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt)),
254
+ jobs: page.items,
255
+ pagination: page.pagination,
250
256
  updatedAt: new Date().toISOString(),
251
257
  };
252
258
  }
@@ -254,13 +260,13 @@ export async function relayRuntimeJobLog(runtime, id) {
254
260
  if (id.startsWith("agent-update:")) {
255
261
  const updateId = id.slice("agent-update:".length);
256
262
  const log = runtime.agentUpdates.readLog(updateId);
257
- return { job: (await runtime.jobs()).jobs.find((job) => job.id === id) ?? null, plain: log.plain };
263
+ return { job: (await runtime.jobs({ limit: 500 })).jobs.find((job) => job.id === id) ?? null, plain: log.plain };
258
264
  }
259
265
  if (id.startsWith("queue:")) {
260
266
  const queueId = id.slice("queue:".length);
261
267
  const item = runtime.queueService.rawList().find((candidate) => candidate.id === queueId);
262
268
  return {
263
- job: (await runtime.jobs()).jobs.find((job) => job.id === id) ?? null,
269
+ job: (await runtime.jobs({ limit: 500 })).jobs.find((job) => job.id === id) ?? null,
264
270
  plain: item ? [
265
271
  `Queued prompt: ${item.id}`,
266
272
  `Created: ${new Date(item.createdAt).toISOString()}`,
@@ -270,7 +276,7 @@ export async function relayRuntimeJobLog(runtime, id) {
270
276
  ].filter(Boolean).join("\n") : "Queued prompt not found.",
271
277
  };
272
278
  }
273
- const job = (await runtime.jobs()).jobs.find((candidate) => candidate.id === id) ?? null;
279
+ const job = (await runtime.jobs({ limit: 500 })).jobs.find((candidate) => candidate.id === id) ?? null;
274
280
  return { job, plain: job?.logTail || job?.logPath || job?.summary || runtime.jobStore.get(id)?.summary || "No log available for this job." };
275
281
  }
276
282
  export async function relayRuntimeJobAction(runtime, id, action, actor) {
@@ -32,10 +32,11 @@ import { createSupportBundle } from "../support/support-bundle.js";
32
32
  import { transcribeAudio } from "../artifacts/voice.js";
33
33
  import { WebActivityStore, WebChatStore, } from "../web/web-state.js";
34
34
  import { evaluateWorkspacePolicy, filterAllowedWorkspaces } from "../core/workspace-policy.js";
35
- import { relayRuntimeSubscribe, relayRuntimeSnapshot, relayRuntimeStatus, relayRuntimeBootstrapStatus, relayRuntimeVersion, relayRuntimeDiagnostics, relayRuntimeAdapterHealth, relayRuntimePermissions, relayRuntimeMetrics, relayRuntimeAudit, relayRuntimeSupportBundle, relayRuntimeLogs, relayRuntimeClearLogs, relayRuntimeRestartConnector, relayRuntimeDispose } from "./relay-runtime-dashboard.js";
35
+ import { relayRuntimeSubscribe, relayRuntimeSnapshot, relayRuntimeStatus, relayRuntimeBootstrapStatus, relayRuntimeVersion, relayRuntimeDiagnostics, relayRuntimeAdapterHealth, relayRuntimePermissions, relayRuntimeMetrics, relayRuntimeAudit, relayRuntimeAuditPage, relayRuntimeSupportBundle, relayRuntimeLogs, relayRuntimeClearLogs, relayRuntimeRestartConnector, relayRuntimeDispose } from "./relay-runtime-dashboard.js";
36
+ import { relayRuntimeTrace } from "./relay-runtime-trace.js";
36
37
  import { relayRuntimeUpdateConnector, relayRuntimeAgentUpdateJobs, relayRuntimeStartAgentUpdate, relayRuntimeAgentUpdateLog, relayRuntimeDeleteAgentUpdateLog, relayRuntimeSendAgentUpdateInput, relayRuntimeCancelAgentUpdate, relayRuntimeTasks, relayRuntimeJobs, relayRuntimeJobLog, relayRuntimeJobAction, relayRuntimeRecordAgentUpdateLifecycle } from "./relay-runtime-updates-jobs.js";
37
38
  import { relayRuntimeActiveSessions, relayRuntimeGetSession, relayRuntimeListKnownContextMetadata, relayRuntimeDiscoverRunningConnectorSessions, relayRuntimeDiscoverActiveCodexSessions, relayRuntimeExternalActiveSession, relayRuntimeSessionStubForMetadata, relayRuntimeCapabilitiesForAgent, relayRuntimeActiveSessionKey, relayRuntimePreferredActiveSession, relayRuntimeRecordActivity, relayRuntimeAppendActivity, relayRuntimeEnrichActivityInput, relayRuntimeEnrichActivityEvent, relayRuntimeEnrichActivityFields, relayRuntimeAppendAudit, relayRuntimeUpdateCurrentProgress, relayRuntimeAddCurrentTool, relayRuntimeBroadcastQueue, relayRuntimeBroadcastStatus, relayRuntimeBroadcast, relayRuntimeScheduleActiveSessionsBroadcast, relayRuntimePublicInfo } from "./relay-runtime-active-sessions.js";
38
- import { relayRuntimeLocks, relayRuntimeLockWebSession, relayRuntimeUnlockWebSession, relayRuntimeControlOptions, relayRuntimeAuthStatus, relayRuntimeLogin, relayRuntimeLogout, relayRuntimeChatHistory, relayRuntimeWebMirrorPreference, relayRuntimeSessionDetail, relayRuntimeClearChatHistory, relayRuntimeActivity, relayRuntimeRetry, relayRuntimeSync, relayRuntimeListSessions, relayRuntimeListSessionsPage, relayRuntimeFilteredSessions, relayRuntimeListModels, relayRuntimeSetAgent, relayRuntimeNewSession, relayRuntimeSwitchSession, relayRuntimeAttachSession, relayRuntimeSetModel, relayRuntimeSetReasoningEffort, relayRuntimeSetFastMode, relayRuntimeSetLaunchProfile, relayRuntimeHandback, relayRuntimeAbort, relayRuntimeGetControlSession, relayRuntimeCliPathOptions } from "./relay-runtime-sessions.js";
39
+ import { relayRuntimeLocks, relayRuntimeLockWebSession, relayRuntimeUnlockWebSession, relayRuntimeControlOptions, relayRuntimeAuthStatus, relayRuntimeLogin, relayRuntimeLogout, relayRuntimeChatHistory, relayRuntimeWebMirrorPreference, relayRuntimeSessionDetail, relayRuntimeClearChatHistory, relayRuntimeActivity, relayRuntimeActivityPage, relayRuntimeRetry, relayRuntimeSync, relayRuntimeListSessions, relayRuntimeListSessionsPage, relayRuntimeFilteredSessions, relayRuntimeListModels, relayRuntimeSetAgent, relayRuntimeNewSession, relayRuntimeSwitchSession, relayRuntimeAttachSession, relayRuntimeSetModel, relayRuntimeSetReasoningEffort, relayRuntimeSetFastMode, relayRuntimeSetLaunchProfile, relayRuntimeHandback, relayRuntimeAbort, relayRuntimeGetControlSession, relayRuntimeCliPathOptions } from "./relay-runtime-sessions.js";
39
40
  import { relayRuntimeSendPrompt, relayRuntimeSendUploadPrompt, relayRuntimeSendEnvelope, relayRuntimeQueue, relayRuntimeQueuePaused, relayRuntimeQueueAction, relayRuntimeArtifacts, relayRuntimeArtifact, relayRuntimeDeleteArtifact, relayRuntimeCreateArtifactZip, relayRuntimeArtifactPreview, relayRuntimeEnsureActiveThread, relayRuntimeEnsureIdle, relayRuntimeRunPrompt, relayRuntimeDrainQueue, relayRuntimeUpdateSession } from "./relay-runtime-prompt-queue-artifacts.js";
40
41
  export const WEB_CONTEXT_KEY = "web:dashboard";
41
42
  const ACTIVE_CODEX_DISCOVERY_LIMIT = 200;
@@ -208,8 +209,8 @@ export class RelayRuntime {
208
209
  tasks() {
209
210
  return relayRuntimeTasks(this);
210
211
  }
211
- async jobs() {
212
- return relayRuntimeJobs(this);
212
+ async jobs(options = {}) {
213
+ return relayRuntimeJobs(this, options);
213
214
  }
214
215
  async jobLog(id) {
215
216
  return relayRuntimeJobLog(this, id);
@@ -226,6 +227,12 @@ export class RelayRuntime {
226
227
  audit(options = 50) {
227
228
  return relayRuntimeAudit(this, options);
228
229
  }
230
+ auditPage(options = {}) {
231
+ return relayRuntimeAuditPage(this, options);
232
+ }
233
+ async trace(correlationId) {
234
+ return relayRuntimeTrace(this, correlationId);
235
+ }
229
236
  async supportBundle(actor) {
230
237
  return relayRuntimeSupportBundle(this, actor);
231
238
  }
@@ -265,6 +272,9 @@ export class RelayRuntime {
265
272
  activity(options = {}) {
266
273
  return relayRuntimeActivity(this, options);
267
274
  }
275
+ activityPage(options = {}) {
276
+ return relayRuntimeActivityPage(this, options);
277
+ }
268
278
  async retry(actor) {
269
279
  return relayRuntimeRetry(this, actor);
270
280
  }
@@ -331,8 +341,8 @@ export class RelayRuntime {
331
341
  queueAction(action, id, actor) {
332
342
  return relayRuntimeQueueAction(this, action, id, actor);
333
343
  }
334
- async artifacts() {
335
- return relayRuntimeArtifacts(this);
344
+ async artifacts(limit = 20) {
345
+ return relayRuntimeArtifacts(this, limit);
336
346
  }
337
347
  async artifact(turnId) {
338
348
  return relayRuntimeArtifact(this, turnId);
@@ -224,6 +224,16 @@ export function toPromptEnvelope(input, artifactOutDir) {
224
224
  description: describePromptInput(input),
225
225
  };
226
226
  }
227
+ export function createCorrelationId() {
228
+ return randomUUID().replace(/-/g, "").slice(0, 12);
229
+ }
230
+ export function ensurePromptCorrelationId(prompt) {
231
+ const existing = prompt.correlationId?.trim();
232
+ if (existing) {
233
+ return { ...prompt, correlationId: existing };
234
+ }
235
+ return { ...prompt, correlationId: createCorrelationId() };
236
+ }
227
237
  function createQueueId() {
228
238
  return randomUUID().replace(/-/g, "").slice(0, 8);
229
239
  }
@@ -232,7 +242,9 @@ function isPromptEnvelope(value) {
232
242
  return false;
233
243
  }
234
244
  const candidate = value;
235
- return isCodexPromptInput(candidate.input) && typeof candidate.description === "string";
245
+ return isCodexPromptInput(candidate.input) &&
246
+ typeof candidate.description === "string" &&
247
+ (candidate.correlationId === undefined || typeof candidate.correlationId === "string");
236
248
  }
237
249
  function isQueuedPrompt(value) {
238
250
  return isPromptEnvelope(value) &&
@@ -9,6 +9,7 @@ export const WEB_API_ROUTE_DEFINITIONS = [
9
9
  exact("/api/progress", ["GET"], "inspect"),
10
10
  exact("/api/metrics", ["GET"], "inspect"),
11
11
  exact("/api/jobs", ["GET"], "inspect"),
12
+ exact("/api/trace", ["GET"], "sessions.read"),
12
13
  dynamic("/api/jobs/:id/log", "^/api/jobs/[^/]+/log$", ["GET"], "inspect", `/api/jobs/${stringToken}/log`),
13
14
  dynamic("/api/jobs/:id/action", "^/api/jobs/[^/]+/action$", ["POST"], "inspect", `/api/jobs/${stringToken}/action`),
14
15
  exact("/api/active-sessions", ["GET"], "sessions.read"),
@@ -35,6 +36,7 @@ export const WEB_API_ROUTE_DEFINITIONS = [
35
36
  exact("/api/peers/global-sessions", ["GET"], "sessions.read"),
36
37
  dynamic("/api/peers/invitations/:id", "^/api/peers/invitations/[^/]+$", ["DELETE"], "peers.write", `/api/peers/invitations/${stringToken}`),
37
38
  dynamic("/api/peers/:id/repin", "^/api/peers/[^/]+/repin$", ["POST"], "peers.write", `/api/peers/${stringToken}/repin`),
39
+ dynamic("/api/peers/:id/rotate", "^/api/peers/[^/]+/rotate$", ["POST"], "peers.write", `/api/peers/${stringToken}/rotate`),
38
40
  dynamic("/api/peers/:id/health", "^/api/peers/[^/]+/health$", ["GET"], "peers.connect", `/api/peers/${stringToken}/health`),
39
41
  dynamic("/api/peers/:id", "^/api/peers/[^/]+$", ["PATCH", "DELETE"], "peers.write", `/api/peers/${stringToken}`),
40
42
  dynamic("/api/peers/:id/proxy", "^/api/peers/[^/]+/proxy$", ["POST"], "peers.connect", `/api/peers/${stringToken}/proxy`),
@@ -278,19 +278,22 @@ export async function handleDashboardAccessRoute(req, res, url, options) {
278
278
  return true;
279
279
  }
280
280
  if (req.method === "GET" && url.pathname === "/api/audit") {
281
+ const page = runtime.auditPage({
282
+ limit: numberParam(url, "limit", 50),
283
+ cursor: url.searchParams.get("cursor") || undefined,
284
+ channelId: (url.searchParams.get("channel") || "all"),
285
+ category: (url.searchParams.get("category") || "all"),
286
+ status: (url.searchParams.get("status") || "all"),
287
+ action: url.searchParams.get("action") || "all",
288
+ actor: url.searchParams.get("actor") || undefined,
289
+ agentId: url.searchParams.get("agent") || "all",
290
+ threadId: url.searchParams.get("thread") || undefined,
291
+ workspace: url.searchParams.get("workspace") || undefined,
292
+ since: url.searchParams.get("since") || undefined,
293
+ });
281
294
  sendJson(res, 200, {
282
- events: runtime.audit({
283
- limit: numberParam(url, "limit", 50),
284
- channelId: (url.searchParams.get("channel") || "all"),
285
- category: (url.searchParams.get("category") || "all"),
286
- status: (url.searchParams.get("status") || "all"),
287
- action: url.searchParams.get("action") || "all",
288
- actor: url.searchParams.get("actor") || undefined,
289
- agentId: url.searchParams.get("agent") || "all",
290
- threadId: url.searchParams.get("thread") || undefined,
291
- workspace: url.searchParams.get("workspace") || undefined,
292
- since: url.searchParams.get("since") || undefined,
293
- }),
295
+ events: page.items,
296
+ pagination: page.pagination,
294
297
  });
295
298
  return true;
296
299
  }
@@ -1,9 +1,13 @@
1
- import { readJsonBody, requiredSearch, sendFile, sendJson, stringField, } from "./web-dashboard-http.js";
1
+ import { cursorPage, normalizeCursorLimit } from "../core/pagination.js";
2
+ import { numberParam, readJsonBody, requiredSearch, sendFile, sendJson, stringField, } from "./web-dashboard-http.js";
2
3
  export async function handleDashboardArtifactRoute(req, res, url, options) {
3
4
  const { runtime, authUser } = options;
4
5
  if (req.method === "GET" && url.pathname === "/api/artifacts") {
5
6
  await options.assertCurrentSessionScope(authUser);
6
- sendJson(res, 200, { reports: await runtime.artifacts() });
7
+ const limit = normalizeCursorLimit(numberParam(url, "limit", 50), 50, 200);
8
+ const reports = await runtime.artifacts(500);
9
+ const page = cursorPage(reports, url.searchParams.get("cursor") || undefined, limit, (report) => report.turnId);
10
+ sendJson(res, 200, { reports: page.items, pagination: page.pagination });
7
11
  return true;
8
12
  }
9
13
  if (req.method === "DELETE" && url.pathname === "/api/artifacts") {
@@ -13,6 +13,7 @@ const clientSources = [
13
13
  "client/jobs.js",
14
14
  "client/metrics.js",
15
15
  "client/admin.js",
16
+ "client/users.js",
16
17
  "client/settings-wizard.js",
17
18
  ];
18
19
  const styleSources = [
@@ -137,7 +137,6 @@ ${faviconLinks}
137
137
  <select id="peerSelect" title="NordRelay target"></select>
138
138
  <select id="agentSelect"></select>
139
139
  <button id="themeBtn" class="secondary" title="Toggle dark theme">Dark</button>
140
- <button id="refreshBtn">Refresh</button>
141
140
  <button id="logoutBtn" class="secondary">Logout</button>
142
141
  </div>
143
142
  </header>
@@ -200,6 +199,7 @@ ${faviconLinks}
200
199
  <div class="panel">
201
200
  <div class="row"><button id="reloadTasksBtn">Reload tasks</button></div>
202
201
  <div id="tasksList" class="list"></div>
202
+ <div id="jobsPager" class="pager"></div>
203
203
  </div>
204
204
  </section>
205
205
 
@@ -232,6 +232,14 @@ ${faviconLinks}
232
232
  <div class="panel">
233
233
  <div class="row"><select id="activitySource"><option value="all">All sources</option><option value="web">Web</option><option value="telegram">Telegram</option><option value="discord">Discord</option><option value="slack">Slack</option><option value="cli">CLI</option></select><select id="activityCategory"><option value="all">All categories</option><option value="prompt">Prompt</option><option value="session">Session</option><option value="queue">Queue</option><option value="agent-update">Agent update</option><option value="artifact">Artifact</option><option value="system">System</option><option value="auth">Auth</option><option value="security">Security</option><option value="tool">Tool</option></select><select id="activityStatus"><option value="all">All statuses</option><option value="queued">Queued</option><option value="running">Running</option><option value="completed">Completed</option><option value="failed">Failed</option><option value="aborted">Aborted</option><option value="info">Info</option></select><input id="activityActor" placeholder="Actor"><input id="activityAgent" placeholder="Agent"><input id="activityThread" placeholder="Thread ID"><input id="activityWorkspace" placeholder="Workspace"><input id="activityType" placeholder="Type"><input id="activitySince" type="datetime-local"><input id="activityLimit" type="number" value="100" min="1" max="500"><button id="loadActivityBtn">Load activity</button><button id="exportActivityBtn" class="secondary">Export</button></div>
234
234
  <div id="activityList" class="list"></div>
235
+ <div id="activityPager" class="pager"></div>
236
+ </div>
237
+ </section>
238
+
239
+ <section class="page" id="page-trace">
240
+ <div class="panel">
241
+ <div class="row"><input id="traceCorrelationId" placeholder="Correlation ID"><button id="loadTraceBtn">Load trace</button></div>
242
+ <div id="traceDetail" class="list"></div>
235
243
  </div>
236
244
  </section>
237
245
 
@@ -240,6 +248,7 @@ ${faviconLinks}
240
248
  <div class="row"><button id="reloadArtifactsBtn">Reload artifacts</button><input id="artifactSearch" placeholder="Search artifacts"><select id="artifactKind"><option value="all">All files</option><option value="images">Images</option><option value="docs">Docs/code</option></select><button id="zipSelectedArtifactsBtn" class="secondary">ZIP selected</button><button id="deleteSelectedArtifactsBtn" class="danger">Delete selected</button></div>
241
249
  <div id="artifactPreview" class="preview"></div>
242
250
  <div id="artifactList" class="list"></div>
251
+ <div id="artifactPager" class="pager"></div>
243
252
  </div>
244
253
  </section>
245
254
 
@@ -268,49 +277,71 @@ ${faviconLinks}
268
277
 
269
278
  <section class="page" id="page-access">
270
279
  <div class="panel">
271
- <div class="row"><button id="loadAccessBtn">Reload users</button><button id="createUserBtn">Create user</button><button id="createGroupBtn" class="secondary">Create group</button><button id="createChatBtn" class="secondary">Add Telegram chat</button><button id="createDiscordChannelBtn" class="secondary">Add Discord channel</button><button id="createSlackChannelBtn" class="secondary">Add Slack channel</button><button id="lockSessionBtn" class="secondary">Lock web session</button><button id="unlockSessionBtn" class="secondary">Unlock web session</button></div>
272
- <div id="accessTabs" class="tabs access-tabs">
273
- <button type="button" data-access-tab="users" class="active">Users</button>
274
- <button type="button" data-access-tab="groups">Groups</button>
275
- <button type="button" data-access-tab="telegram">Telegram</button>
276
- <button type="button" data-access-tab="discord">Discord</button>
277
- <button type="button" data-access-tab="slack">Slack</button>
278
- <button type="button" data-access-tab="locks">Locks</button>
279
- <button type="button" data-access-tab="audit">Audit</button>
280
+ <div class="section-header access-section-header">
281
+ <div id="accessTabs" class="section-tabs access-tabs" role="tablist" aria-label="Users sections">
282
+ <button type="button" role="tab" aria-selected="true" tabindex="0" data-access-tab="users" class="active">Users</button>
283
+ <button type="button" role="tab" aria-selected="false" tabindex="-1" data-access-tab="groups">Groups</button>
284
+ <button type="button" role="tab" aria-selected="false" tabindex="-1" data-access-tab="telegram">Telegram</button>
285
+ <button type="button" role="tab" aria-selected="false" tabindex="-1" data-access-tab="discord">Discord</button>
286
+ <button type="button" role="tab" aria-selected="false" tabindex="-1" data-access-tab="slack">Slack</button>
287
+ <button type="button" role="tab" aria-selected="false" tabindex="-1" data-access-tab="locks">Locks</button>
288
+ <button type="button" role="tab" aria-selected="false" tabindex="-1" data-access-tab="audit">Audit</button>
289
+ </div>
280
290
  </div>
281
291
  <div class="access-tab active" data-access-tab-panel="users">
282
- <div id="accessPanel" class="settings-grid"></div>
292
+ <div class="access-tab-heading">
293
+ <div class="row access-heading-actions"><button id="loadAccessBtn" class="secondary">Reload</button><button id="createUserBtn">Create user</button></div>
294
+ <div class="access-filter-row">
295
+ <input id="userSearch" placeholder="Search users">
296
+ <select id="userStatusFilter"><option value="all">All statuses</option><option value="active">Active</option><option value="disabled">Disabled</option></select>
297
+ <select id="userGroupFilter"><option value="all">All groups</option></select>
298
+ <select id="userIdentityFilter"><option value="all">All identities</option><option value="telegram">Telegram linked</option><option value="discord">Discord linked</option><option value="slack">Slack linked</option><option value="web">Web sessions</option><option value="unlinked">No chat identity</option></select>
299
+ </div>
300
+ </div>
301
+ <div id="accessPanel" class="list user-list"></div>
302
+ <div id="usersPager" class="pager"></div>
283
303
  </div>
284
304
  <div class="access-tab" data-access-tab-panel="groups">
285
- <h2>Groups</h2>
305
+ <div class="access-tab-heading">
306
+ <div class="row access-heading-actions"><button id="createGroupBtn" class="secondary">Create group</button></div>
307
+ <div class="access-filter-row"><input id="groupSearch" placeholder="Search groups"></div>
308
+ </div>
286
309
  <div id="groupsList" class="list"></div>
287
310
  </div>
288
311
  <div class="access-tab" data-access-tab-panel="telegram">
289
- <h2>Telegram chats</h2>
312
+ <div class="access-tab-heading">
313
+ <div class="row access-heading-actions"><button id="createChatBtn" class="secondary">Add Telegram chat</button></div>
314
+ <input id="telegramChatSearch" placeholder="Search Telegram chats">
315
+ </div>
290
316
  <div id="telegramChatsList" class="list"></div>
291
317
  </div>
292
318
  <div class="access-tab" data-access-tab-panel="discord">
293
319
  <div class="access-tab-heading">
294
- <h2>Discord channels</h2>
320
+ <div class="row access-heading-actions"><button id="createDiscordChannelBtn" class="secondary">Add Discord channel</button></div>
295
321
  <input id="discordChannelSearch" placeholder="Search Discord channels">
296
322
  </div>
297
323
  <div id="discordChannelsList" class="list"></div>
298
324
  </div>
299
325
  <div class="access-tab" data-access-tab-panel="slack">
300
326
  <div class="access-tab-heading">
301
- <h2>Slack channels</h2>
327
+ <div class="row access-heading-actions"><button id="createSlackChannelBtn" class="secondary">Add Slack channel</button></div>
302
328
  <input id="slackChannelSearch" placeholder="Search Slack channels">
303
329
  </div>
304
330
  <div id="slackChannelsList" class="list"></div>
305
331
  </div>
306
332
  <div class="access-tab" data-access-tab-panel="locks">
307
- <h2>Locks</h2>
333
+ <div class="access-tab-heading">
334
+ <div class="row access-heading-actions"><button id="lockSessionBtn" class="secondary">Lock web session</button><button id="unlockSessionBtn" class="secondary">Unlock web session</button></div>
335
+ </div>
308
336
  <div id="locksList" class="list"></div>
309
337
  </div>
310
338
  <div class="access-tab" data-access-tab-panel="audit">
311
- <h2>Audit</h2>
312
- <div class="row"><select id="auditChannel"><option value="all">All channels</option><option value="web">Web</option><option value="telegram">Telegram</option><option value="discord">Discord</option><option value="slack">Slack</option></select><select id="auditCategory"><option value="all">All categories</option><option value="prompt">Prompt</option><option value="session">Session</option><option value="queue">Queue</option><option value="agent-update">Agent update</option><option value="artifact">Artifact</option><option value="system">System</option><option value="auth">Auth</option><option value="security">Security</option><option value="tool">Tool</option></select><select id="auditStatus"><option value="all">All statuses</option><option value="ok">OK</option><option value="failed">Failed</option><option value="denied">Denied</option></select><input id="auditActor" placeholder="Actor"><input id="auditAgent" placeholder="Agent"><input id="auditThread" placeholder="Thread ID"><input id="auditWorkspace" placeholder="Workspace"><input id="auditSince" type="datetime-local"><input id="auditLimit" type="number" value="50" min="1" max="500"><button id="loadAuditBtn">Load audit</button><button id="exportAuditBtn" class="secondary">Export</button></div>
339
+ <div class="access-tab-heading">
340
+ <div class="row access-heading-actions"><button id="loadAuditBtn">Load audit</button><button id="exportAuditBtn" class="secondary">Export</button></div>
341
+ </div>
342
+ <div class="row audit-filter-row"><select id="auditChannel"><option value="all">All channels</option><option value="web">Web</option><option value="telegram">Telegram</option><option value="discord">Discord</option><option value="slack">Slack</option></select><select id="auditCategory"><option value="all">All categories</option><option value="prompt">Prompt</option><option value="session">Session</option><option value="queue">Queue</option><option value="agent-update">Agent update</option><option value="artifact">Artifact</option><option value="system">System</option><option value="auth">Auth</option><option value="security">Security</option><option value="tool">Tool</option></select><select id="auditStatus"><option value="all">All statuses</option><option value="ok">OK</option><option value="failed">Failed</option><option value="denied">Denied</option></select><input id="auditActor" placeholder="Actor"><input id="auditAgent" placeholder="Agent"><input id="auditThread" placeholder="Thread ID"><input id="auditWorkspace" placeholder="Workspace"><input id="auditSince" type="datetime-local"><input id="auditLimit" type="number" value="50" min="1" max="500"></div>
313
343
  <div id="auditList" class="list"></div>
344
+ <div id="auditPager" class="pager"></div>
314
345
  </div>
315
346
  </div>
316
347
  </section>
@@ -326,8 +357,11 @@ ${faviconLinks}
326
357
 
327
358
  <section class="page" id="page-settings">
328
359
  <div class="panel">
329
- <div class="row"><button id="saveSettingsBtn">Save settings</button><button id="settingsWizardBtn" class="secondary">Setup wizard</button><button id="restartBtn" class="secondary">Restart NordRelay</button><span id="settingsStatus"></span></div>
330
- <div id="settingsTabs" class="tabs"></div>
360
+ <div id="settingsTabHeader" class="section-header settings-section-header">
361
+ <div id="settingsTabs" class="section-tabs settings-tabs" role="tablist" aria-label="Settings sections"></div>
362
+ </div>
363
+ <div id="settingsSubnav" class="settings-subnav" hidden></div>
364
+ <div id="settingsActions" class="row settings-actions"><button id="saveSettingsBtn">Save settings</button><button id="settingsWizardBtn" class="secondary">Setup wizard</button><button id="restartBtn" class="secondary">Restart NordRelay</button><span id="settingsStatus"></span></div>
331
365
  <div id="settingsForm" class="settings-grid"></div>
332
366
  </div>
333
367
  </section>
@@ -372,6 +406,10 @@ ${faviconLinks}
372
406
  <div id="sessionDetail"></div>
373
407
  <div class="row dialog-actions"><button id="closeSessionDetailBtn" class="secondary">Close</button></div>
374
408
  </dialog>
409
+ <dialog id="userDetailDialog">
410
+ <div id="userDetail"></div>
411
+ <div class="row dialog-actions"><button id="closeUserDetailBtn" class="secondary">Close</button></div>
412
+ </dialog>
375
413
  <dialog id="adminDialog">
376
414
  <form method="dialog" id="adminDialogForm">
377
415
  <h2 id="adminDialogTitle">Edit</h2>