@nordbyte/nordrelay 0.2.1 → 0.3.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.
@@ -1,14 +1,17 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { mkdirSync } from "node:fs";
3
- import path from "node:path";
4
- import { readJsonFileWithBackup, writeJsonFileAtomic } from "./persistence.js";
2
+ import { createDocumentStore } from "./state-backend.js";
5
3
  export class PromptStore {
6
- persistPath;
4
+ store;
7
5
  lastPrompts = new Map();
8
6
  queues = new Map();
9
7
  pausedContexts = new Set();
10
- constructor(workspace) {
11
- this.persistPath = path.join(workspace, ".nordrelay", "prompts.json");
8
+ constructor(workspace, backend = "json") {
9
+ this.store = createDocumentStore({
10
+ workspace,
11
+ fileName: "prompts.json",
12
+ sqliteKey: "prompts",
13
+ backend,
14
+ });
12
15
  this.load();
13
16
  }
14
17
  setLastPrompt(contextKey, prompt) {
@@ -18,12 +21,13 @@ export class PromptStore {
18
21
  getLastPrompt(contextKey) {
19
22
  return this.lastPrompts.get(contextKey);
20
23
  }
21
- enqueue(contextKey, prompt) {
24
+ enqueue(contextKey, prompt, options = {}) {
22
25
  const item = {
23
26
  ...prompt,
24
27
  id: createQueueId(),
25
28
  contextKey,
26
29
  createdAt: Date.now(),
30
+ notBefore: options.notBefore,
27
31
  };
28
32
  const queue = this.queues.get(contextKey) ?? [];
29
33
  queue.push(item);
@@ -39,7 +43,15 @@ export class PromptStore {
39
43
  }
40
44
  dequeue(contextKey) {
41
45
  const queue = this.queues.get(contextKey);
42
- const item = queue?.shift();
46
+ if (!queue || queue.length === 0) {
47
+ return undefined;
48
+ }
49
+ const now = Date.now();
50
+ const index = queue.findIndex((queued) => !queued.notBefore || queued.notBefore <= now);
51
+ if (index === -1) {
52
+ return undefined;
53
+ }
54
+ const [item] = queue.splice(index, 1);
43
55
  if (!queue || queue.length === 0) {
44
56
  this.queues.delete(contextKey);
45
57
  }
@@ -53,6 +65,16 @@ export class PromptStore {
53
65
  list(contextKey) {
54
66
  return [...(this.queues.get(contextKey) ?? [])];
55
67
  }
68
+ get(contextKey, id) {
69
+ return this.queues.get(contextKey)?.find((item) => item.id === id);
70
+ }
71
+ nextRunnableAt(contextKey) {
72
+ const timestamps = (this.queues.get(contextKey) ?? [])
73
+ .map((item) => item.notBefore)
74
+ .filter((value) => typeof value === "number")
75
+ .sort((left, right) => left - right);
76
+ return timestamps[0] ?? null;
77
+ }
56
78
  listContextKeys() {
57
79
  return [...new Set([...this.queues.keys(), ...this.pausedContexts])];
58
80
  }
@@ -143,13 +165,12 @@ export class PromptStore {
143
165
  }
144
166
  persist() {
145
167
  try {
146
- mkdirSync(path.dirname(this.persistPath), { recursive: true });
147
168
  const payload = {
148
169
  lastPrompts: Object.fromEntries(this.lastPrompts.entries()),
149
170
  queues: Object.fromEntries(this.queues.entries()),
150
171
  pausedContexts: [...this.pausedContexts],
151
172
  };
152
- writeJsonFileAtomic(this.persistPath, payload);
173
+ this.store.write(payload);
153
174
  }
154
175
  catch (error) {
155
176
  console.warn("Failed to persist prompt store:", error instanceof Error ? error.message : String(error));
@@ -157,7 +178,7 @@ export class PromptStore {
157
178
  }
158
179
  load() {
159
180
  try {
160
- const payload = readJsonFileWithBackup(this.persistPath).value;
181
+ const payload = this.store.read();
161
182
  if (!payload) {
162
183
  return;
163
184
  }
@@ -218,6 +239,7 @@ function isQueuedPrompt(value) {
218
239
  typeof value.id === "string" &&
219
240
  typeof value.contextKey === "string" &&
220
241
  typeof value.createdAt === "number" &&
242
+ (value.notBefore === undefined || typeof value.notBefore === "number") &&
221
243
  (value.updatedAt === undefined || typeof value.updatedAt === "number") &&
222
244
  (value.attempts === undefined || typeof value.attempts === "number") &&
223
245
  (value.lastError === undefined || typeof value.lastError === "string");
@@ -0,0 +1,479 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import path from "node:path";
3
+ import { createArtifactZipBundle, getArtifactTurnReport, ensureOutDir, listRecentArtifactReports, removeArtifactTurn, totalArtifactSize, } from "./artifacts.js";
4
+ import { buildFileInstructions, outboxPath, stageFile, } from "./attachments.js";
5
+ import { CODEX_AGENT_CAPABILITIES, CODEX_REASONING_EFFORTS, PI_THINKING_LEVELS, agentLabel, agentReasoningLabel, } from "./agent.js";
6
+ import { enabledAgents } from "./agent-factory.js";
7
+ import { checkAuthStatus } from "./codex-auth.js";
8
+ import { friendlyErrorText } from "./error-messages.js";
9
+ import { getConnectorHealth, getVersionChecks, readFormattedLogTail } from "./operations.js";
10
+ import { PromptStore, toPromptEnvelope } from "./prompt-store.js";
11
+ import { renderSessionInfoPlain } from "./session-format.js";
12
+ import { SessionRegistry } from "./session-registry.js";
13
+ import { transcribeAudio } from "./voice.js";
14
+ import { evaluateWorkspacePolicy, filterAllowedWorkspaces } from "./workspace-policy.js";
15
+ const WEB_CONTEXT_KEY = "0";
16
+ const MAX_WEB_SESSION_PAGE_SIZE = 50;
17
+ export class RelayRuntime {
18
+ config;
19
+ registry;
20
+ promptStore;
21
+ subscribers = new Set();
22
+ draining = false;
23
+ currentTurnId = null;
24
+ accumulatedText = "";
25
+ constructor(config) {
26
+ this.config = config;
27
+ this.registry = new SessionRegistry(config);
28
+ this.promptStore = new PromptStore(config.workspace, config.stateBackend);
29
+ }
30
+ subscribe(callback) {
31
+ this.subscribers.add(callback);
32
+ void this.snapshot().then((data) => callback({ type: "snapshot", data })).catch(() => { });
33
+ return () => this.subscribers.delete(callback);
34
+ }
35
+ async snapshot() {
36
+ const session = await this.getSession(true);
37
+ const info = this.publicInfo(session);
38
+ return {
39
+ session: info,
40
+ sessionText: renderSessionInfoPlain(info),
41
+ queue: this.queue(),
42
+ processing: session.isProcessing(),
43
+ enabledAgents: enabledAgents(this.config),
44
+ workspaces: filterAllowedWorkspaces(session.listWorkspaces(), this.config),
45
+ };
46
+ }
47
+ async status() {
48
+ return {
49
+ health: await getConnectorHealth(),
50
+ versionChecks: await getVersionChecks({ piCliPath: this.config.piCliPath }),
51
+ snapshot: await this.snapshot(),
52
+ };
53
+ }
54
+ async listSessions(limit = 80, query = "") {
55
+ return this.filteredSessions(await this.getSession(true), query, Math.max(1, limit * 3)).slice(0, limit);
56
+ }
57
+ async listSessionsPage(page = 1, pageSize = MAX_WEB_SESSION_PAGE_SIZE, query = "") {
58
+ const session = await this.getSession(true);
59
+ const effectivePage = Math.max(1, Math.floor(page));
60
+ const effectivePageSize = Math.min(MAX_WEB_SESSION_PAGE_SIZE, Math.max(1, Math.floor(pageSize)));
61
+ const offset = (effectivePage - 1) * effectivePageSize;
62
+ const requested = Math.min(5_000, Math.max(100, (offset + effectivePageSize + 1) * 3));
63
+ const records = this.filteredSessions(session, query, requested);
64
+ return {
65
+ sessions: records.slice(offset, offset + effectivePageSize),
66
+ pagination: {
67
+ page: effectivePage,
68
+ pageSize: effectivePageSize,
69
+ hasPrevious: effectivePage > 1,
70
+ hasNext: records.length > offset + effectivePageSize,
71
+ },
72
+ };
73
+ }
74
+ filteredSessions(session, query, limit) {
75
+ const normalized = query.trim().toLowerCase();
76
+ return session.listAllSessions(limit)
77
+ .filter((record) => evaluateWorkspacePolicy(record.cwd, this.config).allowed)
78
+ .filter((record) => {
79
+ if (!normalized) {
80
+ return true;
81
+ }
82
+ return [
83
+ record.id,
84
+ record.title,
85
+ record.cwd,
86
+ record.model,
87
+ record.reasoningEffort,
88
+ record.firstUserMessage,
89
+ ].some((value) => value?.toLowerCase().includes(normalized));
90
+ });
91
+ }
92
+ async listModels() {
93
+ return (await this.getSession(true)).listModels();
94
+ }
95
+ async setAgent(agentId) {
96
+ if (!enabledAgents(this.config).includes(agentId)) {
97
+ throw new Error(`Agent is not enabled: ${agentId}`);
98
+ }
99
+ const session = await this.registry.switchAgent(WEB_CONTEXT_KEY, agentId);
100
+ this.updateSession(session);
101
+ return this.publicInfo(session);
102
+ }
103
+ async newSession(options = {}) {
104
+ const session = await this.getSession(true);
105
+ this.ensureIdle(session);
106
+ const info = await session.newThread(options.workspace, options.model);
107
+ this.updateSession(session);
108
+ return this.publicInfo(session);
109
+ }
110
+ async switchSession(threadId) {
111
+ const session = await this.getSession(true);
112
+ this.ensureIdle(session);
113
+ const info = await session.switchSession(threadId);
114
+ this.updateSession(session);
115
+ return this.publicInfo(session);
116
+ }
117
+ async attachSession(threadId) {
118
+ return this.switchSession(threadId);
119
+ }
120
+ async setModel(model) {
121
+ const session = await this.getSession(true);
122
+ this.ensureIdle(session);
123
+ await session.setModelForCurrentSession(model);
124
+ this.updateSession(session);
125
+ return this.publicInfo(session);
126
+ }
127
+ async setReasoningEffort(effort) {
128
+ const session = await this.getSession(true);
129
+ this.ensureIdle(session);
130
+ const options = session.getInfo().agentId === "pi" ? PI_THINKING_LEVELS : CODEX_REASONING_EFFORTS;
131
+ if (!options.includes(effort)) {
132
+ throw new Error(`Invalid ${agentReasoningLabel(session.getInfo().agentId)} value: ${effort}`);
133
+ }
134
+ await session.setReasoningEffortForCurrentSession(effort);
135
+ this.updateSession(session);
136
+ return this.publicInfo(session);
137
+ }
138
+ async setFastMode(enabled) {
139
+ const session = await this.getSession(true);
140
+ this.ensureIdle(session);
141
+ if (!(session.getInfo().capabilities ?? CODEX_AGENT_CAPABILITIES).fastMode) {
142
+ throw new Error(`Fast mode is not supported for ${agentLabel(session.getInfo().agentId)}.`);
143
+ }
144
+ session.setFastMode(enabled);
145
+ this.updateSession(session);
146
+ return this.publicInfo(session);
147
+ }
148
+ async setLaunchProfile(profileId) {
149
+ const session = await this.getSession(true);
150
+ this.ensureIdle(session);
151
+ session.setLaunchProfile(profileId);
152
+ this.updateSession(session);
153
+ return this.publicInfo(session);
154
+ }
155
+ async handback() {
156
+ const session = await this.getSession(true);
157
+ this.ensureIdle(session);
158
+ const result = session.handback();
159
+ this.updateSession(session);
160
+ return result;
161
+ }
162
+ async abort() {
163
+ const session = await this.getSession(true);
164
+ await session.abort();
165
+ this.broadcast({ type: "status", level: "warn", message: "Current operation aborted.", at: new Date().toISOString() });
166
+ }
167
+ async sendPrompt(text) {
168
+ const trimmed = text.trim();
169
+ if (!trimmed) {
170
+ throw new Error("Prompt is empty.");
171
+ }
172
+ return this.sendEnvelope(toPromptEnvelope(trimmed));
173
+ }
174
+ async sendUploadPrompt(options) {
175
+ const text = options.text?.trim() ?? "";
176
+ const files = options.files.filter((file) => file.data.byteLength > 0);
177
+ if (!text && files.length === 0) {
178
+ throw new Error("Prompt is empty.");
179
+ }
180
+ const session = await this.getSession(false);
181
+ const workspace = session.getInfo().workspace;
182
+ const turnId = randomUUID().slice(0, 12);
183
+ const outDir = outboxPath(workspace, turnId);
184
+ await ensureOutDir(outDir);
185
+ const stagedFiles = [];
186
+ const imagePaths = [];
187
+ const transcriptParts = [];
188
+ for (const [index, file] of files.entries()) {
189
+ const mimeType = normalizeMimeType(file.mimeType, file.name);
190
+ const staged = await stageFile(file.data, file.name || `upload-${index + 1}`, mimeType, {
191
+ workspace,
192
+ turnId,
193
+ maxFileSize: this.config.maxFileSize,
194
+ });
195
+ stagedFiles.push(staged);
196
+ if (mimeType.startsWith("image/")) {
197
+ imagePaths.push(staged.localPath);
198
+ }
199
+ if (mimeType.startsWith("audio/")) {
200
+ const result = await transcribeAudio(staged.localPath, {
201
+ preferredBackend: this.config.voicePreferredBackend === "auto"
202
+ ? undefined
203
+ : this.config.voicePreferredBackend,
204
+ language: this.config.voiceDefaultLanguage,
205
+ });
206
+ const transcript = result.text.trim();
207
+ if (transcript) {
208
+ transcriptParts.push(`Audio transcript (${staged.safeName}, via ${result.backend}):\n${transcript}`);
209
+ }
210
+ }
211
+ }
212
+ const audioOnly = stagedFiles.length > 0 && stagedFiles.every((file) => file.mimeType.startsWith("audio/"));
213
+ if (this.config.voiceTranscribeOnly && audioOnly && !text) {
214
+ return {
215
+ queued: false,
216
+ transcript: transcriptParts.join("\n\n"),
217
+ transcribeOnly: true,
218
+ files: uploadFileDtos(stagedFiles),
219
+ };
220
+ }
221
+ const promptInput = {};
222
+ const textParts = [text, ...transcriptParts].filter(Boolean);
223
+ if (textParts.length > 0) {
224
+ promptInput.text = textParts.join("\n\n");
225
+ }
226
+ if (imagePaths.length > 0) {
227
+ promptInput.imagePaths = imagePaths;
228
+ }
229
+ if (stagedFiles.length > 0) {
230
+ promptInput.stagedFileInstructions = buildFileInstructions(stagedFiles, outDir);
231
+ }
232
+ const result = await this.sendEnvelope(toPromptEnvelope(promptInput, outDir));
233
+ return {
234
+ ...result,
235
+ transcript: transcriptParts.join("\n\n") || undefined,
236
+ files: uploadFileDtos(stagedFiles),
237
+ };
238
+ }
239
+ async sendEnvelope(envelope) {
240
+ const session = await this.getSession(false);
241
+ if (session.isProcessing()) {
242
+ const queued = this.promptStore.enqueue(WEB_CONTEXT_KEY, envelope);
243
+ this.broadcastQueue();
244
+ return { queued: true, queueId: queued.id };
245
+ }
246
+ void this.runPrompt(session, envelope).catch((error) => {
247
+ this.broadcast({ type: "turn_error", id: this.currentTurnId ?? "turn", error: friendlyErrorText(error), at: new Date().toISOString() });
248
+ });
249
+ return { queued: false };
250
+ }
251
+ queue() {
252
+ return this.promptStore.list(WEB_CONTEXT_KEY).map(queueItemDto);
253
+ }
254
+ queueAction(action, id) {
255
+ if (action === "pause")
256
+ this.promptStore.pause(WEB_CONTEXT_KEY);
257
+ if (action === "resume")
258
+ this.promptStore.resume(WEB_CONTEXT_KEY);
259
+ if (action === "clear")
260
+ this.promptStore.clear(WEB_CONTEXT_KEY);
261
+ if (id && action === "cancel")
262
+ this.promptStore.remove(WEB_CONTEXT_KEY, id);
263
+ if (id && action === "top")
264
+ this.promptStore.moveToTop(WEB_CONTEXT_KEY, id);
265
+ if (id && action === "up")
266
+ this.promptStore.moveUp(WEB_CONTEXT_KEY, id);
267
+ if (id && action === "down")
268
+ this.promptStore.moveDown(WEB_CONTEXT_KEY, id);
269
+ if (id && action === "run") {
270
+ const item = this.promptStore.remove(WEB_CONTEXT_KEY, id);
271
+ if (item)
272
+ this.promptStore.enqueueFront(WEB_CONTEXT_KEY, item);
273
+ void this.drainQueue().catch((error) => this.broadcastStatus(friendlyErrorText(error), "error"));
274
+ }
275
+ this.broadcastQueue();
276
+ return this.queue();
277
+ }
278
+ async artifacts() {
279
+ const session = await this.getSession(true);
280
+ return (await listRecentArtifactReports(session.getInfo().workspace, 20, this.config.maxFileSize)).map(artifactDto);
281
+ }
282
+ async artifact(turnId) {
283
+ const session = await this.getSession(true);
284
+ return getArtifactTurnReport(session.getInfo().workspace, turnId, this.config.maxFileSize);
285
+ }
286
+ async deleteArtifact(turnId) {
287
+ const session = await this.getSession(true);
288
+ return removeArtifactTurn(session.getInfo().workspace, turnId);
289
+ }
290
+ async createArtifactZip(turnId) {
291
+ const report = await this.artifact(turnId);
292
+ if (!report) {
293
+ return null;
294
+ }
295
+ const bundle = await createArtifactZipBundle(report.artifacts, report.outDir, {
296
+ maxFileSize: this.config.maxFileSize,
297
+ bundleName: `nordrelay-artifacts-${turnId}.zip`,
298
+ });
299
+ return bundle ? { path: bundle.localPath, name: bundle.name } : null;
300
+ }
301
+ async logs(target = "connector", lines = 100) {
302
+ if (target === "update") {
303
+ const { getUpdateLogPath } = await import("./operations.js");
304
+ return readFormattedLogTail(lines, getUpdateLogPath());
305
+ }
306
+ return readFormattedLogTail(lines);
307
+ }
308
+ dispose() {
309
+ this.registry.disposeAll();
310
+ this.subscribers.clear();
311
+ }
312
+ async getSession(deferThreadStart) {
313
+ return this.registry.getOrCreate(WEB_CONTEXT_KEY, { deferThreadStart });
314
+ }
315
+ async ensureActiveThread(session) {
316
+ if (!session.hasActiveThread()) {
317
+ await session.newThread();
318
+ this.updateSession(session);
319
+ }
320
+ }
321
+ ensureIdle(session) {
322
+ if (session.isProcessing()) {
323
+ throw new Error("The active session is still processing a turn.");
324
+ }
325
+ }
326
+ async runPrompt(session, envelope) {
327
+ await this.ensureActiveThread(session);
328
+ const info = session.getInfo();
329
+ if ((info.capabilities ?? CODEX_AGENT_CAPABILITIES).auth) {
330
+ const auth = await checkAuthStatus(this.config.codexApiKey);
331
+ if (!auth.authenticated) {
332
+ throw new Error(`Codex is not authenticated: ${auth.detail}`);
333
+ }
334
+ }
335
+ const workspacePolicy = evaluateWorkspacePolicy(session.getInfo().workspace, this.config);
336
+ if (!workspacePolicy.allowed) {
337
+ throw new Error(workspacePolicy.warning ?? "Current workspace is blocked by policy.");
338
+ }
339
+ const turnId = randomUUID().slice(0, 12);
340
+ this.currentTurnId = turnId;
341
+ this.accumulatedText = "";
342
+ this.promptStore.setLastPrompt(WEB_CONTEXT_KEY, envelope);
343
+ this.broadcast({ type: "turn_start", id: turnId, prompt: envelope.description, at: new Date().toISOString() });
344
+ const callbacks = {
345
+ onTextDelta: (delta) => {
346
+ this.accumulatedText += delta;
347
+ this.broadcast({ type: "text_delta", id: turnId, delta });
348
+ },
349
+ onToolStart: (toolName, toolCallId) => this.broadcast({ type: "tool_start", id: turnId, toolCallId, toolName }),
350
+ onToolUpdate: (toolCallId, partialResult) => this.broadcast({ type: "tool_update", id: turnId, toolCallId, partialResult }),
351
+ onToolEnd: (toolCallId, isError) => this.broadcast({ type: "tool_end", id: turnId, toolCallId, isError }),
352
+ onTodoUpdate: (items) => this.broadcast({ type: "todo_update", id: turnId, items }),
353
+ onTurnComplete: () => { },
354
+ onAgentEnd: () => this.broadcast({ type: "turn_complete", id: turnId, at: new Date().toISOString() }),
355
+ };
356
+ try {
357
+ await session.prompt(envelope.input, callbacks);
358
+ this.updateSession(session);
359
+ this.broadcast({ type: "turn_complete", id: turnId, at: new Date().toISOString() });
360
+ }
361
+ catch (error) {
362
+ this.broadcast({ type: "turn_error", id: turnId, error: friendlyErrorText(error), at: new Date().toISOString() });
363
+ throw error;
364
+ }
365
+ finally {
366
+ this.currentTurnId = null;
367
+ await this.drainQueue();
368
+ }
369
+ }
370
+ async drainQueue() {
371
+ if (this.draining || this.promptStore.isPaused(WEB_CONTEXT_KEY)) {
372
+ return;
373
+ }
374
+ this.draining = true;
375
+ try {
376
+ const session = await this.getSession(false);
377
+ while (!session.isProcessing()) {
378
+ const next = this.promptStore.dequeue(WEB_CONTEXT_KEY);
379
+ this.broadcastQueue();
380
+ if (!next) {
381
+ return;
382
+ }
383
+ await this.runPrompt(session, next);
384
+ }
385
+ }
386
+ finally {
387
+ this.draining = false;
388
+ }
389
+ }
390
+ updateSession(session) {
391
+ this.registry.updateMetadata(WEB_CONTEXT_KEY, session);
392
+ this.broadcast({ type: "session_update", session: this.publicInfo(session) });
393
+ }
394
+ broadcastQueue() {
395
+ this.broadcast({ type: "queue_update", queue: this.queue() });
396
+ }
397
+ broadcastStatus(message, level = "info") {
398
+ this.broadcast({ type: "status", message, level, at: new Date().toISOString() });
399
+ }
400
+ broadcast(event) {
401
+ for (const subscriber of this.subscribers) {
402
+ try {
403
+ subscriber(event);
404
+ }
405
+ catch {
406
+ this.subscribers.delete(subscriber);
407
+ }
408
+ }
409
+ }
410
+ publicInfo(session) {
411
+ const info = session.getInfo();
412
+ const agentId = info.agentId ?? "codex";
413
+ return {
414
+ ...info,
415
+ agentId,
416
+ agentLabel: info.agentLabel ?? agentLabel(agentId),
417
+ capabilities: info.capabilities ?? CODEX_AGENT_CAPABILITIES,
418
+ };
419
+ }
420
+ }
421
+ function queueItemDto(item) {
422
+ return {
423
+ id: item.id,
424
+ description: item.description,
425
+ createdAt: new Date(item.createdAt).toISOString(),
426
+ attempts: item.attempts ?? 0,
427
+ notBefore: item.notBefore ? new Date(item.notBefore).toISOString() : undefined,
428
+ lastError: item.lastError,
429
+ };
430
+ }
431
+ function artifactDto(report) {
432
+ return {
433
+ turnId: report.turnId,
434
+ updatedAt: report.updatedAt.toISOString(),
435
+ source: report.source,
436
+ fileCount: report.artifacts.length,
437
+ totalSizeBytes: totalArtifactSize(report.artifacts),
438
+ skippedCount: report.skippedCount,
439
+ omittedCount: report.omittedCount,
440
+ artifacts: report.artifacts.map((artifact) => ({
441
+ name: artifact.name,
442
+ relativePath: artifact.relativePath.split(path.sep).join("/"),
443
+ sizeBytes: artifact.sizeBytes,
444
+ })),
445
+ };
446
+ }
447
+ function normalizeMimeType(value, name) {
448
+ const configured = value?.trim();
449
+ if (configured) {
450
+ return configured;
451
+ }
452
+ const extension = path.extname(name).toLowerCase();
453
+ if ([".jpg", ".jpeg"].includes(extension))
454
+ return "image/jpeg";
455
+ if (extension === ".png")
456
+ return "image/png";
457
+ if (extension === ".gif")
458
+ return "image/gif";
459
+ if (extension === ".webp")
460
+ return "image/webp";
461
+ if (extension === ".mp3")
462
+ return "audio/mpeg";
463
+ if (extension === ".wav")
464
+ return "audio/wav";
465
+ if (extension === ".ogg" || extension === ".oga")
466
+ return "audio/ogg";
467
+ if (extension === ".m4a")
468
+ return "audio/mp4";
469
+ if (extension === ".webm")
470
+ return "audio/webm";
471
+ return "application/octet-stream";
472
+ }
473
+ function uploadFileDtos(files) {
474
+ return files.map((file) => ({
475
+ name: file.safeName,
476
+ mimeType: file.mimeType,
477
+ sizeBytes: file.sizeBytes,
478
+ }));
479
+ }
@@ -0,0 +1,81 @@
1
+ import { createDocumentStore } from "./state-backend.js";
2
+ export class SessionLockStore {
3
+ store;
4
+ constructor(workspace, backend = "json") {
5
+ this.store = createDocumentStore({
6
+ workspace,
7
+ fileName: "locks.json",
8
+ sqliteKey: "locks",
9
+ backend,
10
+ });
11
+ }
12
+ get(contextKey, now = Date.now()) {
13
+ const payload = this.readPayload();
14
+ const lock = payload.locks[contextKey];
15
+ if (!lock) {
16
+ return null;
17
+ }
18
+ if (lock.expiresAt && lock.expiresAt <= now) {
19
+ delete payload.locks[contextKey];
20
+ this.store.write(payload);
21
+ return null;
22
+ }
23
+ return lock;
24
+ }
25
+ set(contextKey, ownerId, ownerName, ttlMs) {
26
+ const payload = this.readPayload();
27
+ const lock = {
28
+ contextKey,
29
+ ownerId,
30
+ ownerName,
31
+ createdAt: Date.now(),
32
+ expiresAt: ttlMs > 0 ? Date.now() + ttlMs : undefined,
33
+ };
34
+ payload.locks[contextKey] = lock;
35
+ this.store.write(payload);
36
+ return lock;
37
+ }
38
+ clear(contextKey) {
39
+ const payload = this.readPayload();
40
+ const existed = Boolean(payload.locks[contextKey]);
41
+ delete payload.locks[contextKey];
42
+ this.store.write(payload);
43
+ return existed;
44
+ }
45
+ list() {
46
+ const payload = this.readPayload();
47
+ const now = Date.now();
48
+ const locks = Object.values(payload.locks).filter((lock) => !lock.expiresAt || lock.expiresAt > now);
49
+ if (locks.length !== Object.keys(payload.locks).length) {
50
+ payload.locks = Object.fromEntries(locks.map((lock) => [lock.contextKey, lock]));
51
+ this.store.write(payload);
52
+ }
53
+ return locks.sort((left, right) => right.createdAt - left.createdAt);
54
+ }
55
+ readPayload() {
56
+ const payload = this.store.read();
57
+ if (!payload || payload.version !== 1 || !payload.locks || typeof payload.locks !== "object") {
58
+ return { version: 1, locks: {} };
59
+ }
60
+ return {
61
+ version: 1,
62
+ locks: Object.fromEntries(Object.entries(payload.locks).filter(([, lock]) => isSessionLock(lock))),
63
+ };
64
+ }
65
+ }
66
+ export function canWriteWithLock(lock, userId, isAdmin) {
67
+ if (!lock) {
68
+ return true;
69
+ }
70
+ return isAdmin || userId === lock.ownerId;
71
+ }
72
+ function isSessionLock(value) {
73
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
74
+ return false;
75
+ }
76
+ const candidate = value;
77
+ return typeof candidate.contextKey === "string" &&
78
+ Number.isInteger(candidate.ownerId) &&
79
+ typeof candidate.createdAt === "number" &&
80
+ (candidate.expiresAt === undefined || typeof candidate.expiresAt === "number");
81
+ }