@nordbyte/nordrelay 0.2.1 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +22 -0
- package/CHANGELOG.md +26 -0
- package/README.md +147 -19
- package/dist/access-control.js +6 -0
- package/dist/agent-adapter.js +60 -0
- package/dist/audit-log.js +54 -0
- package/dist/bot-preferences.js +13 -9
- package/dist/bot-ui.js +6 -0
- package/dist/bot.js +526 -26
- package/dist/channel-adapter.js +58 -0
- package/dist/codex-session.js +3 -1
- package/dist/config.js +47 -0
- package/dist/context-key.js +23 -0
- package/dist/index.js +47 -2
- package/dist/logger.js +24 -1
- package/dist/operations.js +340 -15
- package/dist/prompt-store.js +33 -11
- package/dist/relay-runtime.js +908 -0
- package/dist/session-locks.js +81 -0
- package/dist/session-registry.js +11 -7
- package/dist/settings-service.js +253 -0
- package/dist/state-backend.js +83 -0
- package/dist/web-dashboard.js +890 -0
- package/dist/web-state.js +131 -0
- package/docker-compose.yml +1 -1
- package/package.json +4 -1
- package/plugins/nordrelay/.codex-plugin/plugin.json +1 -1
- package/plugins/nordrelay/scripts/nordrelay.mjs +235 -13
|
@@ -0,0 +1,908 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { createArtifactZipBundle, getArtifactTurnReport, ensureOutDir, listRecentArtifactReports, removeArtifactTurn, totalArtifactSize, } from "./artifacts.js";
|
|
5
|
+
import { buildFileInstructions, outboxPath, stageFile, } from "./attachments.js";
|
|
6
|
+
import { CODEX_AGENT_CAPABILITIES, CODEX_REASONING_EFFORTS, PI_THINKING_LEVELS, agentLabel, agentReasoningLabel, } from "./agent.js";
|
|
7
|
+
import { enabledAgents } from "./agent-factory.js";
|
|
8
|
+
import { checkAuthStatus } from "./codex-auth.js";
|
|
9
|
+
import { getThreadRolloutSnapshot, } from "./codex-state.js";
|
|
10
|
+
import { friendlyErrorText } from "./error-messages.js";
|
|
11
|
+
import { getConnectorHealth, getVersionChecks, readFormattedLogTail, spawnConnectorRestart } from "./operations.js";
|
|
12
|
+
import { PromptStore, toPromptEnvelope } from "./prompt-store.js";
|
|
13
|
+
import { renderSessionInfoPlain } from "./session-format.js";
|
|
14
|
+
import { SessionRegistry } from "./session-registry.js";
|
|
15
|
+
import { transcribeAudio } from "./voice.js";
|
|
16
|
+
import { WebActivityStore, WebChatStore, } from "./web-state.js";
|
|
17
|
+
import { evaluateWorkspacePolicy, filterAllowedWorkspaces } from "./workspace-policy.js";
|
|
18
|
+
const WEB_CONTEXT_KEY = "web:dashboard";
|
|
19
|
+
const MAX_WEB_SESSION_PAGE_SIZE = 50;
|
|
20
|
+
const MAX_CHAT_HISTORY = 250;
|
|
21
|
+
const MAX_TEXT_PREVIEW_BYTES = 256 * 1024;
|
|
22
|
+
export class RelayRuntime {
|
|
23
|
+
config;
|
|
24
|
+
registry;
|
|
25
|
+
promptStore;
|
|
26
|
+
chatStore;
|
|
27
|
+
activityStore;
|
|
28
|
+
subscribers = new Set();
|
|
29
|
+
externalMonitor;
|
|
30
|
+
draining = false;
|
|
31
|
+
currentTurnId = null;
|
|
32
|
+
accumulatedText = "";
|
|
33
|
+
currentTurnStartedAt = 0;
|
|
34
|
+
externalMirror = null;
|
|
35
|
+
constructor(config) {
|
|
36
|
+
this.config = config;
|
|
37
|
+
this.registry = new SessionRegistry(config, {
|
|
38
|
+
fileName: "web-contexts.json",
|
|
39
|
+
sqliteKey: "web-contexts",
|
|
40
|
+
});
|
|
41
|
+
this.promptStore = new PromptStore(config.workspace, config.stateBackend);
|
|
42
|
+
this.chatStore = new WebChatStore(config.workspace, config.stateBackend, MAX_CHAT_HISTORY);
|
|
43
|
+
this.activityStore = new WebActivityStore(config.workspace, config.stateBackend, config.auditMaxEvents);
|
|
44
|
+
if (config.codexExternalBusyCheckMs > 0) {
|
|
45
|
+
this.externalMonitor = setInterval(() => {
|
|
46
|
+
void this.monitorExternalActivity().catch((error) => this.broadcastStatus(friendlyErrorText(error), "error"));
|
|
47
|
+
}, config.codexExternalBusyCheckMs);
|
|
48
|
+
this.externalMonitor.unref?.();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
subscribe(callback) {
|
|
52
|
+
this.subscribers.add(callback);
|
|
53
|
+
void this.snapshot().then((data) => callback({ type: "snapshot", data })).catch(() => { });
|
|
54
|
+
void this.chatHistory().then((messages) => callback({ type: "chat_history", messages })).catch(() => { });
|
|
55
|
+
callback({ type: "activity_update", events: this.activity({ limit: 50 }) });
|
|
56
|
+
return () => this.subscribers.delete(callback);
|
|
57
|
+
}
|
|
58
|
+
async snapshot() {
|
|
59
|
+
const session = await this.getSession(true);
|
|
60
|
+
const info = this.publicInfo(session);
|
|
61
|
+
return {
|
|
62
|
+
session: info,
|
|
63
|
+
sessionText: renderSessionInfoPlain(info),
|
|
64
|
+
queue: this.queue(),
|
|
65
|
+
queuePaused: this.queuePaused(),
|
|
66
|
+
processing: session.isProcessing(),
|
|
67
|
+
enabledAgents: enabledAgents(this.config),
|
|
68
|
+
workspaces: filterAllowedWorkspaces(session.listWorkspaces(), this.config),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
async status() {
|
|
72
|
+
return {
|
|
73
|
+
health: await getConnectorHealth(),
|
|
74
|
+
versionChecks: await getVersionChecks({ piCliPath: this.config.piCliPath }),
|
|
75
|
+
snapshot: await this.snapshot(),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
async diagnostics() {
|
|
79
|
+
return {
|
|
80
|
+
health: await getConnectorHealth(),
|
|
81
|
+
versionChecks: await getVersionChecks({ piCliPath: this.config.piCliPath }),
|
|
82
|
+
snapshot: await this.snapshot(),
|
|
83
|
+
runtime: {
|
|
84
|
+
stateBackend: this.config.stateBackend,
|
|
85
|
+
sourceWorkspace: this.config.workspace,
|
|
86
|
+
queuePaused: this.promptStore.isPaused(WEB_CONTEXT_KEY),
|
|
87
|
+
externalMirror: this.externalMirror ? { ...this.externalMirror } : null,
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
async controlOptions() {
|
|
92
|
+
const session = await this.getSession(true);
|
|
93
|
+
const info = this.publicInfo(session);
|
|
94
|
+
const capabilities = info.capabilities ?? CODEX_AGENT_CAPABILITIES;
|
|
95
|
+
return {
|
|
96
|
+
models: capabilities.modelSelection ? session.listModels() : [],
|
|
97
|
+
reasoningLabel: agentReasoningLabel(info.agentId),
|
|
98
|
+
reasoningOptions: info.agentId === "pi" ? PI_THINKING_LEVELS : CODEX_REASONING_EFFORTS,
|
|
99
|
+
launchProfiles: capabilities.launchProfiles
|
|
100
|
+
? this.config.launchProfiles.map((profile) => ({
|
|
101
|
+
id: profile.id,
|
|
102
|
+
label: profile.label,
|
|
103
|
+
behavior: `${profile.sandboxMode} / ${profile.approvalPolicy}`,
|
|
104
|
+
unsafe: profile.unsafe,
|
|
105
|
+
}))
|
|
106
|
+
: [],
|
|
107
|
+
workspaces: filterAllowedWorkspaces(session.listWorkspaces(), this.config),
|
|
108
|
+
capabilities,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
async chatHistory(limit = 200) {
|
|
112
|
+
const session = await this.getSession(true);
|
|
113
|
+
return this.chatStore.list(this.publicInfo(session).threadId, limit);
|
|
114
|
+
}
|
|
115
|
+
async clearChatHistory() {
|
|
116
|
+
const session = await this.getSession(true);
|
|
117
|
+
const removed = this.chatStore.clear(this.publicInfo(session).threadId);
|
|
118
|
+
const messages = await this.chatHistory();
|
|
119
|
+
this.broadcast({ type: "chat_history", messages });
|
|
120
|
+
return { removed, messages };
|
|
121
|
+
}
|
|
122
|
+
activity(options = {}) {
|
|
123
|
+
return this.activityStore.list(options);
|
|
124
|
+
}
|
|
125
|
+
async listSessions(limit = 80, query = "") {
|
|
126
|
+
return this.filteredSessions(await this.getSession(true), query, Math.max(1, limit * 3)).slice(0, limit);
|
|
127
|
+
}
|
|
128
|
+
async listSessionsPage(page = 1, pageSize = MAX_WEB_SESSION_PAGE_SIZE, query = "") {
|
|
129
|
+
const session = await this.getSession(true);
|
|
130
|
+
const effectivePage = Math.max(1, Math.floor(page));
|
|
131
|
+
const effectivePageSize = Math.min(MAX_WEB_SESSION_PAGE_SIZE, Math.max(1, Math.floor(pageSize)));
|
|
132
|
+
const offset = (effectivePage - 1) * effectivePageSize;
|
|
133
|
+
const requested = Math.min(5_000, Math.max(100, (offset + effectivePageSize + 1) * 3));
|
|
134
|
+
const records = this.filteredSessions(session, query, requested);
|
|
135
|
+
return {
|
|
136
|
+
sessions: records.slice(offset, offset + effectivePageSize),
|
|
137
|
+
pagination: {
|
|
138
|
+
page: effectivePage,
|
|
139
|
+
pageSize: effectivePageSize,
|
|
140
|
+
hasPrevious: effectivePage > 1,
|
|
141
|
+
hasNext: records.length > offset + effectivePageSize,
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
filteredSessions(session, query, limit) {
|
|
146
|
+
const normalized = query.trim().toLowerCase();
|
|
147
|
+
return session.listAllSessions(limit)
|
|
148
|
+
.filter((record) => evaluateWorkspacePolicy(record.cwd, this.config).allowed)
|
|
149
|
+
.filter((record) => {
|
|
150
|
+
if (!normalized) {
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
return [
|
|
154
|
+
record.id,
|
|
155
|
+
record.title,
|
|
156
|
+
record.cwd,
|
|
157
|
+
record.model,
|
|
158
|
+
record.reasoningEffort,
|
|
159
|
+
record.firstUserMessage,
|
|
160
|
+
].some((value) => value?.toLowerCase().includes(normalized));
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
async listModels() {
|
|
164
|
+
return (await this.getSession(true)).listModels();
|
|
165
|
+
}
|
|
166
|
+
async setAgent(agentId) {
|
|
167
|
+
if (!enabledAgents(this.config).includes(agentId)) {
|
|
168
|
+
throw new Error(`Agent is not enabled: ${agentId}`);
|
|
169
|
+
}
|
|
170
|
+
const session = await this.registry.switchAgent(WEB_CONTEXT_KEY, agentId);
|
|
171
|
+
this.updateSession(session);
|
|
172
|
+
return this.publicInfo(session);
|
|
173
|
+
}
|
|
174
|
+
async newSession(options = {}) {
|
|
175
|
+
const session = options.agentId ? await this.registry.switchAgent(WEB_CONTEXT_KEY, options.agentId) : await this.getSession(true);
|
|
176
|
+
this.ensureIdle(session);
|
|
177
|
+
if (options.reasoningEffort) {
|
|
178
|
+
const reasoningOptions = session.getInfo().agentId === "pi" ? PI_THINKING_LEVELS : CODEX_REASONING_EFFORTS;
|
|
179
|
+
if (!reasoningOptions.includes(options.reasoningEffort)) {
|
|
180
|
+
throw new Error(`Invalid ${agentReasoningLabel(session.getInfo().agentId)} value: ${options.reasoningEffort}`);
|
|
181
|
+
}
|
|
182
|
+
session.setReasoningEffort(options.reasoningEffort);
|
|
183
|
+
}
|
|
184
|
+
if (options.launchProfileId && (session.getInfo().capabilities ?? CODEX_AGENT_CAPABILITIES).launchProfiles) {
|
|
185
|
+
session.setLaunchProfile(options.launchProfileId);
|
|
186
|
+
}
|
|
187
|
+
if (typeof options.fastMode === "boolean" && (session.getInfo().capabilities ?? CODEX_AGENT_CAPABILITIES).fastMode) {
|
|
188
|
+
session.setFastMode(options.fastMode);
|
|
189
|
+
}
|
|
190
|
+
const info = await session.newThread(options.workspace, options.model);
|
|
191
|
+
this.updateSession(session);
|
|
192
|
+
this.appendActivity({
|
|
193
|
+
source: "web",
|
|
194
|
+
status: "info",
|
|
195
|
+
type: "session_new",
|
|
196
|
+
threadId: info.threadId,
|
|
197
|
+
workspace: info.workspace,
|
|
198
|
+
agentId: info.agentId,
|
|
199
|
+
detail: "New dashboard session created.",
|
|
200
|
+
});
|
|
201
|
+
return this.publicInfo(session);
|
|
202
|
+
}
|
|
203
|
+
async switchSession(threadId) {
|
|
204
|
+
const session = await this.getSession(true);
|
|
205
|
+
this.ensureIdle(session);
|
|
206
|
+
const info = await session.switchSession(threadId);
|
|
207
|
+
this.updateSession(session);
|
|
208
|
+
this.broadcast({ type: "chat_history", messages: await this.chatHistory() });
|
|
209
|
+
this.appendActivity({
|
|
210
|
+
source: "web",
|
|
211
|
+
status: "info",
|
|
212
|
+
type: "session_switch",
|
|
213
|
+
threadId: info.threadId,
|
|
214
|
+
workspace: info.workspace,
|
|
215
|
+
agentId: info.agentId,
|
|
216
|
+
detail: "Dashboard switched session.",
|
|
217
|
+
});
|
|
218
|
+
return this.publicInfo(session);
|
|
219
|
+
}
|
|
220
|
+
async attachSession(threadId) {
|
|
221
|
+
return this.switchSession(threadId);
|
|
222
|
+
}
|
|
223
|
+
async setModel(model) {
|
|
224
|
+
const session = await this.getSession(true);
|
|
225
|
+
this.ensureIdle(session);
|
|
226
|
+
await session.setModelForCurrentSession(model);
|
|
227
|
+
this.updateSession(session);
|
|
228
|
+
return this.publicInfo(session);
|
|
229
|
+
}
|
|
230
|
+
async setReasoningEffort(effort) {
|
|
231
|
+
const session = await this.getSession(true);
|
|
232
|
+
this.ensureIdle(session);
|
|
233
|
+
const options = session.getInfo().agentId === "pi" ? PI_THINKING_LEVELS : CODEX_REASONING_EFFORTS;
|
|
234
|
+
if (!options.includes(effort)) {
|
|
235
|
+
throw new Error(`Invalid ${agentReasoningLabel(session.getInfo().agentId)} value: ${effort}`);
|
|
236
|
+
}
|
|
237
|
+
await session.setReasoningEffortForCurrentSession(effort);
|
|
238
|
+
this.updateSession(session);
|
|
239
|
+
return this.publicInfo(session);
|
|
240
|
+
}
|
|
241
|
+
async setFastMode(enabled) {
|
|
242
|
+
const session = await this.getSession(true);
|
|
243
|
+
this.ensureIdle(session);
|
|
244
|
+
if (!(session.getInfo().capabilities ?? CODEX_AGENT_CAPABILITIES).fastMode) {
|
|
245
|
+
throw new Error(`Fast mode is not supported for ${agentLabel(session.getInfo().agentId)}.`);
|
|
246
|
+
}
|
|
247
|
+
session.setFastMode(enabled);
|
|
248
|
+
this.updateSession(session);
|
|
249
|
+
return this.publicInfo(session);
|
|
250
|
+
}
|
|
251
|
+
async setLaunchProfile(profileId) {
|
|
252
|
+
const session = await this.getSession(true);
|
|
253
|
+
this.ensureIdle(session);
|
|
254
|
+
session.setLaunchProfile(profileId);
|
|
255
|
+
this.updateSession(session);
|
|
256
|
+
return this.publicInfo(session);
|
|
257
|
+
}
|
|
258
|
+
async handback() {
|
|
259
|
+
const session = await this.getSession(true);
|
|
260
|
+
this.ensureIdle(session);
|
|
261
|
+
const result = session.handback();
|
|
262
|
+
this.updateSession(session);
|
|
263
|
+
return result;
|
|
264
|
+
}
|
|
265
|
+
async abort() {
|
|
266
|
+
const session = await this.getSession(true);
|
|
267
|
+
await session.abort();
|
|
268
|
+
this.broadcast({ type: "status", level: "warn", message: "Current operation aborted.", at: new Date().toISOString() });
|
|
269
|
+
}
|
|
270
|
+
async sendPrompt(text) {
|
|
271
|
+
const trimmed = text.trim();
|
|
272
|
+
if (!trimmed) {
|
|
273
|
+
throw new Error("Prompt is empty.");
|
|
274
|
+
}
|
|
275
|
+
return this.sendEnvelope(toPromptEnvelope(trimmed));
|
|
276
|
+
}
|
|
277
|
+
async sendUploadPrompt(options) {
|
|
278
|
+
const text = options.text?.trim() ?? "";
|
|
279
|
+
const files = options.files.filter((file) => file.data.byteLength > 0);
|
|
280
|
+
if (!text && files.length === 0) {
|
|
281
|
+
throw new Error("Prompt is empty.");
|
|
282
|
+
}
|
|
283
|
+
const session = await this.getSession(false);
|
|
284
|
+
const workspace = session.getInfo().workspace;
|
|
285
|
+
const turnId = randomUUID().slice(0, 12);
|
|
286
|
+
const outDir = outboxPath(workspace, turnId);
|
|
287
|
+
await ensureOutDir(outDir);
|
|
288
|
+
const stagedFiles = [];
|
|
289
|
+
const imagePaths = [];
|
|
290
|
+
const transcriptParts = [];
|
|
291
|
+
for (const [index, file] of files.entries()) {
|
|
292
|
+
const mimeType = normalizeMimeType(file.mimeType, file.name);
|
|
293
|
+
const staged = await stageFile(file.data, file.name || `upload-${index + 1}`, mimeType, {
|
|
294
|
+
workspace,
|
|
295
|
+
turnId,
|
|
296
|
+
maxFileSize: this.config.maxFileSize,
|
|
297
|
+
});
|
|
298
|
+
stagedFiles.push(staged);
|
|
299
|
+
if (mimeType.startsWith("image/")) {
|
|
300
|
+
imagePaths.push(staged.localPath);
|
|
301
|
+
}
|
|
302
|
+
if (mimeType.startsWith("audio/")) {
|
|
303
|
+
const result = await transcribeAudio(staged.localPath, {
|
|
304
|
+
preferredBackend: this.config.voicePreferredBackend === "auto"
|
|
305
|
+
? undefined
|
|
306
|
+
: this.config.voicePreferredBackend,
|
|
307
|
+
language: this.config.voiceDefaultLanguage,
|
|
308
|
+
});
|
|
309
|
+
const transcript = result.text.trim();
|
|
310
|
+
if (transcript) {
|
|
311
|
+
transcriptParts.push(`Audio transcript (${staged.safeName}, via ${result.backend}):\n${transcript}`);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
const audioOnly = stagedFiles.length > 0 && stagedFiles.every((file) => file.mimeType.startsWith("audio/"));
|
|
316
|
+
if (this.config.voiceTranscribeOnly && audioOnly && !text) {
|
|
317
|
+
return {
|
|
318
|
+
queued: false,
|
|
319
|
+
transcript: transcriptParts.join("\n\n"),
|
|
320
|
+
transcribeOnly: true,
|
|
321
|
+
files: uploadFileDtos(stagedFiles),
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
const promptInput = {};
|
|
325
|
+
const textParts = [text, ...transcriptParts].filter(Boolean);
|
|
326
|
+
if (textParts.length > 0) {
|
|
327
|
+
promptInput.text = textParts.join("\n\n");
|
|
328
|
+
}
|
|
329
|
+
if (imagePaths.length > 0) {
|
|
330
|
+
promptInput.imagePaths = imagePaths;
|
|
331
|
+
}
|
|
332
|
+
if (stagedFiles.length > 0) {
|
|
333
|
+
promptInput.stagedFileInstructions = buildFileInstructions(stagedFiles, outDir);
|
|
334
|
+
}
|
|
335
|
+
const result = await this.sendEnvelope(toPromptEnvelope(promptInput, outDir));
|
|
336
|
+
return {
|
|
337
|
+
...result,
|
|
338
|
+
transcript: transcriptParts.join("\n\n") || undefined,
|
|
339
|
+
files: uploadFileDtos(stagedFiles),
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
async sendEnvelope(envelope) {
|
|
343
|
+
const session = await this.getSession(false);
|
|
344
|
+
if (session.isProcessing()) {
|
|
345
|
+
const queued = this.promptStore.enqueue(WEB_CONTEXT_KEY, envelope);
|
|
346
|
+
const info = this.publicInfo(session);
|
|
347
|
+
this.appendActivity({
|
|
348
|
+
source: "web",
|
|
349
|
+
status: "queued",
|
|
350
|
+
type: "prompt_queued",
|
|
351
|
+
threadId: info.threadId,
|
|
352
|
+
workspace: info.workspace,
|
|
353
|
+
agentId: info.agentId,
|
|
354
|
+
prompt: envelope.description,
|
|
355
|
+
detail: `Queued at position ${this.promptStore.list(WEB_CONTEXT_KEY).length}.`,
|
|
356
|
+
});
|
|
357
|
+
this.broadcastQueue();
|
|
358
|
+
return { queued: true, queueId: queued.id };
|
|
359
|
+
}
|
|
360
|
+
void this.runPrompt(session, envelope).catch((error) => {
|
|
361
|
+
this.broadcast({ type: "turn_error", id: this.currentTurnId ?? "turn", error: friendlyErrorText(error), at: new Date().toISOString() });
|
|
362
|
+
});
|
|
363
|
+
return { queued: false };
|
|
364
|
+
}
|
|
365
|
+
queue() {
|
|
366
|
+
return this.promptStore.list(WEB_CONTEXT_KEY).map(queueItemDto);
|
|
367
|
+
}
|
|
368
|
+
queuePaused() {
|
|
369
|
+
return this.promptStore.isPaused(WEB_CONTEXT_KEY);
|
|
370
|
+
}
|
|
371
|
+
queueAction(action, id) {
|
|
372
|
+
if (action === "pause")
|
|
373
|
+
this.promptStore.pause(WEB_CONTEXT_KEY);
|
|
374
|
+
if (action === "resume")
|
|
375
|
+
this.promptStore.resume(WEB_CONTEXT_KEY);
|
|
376
|
+
if (action === "clear")
|
|
377
|
+
this.promptStore.clear(WEB_CONTEXT_KEY);
|
|
378
|
+
if (id && action === "cancel")
|
|
379
|
+
this.promptStore.remove(WEB_CONTEXT_KEY, id);
|
|
380
|
+
if (id && action === "top")
|
|
381
|
+
this.promptStore.moveToTop(WEB_CONTEXT_KEY, id);
|
|
382
|
+
if (id && action === "up")
|
|
383
|
+
this.promptStore.moveUp(WEB_CONTEXT_KEY, id);
|
|
384
|
+
if (id && action === "down")
|
|
385
|
+
this.promptStore.moveDown(WEB_CONTEXT_KEY, id);
|
|
386
|
+
if (id && action === "run") {
|
|
387
|
+
const item = this.promptStore.remove(WEB_CONTEXT_KEY, id);
|
|
388
|
+
if (item)
|
|
389
|
+
this.promptStore.enqueueFront(WEB_CONTEXT_KEY, item);
|
|
390
|
+
void this.drainQueue().catch((error) => this.broadcastStatus(friendlyErrorText(error), "error"));
|
|
391
|
+
}
|
|
392
|
+
this.appendActivity({
|
|
393
|
+
source: "web",
|
|
394
|
+
status: "info",
|
|
395
|
+
type: "queue_updated",
|
|
396
|
+
threadId: null,
|
|
397
|
+
workspace: this.config.workspace,
|
|
398
|
+
detail: id ? `${action}: ${id}` : action,
|
|
399
|
+
});
|
|
400
|
+
this.broadcastQueue();
|
|
401
|
+
return this.queue();
|
|
402
|
+
}
|
|
403
|
+
async artifacts() {
|
|
404
|
+
const session = await this.getSession(true);
|
|
405
|
+
return (await listRecentArtifactReports(session.getInfo().workspace, 20, this.config.maxFileSize)).map(artifactDto);
|
|
406
|
+
}
|
|
407
|
+
async artifact(turnId) {
|
|
408
|
+
const session = await this.getSession(true);
|
|
409
|
+
return getArtifactTurnReport(session.getInfo().workspace, turnId, this.config.maxFileSize);
|
|
410
|
+
}
|
|
411
|
+
async deleteArtifact(turnId) {
|
|
412
|
+
const session = await this.getSession(true);
|
|
413
|
+
return removeArtifactTurn(session.getInfo().workspace, turnId);
|
|
414
|
+
}
|
|
415
|
+
async createArtifactZip(turnId) {
|
|
416
|
+
const report = await this.artifact(turnId);
|
|
417
|
+
if (!report) {
|
|
418
|
+
return null;
|
|
419
|
+
}
|
|
420
|
+
const bundle = await createArtifactZipBundle(report.artifacts, report.outDir, {
|
|
421
|
+
maxFileSize: this.config.maxFileSize,
|
|
422
|
+
bundleName: `nordrelay-artifacts-${turnId}.zip`,
|
|
423
|
+
});
|
|
424
|
+
return bundle ? { path: bundle.localPath, name: bundle.name } : null;
|
|
425
|
+
}
|
|
426
|
+
async artifactPreview(turnId, relativePath) {
|
|
427
|
+
const report = await this.artifact(turnId);
|
|
428
|
+
const artifact = report?.artifacts.find((candidate) => candidate.relativePath.split(path.sep).join("/") === relativePath);
|
|
429
|
+
if (!artifact) {
|
|
430
|
+
return null;
|
|
431
|
+
}
|
|
432
|
+
const extension = path.extname(artifact.name).toLowerCase();
|
|
433
|
+
if ([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"].includes(extension)) {
|
|
434
|
+
return {
|
|
435
|
+
kind: "image",
|
|
436
|
+
name: artifact.name,
|
|
437
|
+
sizeBytes: artifact.sizeBytes,
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
if (!isPreviewableTextFile(extension, artifact.sizeBytes)) {
|
|
441
|
+
return {
|
|
442
|
+
kind: "unsupported",
|
|
443
|
+
name: artifact.name,
|
|
444
|
+
sizeBytes: artifact.sizeBytes,
|
|
445
|
+
detail: artifact.sizeBytes > MAX_TEXT_PREVIEW_BYTES ? "File is too large for inline preview." : "File type is not previewable.",
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
const buffer = await readFile(artifact.localPath);
|
|
449
|
+
const truncated = buffer.byteLength > MAX_TEXT_PREVIEW_BYTES;
|
|
450
|
+
return {
|
|
451
|
+
kind: "text",
|
|
452
|
+
name: artifact.name,
|
|
453
|
+
sizeBytes: artifact.sizeBytes,
|
|
454
|
+
truncated,
|
|
455
|
+
text: buffer.subarray(0, MAX_TEXT_PREVIEW_BYTES).toString("utf8"),
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
async logs(target = "connector", lines = 100) {
|
|
459
|
+
if (target === "update") {
|
|
460
|
+
const { getUpdateLogPath } = await import("./operations.js");
|
|
461
|
+
return readFormattedLogTail(lines, getUpdateLogPath());
|
|
462
|
+
}
|
|
463
|
+
return readFormattedLogTail(lines);
|
|
464
|
+
}
|
|
465
|
+
restartConnector() {
|
|
466
|
+
spawnConnectorRestart();
|
|
467
|
+
this.broadcastStatus("Restart requested. The dashboard may disconnect briefly.", "warn");
|
|
468
|
+
this.appendActivity({
|
|
469
|
+
source: "web",
|
|
470
|
+
status: "info",
|
|
471
|
+
type: "restart_requested",
|
|
472
|
+
threadId: null,
|
|
473
|
+
workspace: this.config.workspace,
|
|
474
|
+
detail: "Dashboard requested a connector restart.",
|
|
475
|
+
});
|
|
476
|
+
return { ok: true, message: "Restart requested." };
|
|
477
|
+
}
|
|
478
|
+
dispose() {
|
|
479
|
+
if (this.externalMonitor) {
|
|
480
|
+
clearInterval(this.externalMonitor);
|
|
481
|
+
}
|
|
482
|
+
this.registry.disposeAll();
|
|
483
|
+
this.subscribers.clear();
|
|
484
|
+
}
|
|
485
|
+
async monitorExternalActivity() {
|
|
486
|
+
const session = await this.getSession(true);
|
|
487
|
+
const info = this.publicInfo(session);
|
|
488
|
+
if (!info.capabilities.externalActivity || info.agentId !== "codex" || !info.threadId || session.isProcessing()) {
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
const snapshot = getThreadRolloutSnapshot(info.threadId, {
|
|
492
|
+
afterLine: this.externalMirror?.threadId === info.threadId ? this.externalMirror.lastLine : Number.MAX_SAFE_INTEGER,
|
|
493
|
+
staleAfterMs: this.config.codexExternalBusyStaleMs,
|
|
494
|
+
}) ?? getThreadRolloutSnapshot(info.threadId, {
|
|
495
|
+
staleAfterMs: this.config.codexExternalBusyStaleMs,
|
|
496
|
+
maxEvents: 0,
|
|
497
|
+
});
|
|
498
|
+
if (!snapshot) {
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
if (!this.externalMirror || this.externalMirror.threadId !== snapshot.threadId || this.externalMirror.rolloutPath !== snapshot.rolloutPath) {
|
|
502
|
+
this.externalMirror = {
|
|
503
|
+
threadId: snapshot.threadId,
|
|
504
|
+
rolloutPath: snapshot.rolloutPath,
|
|
505
|
+
lastLine: snapshot.lineCount,
|
|
506
|
+
turnId: snapshot.activity.turnId,
|
|
507
|
+
startedAt: snapshot.activity.startedAt?.toISOString() ?? null,
|
|
508
|
+
};
|
|
509
|
+
if (snapshot.activity.active) {
|
|
510
|
+
this.startExternalTurn(snapshot);
|
|
511
|
+
}
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
const mirror = this.externalMirror;
|
|
515
|
+
if (snapshot.activity.active) {
|
|
516
|
+
if (mirror.turnId !== snapshot.activity.turnId) {
|
|
517
|
+
mirror.turnId = snapshot.activity.turnId;
|
|
518
|
+
mirror.startedAt = snapshot.activity.startedAt?.toISOString() ?? null;
|
|
519
|
+
mirror.latestAgentLine = undefined;
|
|
520
|
+
this.startExternalTurn(snapshot);
|
|
521
|
+
}
|
|
522
|
+
this.broadcastExternalEvents(snapshot, snapshot.events.filter((event) => event.lineNumber > mirror.lastLine));
|
|
523
|
+
mirror.lastLine = Math.max(mirror.lastLine, snapshot.lineCount);
|
|
524
|
+
mirror.latestStatus = externalStatusLine(snapshot, this.queue().length);
|
|
525
|
+
this.broadcastStatus(mirror.latestStatus, "info");
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
const terminalEvent = [...snapshot.events].reverse().find((event) => event.kind === "task" && event.status && event.status !== "started");
|
|
529
|
+
if (terminalEvent && terminalEvent.lineNumber > mirror.lastLine) {
|
|
530
|
+
const finalAgent = snapshot.events.filter((event) => event.kind === "agent" && event.text).at(-1);
|
|
531
|
+
const finalText = finalAgent?.text ?? snapshot.latestAgentMessage;
|
|
532
|
+
const finalLine = finalAgent?.lineNumber ?? snapshot.lineCount;
|
|
533
|
+
if (finalText && finalLine !== mirror.latestAgentLine) {
|
|
534
|
+
this.chatStore.append({
|
|
535
|
+
threadId: snapshot.threadId,
|
|
536
|
+
role: "agent",
|
|
537
|
+
text: finalText,
|
|
538
|
+
source: "cli",
|
|
539
|
+
turnId: terminalEvent.turnId ?? undefined,
|
|
540
|
+
});
|
|
541
|
+
this.broadcast({ type: "text_delta", id: terminalEvent.turnId ?? "cli", delta: finalText });
|
|
542
|
+
mirror.latestAgentLine = finalLine;
|
|
543
|
+
}
|
|
544
|
+
const externalStartedAt = mirror.startedAt ? new Date(mirror.startedAt) : snapshot.activity.startedAt;
|
|
545
|
+
this.broadcast({
|
|
546
|
+
type: "turn_complete",
|
|
547
|
+
id: terminalEvent.turnId ?? "cli",
|
|
548
|
+
at: terminalEvent.timestamp?.toISOString() ?? new Date().toISOString(),
|
|
549
|
+
});
|
|
550
|
+
this.appendActivity({
|
|
551
|
+
source: "cli",
|
|
552
|
+
status: terminalEvent.status === "aborted" ? "aborted" : terminalEvent.status === "failed" ? "failed" : "completed",
|
|
553
|
+
type: "cli_turn_finished",
|
|
554
|
+
threadId: snapshot.threadId,
|
|
555
|
+
workspace: info.workspace,
|
|
556
|
+
agentId: info.agentId,
|
|
557
|
+
prompt: snapshot.latestUserMessage ?? undefined,
|
|
558
|
+
detail: `Codex CLI task ${terminalEvent.status ?? "finished"}.`,
|
|
559
|
+
durationMs: durationFromDates(externalStartedAt, terminalEvent.timestamp),
|
|
560
|
+
});
|
|
561
|
+
this.broadcastStatus(`Codex CLI task ${terminalEvent.status ?? "finished"}.`, terminalEvent.status === "failed" ? "error" : terminalEvent.status === "aborted" ? "warn" : "info");
|
|
562
|
+
this.broadcast({ type: "chat_history", messages: await this.chatHistory() });
|
|
563
|
+
}
|
|
564
|
+
mirror.lastLine = Math.max(mirror.lastLine, snapshot.lineCount);
|
|
565
|
+
}
|
|
566
|
+
startExternalTurn(snapshot) {
|
|
567
|
+
const prompt = snapshot.latestUserMessage ?? "Codex CLI task";
|
|
568
|
+
this.chatStore.append({
|
|
569
|
+
threadId: snapshot.threadId,
|
|
570
|
+
role: "user",
|
|
571
|
+
text: prompt,
|
|
572
|
+
source: "cli",
|
|
573
|
+
turnId: snapshot.activity.turnId ?? undefined,
|
|
574
|
+
timestamp: snapshot.activity.startedAt?.toISOString(),
|
|
575
|
+
});
|
|
576
|
+
this.broadcast({
|
|
577
|
+
type: "turn_start",
|
|
578
|
+
id: snapshot.activity.turnId ?? "cli",
|
|
579
|
+
prompt,
|
|
580
|
+
at: snapshot.activity.startedAt?.toISOString() ?? new Date().toISOString(),
|
|
581
|
+
source: "cli",
|
|
582
|
+
});
|
|
583
|
+
this.appendActivity({
|
|
584
|
+
source: "cli",
|
|
585
|
+
status: "running",
|
|
586
|
+
type: "cli_turn_started",
|
|
587
|
+
threadId: snapshot.threadId,
|
|
588
|
+
prompt,
|
|
589
|
+
detail: `Rollout: ${snapshot.rolloutPath}`,
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
broadcastExternalEvents(snapshot, events) {
|
|
593
|
+
for (const event of events) {
|
|
594
|
+
if (event.kind === "tool" && event.status === "started") {
|
|
595
|
+
this.broadcast({
|
|
596
|
+
type: "tool_start",
|
|
597
|
+
id: snapshot.activity.turnId ?? "cli",
|
|
598
|
+
toolCallId: `cli-${event.lineNumber}`,
|
|
599
|
+
toolName: event.toolName ?? "tool",
|
|
600
|
+
});
|
|
601
|
+
this.appendActivity({
|
|
602
|
+
source: "cli",
|
|
603
|
+
status: "running",
|
|
604
|
+
type: "cli_tool_started",
|
|
605
|
+
threadId: snapshot.threadId,
|
|
606
|
+
detail: event.toolName ?? "tool",
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
if (event.kind === "tool" && event.status === "finished") {
|
|
610
|
+
this.broadcast({
|
|
611
|
+
type: "tool_end",
|
|
612
|
+
id: snapshot.activity.turnId ?? "cli",
|
|
613
|
+
toolCallId: `cli-${event.lineNumber}`,
|
|
614
|
+
isError: false,
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
async getSession(deferThreadStart) {
|
|
620
|
+
return this.registry.getOrCreate(WEB_CONTEXT_KEY, { deferThreadStart });
|
|
621
|
+
}
|
|
622
|
+
async ensureActiveThread(session) {
|
|
623
|
+
if (!session.hasActiveThread()) {
|
|
624
|
+
await session.newThread();
|
|
625
|
+
this.updateSession(session);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
ensureIdle(session) {
|
|
629
|
+
if (session.isProcessing()) {
|
|
630
|
+
throw new Error("The active session is still processing a turn.");
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
async runPrompt(session, envelope) {
|
|
634
|
+
await this.ensureActiveThread(session);
|
|
635
|
+
const info = session.getInfo();
|
|
636
|
+
if ((info.capabilities ?? CODEX_AGENT_CAPABILITIES).auth) {
|
|
637
|
+
const auth = await checkAuthStatus(this.config.codexApiKey);
|
|
638
|
+
if (!auth.authenticated) {
|
|
639
|
+
throw new Error(`Codex is not authenticated: ${auth.detail}`);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
const workspacePolicy = evaluateWorkspacePolicy(session.getInfo().workspace, this.config);
|
|
643
|
+
if (!workspacePolicy.allowed) {
|
|
644
|
+
throw new Error(workspacePolicy.warning ?? "Current workspace is blocked by policy.");
|
|
645
|
+
}
|
|
646
|
+
const turnId = randomUUID().slice(0, 12);
|
|
647
|
+
this.currentTurnId = turnId;
|
|
648
|
+
this.currentTurnStartedAt = Date.now();
|
|
649
|
+
this.accumulatedText = "";
|
|
650
|
+
this.promptStore.setLastPrompt(WEB_CONTEXT_KEY, envelope);
|
|
651
|
+
const startedAt = new Date().toISOString();
|
|
652
|
+
this.chatStore.append({
|
|
653
|
+
threadId: info.threadId ?? "pending",
|
|
654
|
+
role: "user",
|
|
655
|
+
text: envelope.description,
|
|
656
|
+
source: "web",
|
|
657
|
+
turnId,
|
|
658
|
+
timestamp: startedAt,
|
|
659
|
+
});
|
|
660
|
+
this.appendActivity({
|
|
661
|
+
source: "web",
|
|
662
|
+
status: "running",
|
|
663
|
+
type: "prompt_started",
|
|
664
|
+
threadId: info.threadId,
|
|
665
|
+
workspace: info.workspace,
|
|
666
|
+
agentId: info.agentId,
|
|
667
|
+
prompt: envelope.description,
|
|
668
|
+
});
|
|
669
|
+
this.broadcast({ type: "turn_start", id: turnId, prompt: envelope.description, at: startedAt, source: "web" });
|
|
670
|
+
const callbacks = {
|
|
671
|
+
onTextDelta: (delta) => {
|
|
672
|
+
this.accumulatedText += delta;
|
|
673
|
+
this.broadcast({ type: "text_delta", id: turnId, delta });
|
|
674
|
+
},
|
|
675
|
+
onToolStart: (toolName, toolCallId) => this.broadcast({ type: "tool_start", id: turnId, toolCallId, toolName }),
|
|
676
|
+
onToolUpdate: (toolCallId, partialResult) => this.broadcast({ type: "tool_update", id: turnId, toolCallId, partialResult }),
|
|
677
|
+
onToolEnd: (toolCallId, isError) => this.broadcast({ type: "tool_end", id: turnId, toolCallId, isError }),
|
|
678
|
+
onTodoUpdate: (items) => this.broadcast({ type: "todo_update", id: turnId, items }),
|
|
679
|
+
onTurnComplete: () => { },
|
|
680
|
+
onAgentEnd: () => this.broadcast({ type: "turn_complete", id: turnId, at: new Date().toISOString() }),
|
|
681
|
+
};
|
|
682
|
+
try {
|
|
683
|
+
await session.prompt(envelope.input, callbacks);
|
|
684
|
+
this.updateSession(session);
|
|
685
|
+
if (this.accumulatedText.trim()) {
|
|
686
|
+
this.chatStore.append({
|
|
687
|
+
threadId: info.threadId ?? "pending",
|
|
688
|
+
role: "agent",
|
|
689
|
+
text: this.accumulatedText,
|
|
690
|
+
source: "web",
|
|
691
|
+
turnId,
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
this.appendActivity({
|
|
695
|
+
source: "web",
|
|
696
|
+
status: "completed",
|
|
697
|
+
type: "prompt_completed",
|
|
698
|
+
threadId: info.threadId,
|
|
699
|
+
workspace: info.workspace,
|
|
700
|
+
agentId: info.agentId,
|
|
701
|
+
prompt: envelope.description,
|
|
702
|
+
durationMs: Date.now() - this.currentTurnStartedAt,
|
|
703
|
+
});
|
|
704
|
+
this.broadcast({ type: "turn_complete", id: turnId, at: new Date().toISOString() });
|
|
705
|
+
this.broadcast({ type: "chat_history", messages: await this.chatHistory() });
|
|
706
|
+
}
|
|
707
|
+
catch (error) {
|
|
708
|
+
const errorText = friendlyErrorText(error);
|
|
709
|
+
this.chatStore.append({
|
|
710
|
+
threadId: info.threadId ?? "pending",
|
|
711
|
+
role: "system",
|
|
712
|
+
text: `Error: ${errorText}`,
|
|
713
|
+
source: "web",
|
|
714
|
+
turnId,
|
|
715
|
+
});
|
|
716
|
+
this.appendActivity({
|
|
717
|
+
source: "web",
|
|
718
|
+
status: "failed",
|
|
719
|
+
type: "prompt_failed",
|
|
720
|
+
threadId: info.threadId,
|
|
721
|
+
workspace: info.workspace,
|
|
722
|
+
agentId: info.agentId,
|
|
723
|
+
prompt: envelope.description,
|
|
724
|
+
detail: errorText,
|
|
725
|
+
durationMs: Date.now() - this.currentTurnStartedAt,
|
|
726
|
+
});
|
|
727
|
+
this.broadcast({ type: "turn_error", id: turnId, error: errorText, at: new Date().toISOString() });
|
|
728
|
+
this.broadcast({ type: "chat_history", messages: await this.chatHistory() });
|
|
729
|
+
throw error;
|
|
730
|
+
}
|
|
731
|
+
finally {
|
|
732
|
+
this.currentTurnId = null;
|
|
733
|
+
await this.drainQueue();
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
async drainQueue() {
|
|
737
|
+
if (this.draining || this.promptStore.isPaused(WEB_CONTEXT_KEY)) {
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
this.draining = true;
|
|
741
|
+
try {
|
|
742
|
+
const session = await this.getSession(false);
|
|
743
|
+
while (!session.isProcessing()) {
|
|
744
|
+
const next = this.promptStore.dequeue(WEB_CONTEXT_KEY);
|
|
745
|
+
this.broadcastQueue();
|
|
746
|
+
if (!next) {
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
await this.runPrompt(session, next);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
finally {
|
|
753
|
+
this.draining = false;
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
updateSession(session) {
|
|
757
|
+
this.registry.updateMetadata(WEB_CONTEXT_KEY, session);
|
|
758
|
+
this.broadcast({ type: "session_update", session: this.publicInfo(session) });
|
|
759
|
+
}
|
|
760
|
+
appendActivity(input) {
|
|
761
|
+
const event = this.activityStore.append(input);
|
|
762
|
+
this.broadcast({ type: "activity_update", events: this.activity({ limit: 50 }) });
|
|
763
|
+
return event;
|
|
764
|
+
}
|
|
765
|
+
broadcastQueue() {
|
|
766
|
+
this.broadcast({ type: "queue_update", queue: this.queue(), paused: this.queuePaused() });
|
|
767
|
+
}
|
|
768
|
+
broadcastStatus(message, level = "info") {
|
|
769
|
+
this.broadcast({ type: "status", message, level, at: new Date().toISOString() });
|
|
770
|
+
}
|
|
771
|
+
broadcast(event) {
|
|
772
|
+
for (const subscriber of this.subscribers) {
|
|
773
|
+
try {
|
|
774
|
+
subscriber(event);
|
|
775
|
+
}
|
|
776
|
+
catch {
|
|
777
|
+
this.subscribers.delete(subscriber);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
publicInfo(session) {
|
|
782
|
+
const info = session.getInfo();
|
|
783
|
+
const agentId = info.agentId ?? "codex";
|
|
784
|
+
return {
|
|
785
|
+
...info,
|
|
786
|
+
agentId,
|
|
787
|
+
agentLabel: info.agentLabel ?? agentLabel(agentId),
|
|
788
|
+
capabilities: info.capabilities ?? CODEX_AGENT_CAPABILITIES,
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
function queueItemDto(item) {
|
|
793
|
+
return {
|
|
794
|
+
id: item.id,
|
|
795
|
+
description: item.description,
|
|
796
|
+
createdAt: new Date(item.createdAt).toISOString(),
|
|
797
|
+
attempts: item.attempts ?? 0,
|
|
798
|
+
notBefore: item.notBefore ? new Date(item.notBefore).toISOString() : undefined,
|
|
799
|
+
lastError: item.lastError,
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
function artifactDto(report) {
|
|
803
|
+
return {
|
|
804
|
+
turnId: report.turnId,
|
|
805
|
+
updatedAt: report.updatedAt.toISOString(),
|
|
806
|
+
source: report.source,
|
|
807
|
+
fileCount: report.artifacts.length,
|
|
808
|
+
totalSizeBytes: totalArtifactSize(report.artifacts),
|
|
809
|
+
skippedCount: report.skippedCount,
|
|
810
|
+
omittedCount: report.omittedCount,
|
|
811
|
+
artifacts: report.artifacts.map((artifact) => ({
|
|
812
|
+
name: artifact.name,
|
|
813
|
+
relativePath: artifact.relativePath.split(path.sep).join("/"),
|
|
814
|
+
sizeBytes: artifact.sizeBytes,
|
|
815
|
+
})),
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
function externalStatusLine(snapshot, queueLength) {
|
|
819
|
+
const elapsed = snapshot.activity.startedAt
|
|
820
|
+
? formatDuration((Date.now() - snapshot.activity.startedAt.getTime()) / 1000)
|
|
821
|
+
: "-";
|
|
822
|
+
const tool = snapshot.latestToolName ?? "-";
|
|
823
|
+
return `Codex CLI running · ${elapsed} · tool ${tool} · ${queueLength} queued`;
|
|
824
|
+
}
|
|
825
|
+
function durationFromDates(start, end) {
|
|
826
|
+
if (!start || !end) {
|
|
827
|
+
return undefined;
|
|
828
|
+
}
|
|
829
|
+
return Math.max(0, end.getTime() - start.getTime());
|
|
830
|
+
}
|
|
831
|
+
function formatDuration(seconds) {
|
|
832
|
+
if (!Number.isFinite(seconds) || seconds < 0) {
|
|
833
|
+
return "-";
|
|
834
|
+
}
|
|
835
|
+
if (seconds < 60) {
|
|
836
|
+
return `${Math.round(seconds)}s`;
|
|
837
|
+
}
|
|
838
|
+
const minutes = Math.floor(seconds / 60);
|
|
839
|
+
const remainder = Math.round(seconds % 60);
|
|
840
|
+
return `${minutes}m ${remainder}s`;
|
|
841
|
+
}
|
|
842
|
+
function normalizeMimeType(value, name) {
|
|
843
|
+
const configured = value?.trim();
|
|
844
|
+
if (configured) {
|
|
845
|
+
return configured;
|
|
846
|
+
}
|
|
847
|
+
const extension = path.extname(name).toLowerCase();
|
|
848
|
+
if ([".jpg", ".jpeg"].includes(extension))
|
|
849
|
+
return "image/jpeg";
|
|
850
|
+
if (extension === ".png")
|
|
851
|
+
return "image/png";
|
|
852
|
+
if (extension === ".gif")
|
|
853
|
+
return "image/gif";
|
|
854
|
+
if (extension === ".webp")
|
|
855
|
+
return "image/webp";
|
|
856
|
+
if (extension === ".mp3")
|
|
857
|
+
return "audio/mpeg";
|
|
858
|
+
if (extension === ".wav")
|
|
859
|
+
return "audio/wav";
|
|
860
|
+
if (extension === ".ogg" || extension === ".oga")
|
|
861
|
+
return "audio/ogg";
|
|
862
|
+
if (extension === ".m4a")
|
|
863
|
+
return "audio/mp4";
|
|
864
|
+
if (extension === ".webm")
|
|
865
|
+
return "audio/webm";
|
|
866
|
+
return "application/octet-stream";
|
|
867
|
+
}
|
|
868
|
+
function uploadFileDtos(files) {
|
|
869
|
+
return files.map((file) => ({
|
|
870
|
+
name: file.safeName,
|
|
871
|
+
mimeType: file.mimeType,
|
|
872
|
+
sizeBytes: file.sizeBytes,
|
|
873
|
+
}));
|
|
874
|
+
}
|
|
875
|
+
function isPreviewableTextFile(extension, sizeBytes) {
|
|
876
|
+
if (sizeBytes > MAX_TEXT_PREVIEW_BYTES * 4) {
|
|
877
|
+
return false;
|
|
878
|
+
}
|
|
879
|
+
return [
|
|
880
|
+
"",
|
|
881
|
+
".c",
|
|
882
|
+
".conf",
|
|
883
|
+
".cpp",
|
|
884
|
+
".css",
|
|
885
|
+
".csv",
|
|
886
|
+
".env",
|
|
887
|
+
".go",
|
|
888
|
+
".html",
|
|
889
|
+
".java",
|
|
890
|
+
".js",
|
|
891
|
+
".json",
|
|
892
|
+
".jsx",
|
|
893
|
+
".log",
|
|
894
|
+
".md",
|
|
895
|
+
".py",
|
|
896
|
+
".rb",
|
|
897
|
+
".rs",
|
|
898
|
+
".sh",
|
|
899
|
+
".sql",
|
|
900
|
+
".toml",
|
|
901
|
+
".ts",
|
|
902
|
+
".tsx",
|
|
903
|
+
".txt",
|
|
904
|
+
".xml",
|
|
905
|
+
".yaml",
|
|
906
|
+
".yml",
|
|
907
|
+
].includes(extension);
|
|
908
|
+
}
|