@oar-sh/oar 0.9.10 → 0.9.12
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/CHANGELOG.md +440 -1
- package/README.md +156 -972
- package/package.json +13 -13
- package/server/README.md +112 -25
- package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +57 -4
- package/server/claude-worker/claude-session-process.mjs +217 -11
- package/server/claude-worker/claude-session-worker.mjs +26 -1
- package/server/claude-worker/task-output-discovery.mjs +126 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +235 -14
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +8 -0
- package/server/copilot-worker/copilot-shell-output-mirror.mjs +94 -0
- package/server/copilot-worker/copilot-task-settle.mjs +96 -0
- package/server/db-schema.mjs +68 -2
- package/server/grok-worker/acp-client.mjs +2 -1
- package/server/grok-worker/acp-host-services.mjs +46 -6
- package/server/grok-worker/grok-background-tasks.mjs +241 -0
- package/server/grok-worker/grok-sdk-adapter.mjs +3 -1
- package/server/grok-worker/grok-session-worker.mjs +33 -1
- package/server/grok-worker/grok-turn-runner.mjs +11 -0
- package/server/migrations/0013-previews.mjs +130 -0
- package/server/migrations/0014-conversation-groups.mjs +89 -0
- package/server/migrations/0015-conversation-group-position.mjs +76 -0
- package/server/migrations/0016-share-access-log.mjs +110 -0
- package/server/migrations/0017-conversation-history-indexes.mjs +89 -0
- package/server/public/app/about-ui.js +73 -0
- package/server/public/app/about-view.mjs +234 -0
- package/server/public/app/action-confirmations.js +31 -14
- package/server/public/app/agent-exchange-fold-view.mjs +40 -101
- package/server/public/app/api-client.js +264 -16
- package/server/public/app/ask-user-view.js +17 -15
- package/server/public/app/background-tasks-view.mjs +285 -16
- package/server/public/app/bootstrap.js +253 -37
- package/server/public/app/chat-actions-menu-state.mjs +20 -10
- package/server/public/app/claude-background-command-limit-options.mjs +21 -0
- package/server/public/app/context-menu.mjs +90 -6
- package/server/public/app/conversation-group-drag.mjs +191 -0
- package/server/public/app/conversation-group-order.mjs +265 -0
- package/server/public/app/conversation-group-state.mjs +100 -0
- package/server/public/app/conversation-list-drag.mjs +449 -0
- package/server/public/app/conversation-payload-cache.mjs +357 -0
- package/server/public/app/conversation-turn-state.mjs +81 -0
- package/server/public/app/conversation-view.js +567 -94
- package/server/public/app/conversation-window-retention.mjs +96 -0
- package/server/public/app/cwd-picker.js +1 -1
- package/server/public/app/hidden-messages-view.mjs +312 -0
- package/server/public/app/host-suspend-ui.js +7 -1
- package/server/public/app/journal-view.js +1033 -82
- package/server/public/app/preview-cards.mjs +117 -31
- package/server/public/app/preview-command.mjs +10 -1
- package/server/public/app/relay-board-view.js +19 -6
- package/server/public/app/relay-prompt-sanitizer.mjs +11 -4
- package/server/public/app/relay-restart-error.mjs +16 -0
- package/server/public/app/settings-modal.js +150 -10
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/share-format.mjs +49 -0
- package/server/public/app/share-preview-marks.mjs +167 -0
- package/server/public/app/share-state.js +109 -0
- package/server/public/app/shared-conversations-settings.js +480 -0
- package/server/public/app/socket-handlers.js +138 -20
- package/server/public/app/store.js +121 -30
- package/server/public/app/subagent-row.mjs +3 -3
- package/server/public/app/subagent-viewer.mjs +37 -7
- package/server/public/app/task-console-render.mjs +237 -0
- package/server/public/app/task-console-watch.mjs +42 -0
- package/server/public/app/task-console.mjs +630 -0
- package/server/public/app/task-output-text.mjs +234 -0
- package/server/public/app/thinking-follow.js +0 -4
- package/server/public/app/transcript-island.mjs +73 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +230 -26
- package/server/public/app/transcript-jump-bottom.mjs +170 -22
- package/server/public/app/transcript-reading-anchor.mjs +256 -0
- package/server/public/app/transcript-reload-freshness.mjs +17 -1
- package/server/public/app/transcript-viewport-anchor.mjs +159 -0
- package/server/public/index.html +625 -21
- package/server/relay-tools.md +3 -1
- package/server/relay.mjs +2 -1
- package/server/repositories/conversation-group-repository.mjs +444 -0
- package/server/repositories/message-repository.mjs +9 -0
- package/server/repositories/question-repository.mjs +4 -0
- package/server/repositories/session-repository.mjs +12 -37
- package/server/repositories/share-repository.mjs +322 -0
- package/server/repositories/share-visibility-statements.mjs +10 -0
- package/server/routes/about-routes.mjs +15 -0
- package/server/routes/cache-routes.mjs +4 -0
- package/server/routes/conversation-group-routes.mjs +171 -0
- package/server/routes/messages-routes.mjs +433 -68
- package/server/routes/preview-routes.mjs +9 -1
- package/server/routes/sessions-routes.mjs +901 -253
- package/server/server-runtime.mjs +392 -99
- package/server/services/about-service.mjs +461 -0
- package/server/services/background-task-store.mjs +365 -0
- package/server/services/background-work-view.mjs +228 -0
- package/server/services/cache-rebuild-service.mjs +30 -0
- package/server/services/cli-process-runner.mjs +4 -21
- package/server/services/conversation-history-page.mjs +199 -0
- package/server/services/delete-archive-service.mjs +4 -1
- package/server/services/detached-worker-watch-service.mjs +150 -0
- package/server/services/hidden-message-list.mjs +21 -0
- package/server/services/host-suspend-command.mjs +2 -1
- package/server/services/message-pin-list.mjs +1 -1
- package/server/services/preview-health-probe.mjs +8 -1
- package/server/services/preview-proxy-server.mjs +8 -4
- package/server/services/preview-registry-service.mjs +676 -26
- package/server/services/preview-static-handler.mjs +40 -7
- package/server/services/preview-store.mjs +274 -0
- package/server/services/rate-limit-service.mjs +12 -1
- package/server/services/relay-auth.mjs +183 -0
- package/server/services/relay-cli-launcher-service.mjs +3 -1
- package/server/services/relay-event-service.mjs +17 -0
- package/server/services/relay-prompt-sanitizer.mjs +11 -4
- package/server/services/remote-relay-dispatcher.mjs +187 -7
- package/server/services/request-boundary.mjs +183 -0
- package/server/services/session-activity-tracker.mjs +231 -19
- package/server/services/session-worker-launch-service.mjs +190 -6
- package/server/services/session-worker-process-service.mjs +10 -6
- package/server/services/share-access-log-service.mjs +123 -0
- package/server/services/share-path-redaction.mjs +424 -0
- package/server/services/status-event-service.mjs +7 -3
- package/server/services/task-output-rooms.mjs +259 -0
- package/server/services/task-output-service.mjs +562 -0
- package/server/services/tunnel-worker-path-guard.mjs +24 -41
- package/server/services/windows-autostart-service.mjs +6 -1
- package/server/services/windows-boot-autostart-service.mjs +207 -23
- package/shared/claude-background-command-limit.mjs +83 -0
- package/shared/preview-tool-core.mjs +15 -5
- package/shared/remote-relay-contract.mjs +35 -3
- package/shared/share-path-privacy.mjs +30 -0
- package/shared/task-output-text.mjs +737 -0
- package/shared/windows-system-tools.mjs +106 -0
- package/shared/worker-runtime/background-task-digest.mjs +30 -0
- package/shared/worker-runtime/heartbeat.mjs +24 -0
- package/shared/worker-runtime/kept-posts.mjs +83 -0
- package/shared/worker-runtime/task-output-spool.mjs +181 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
|
@@ -9,9 +9,19 @@ import { randomUUID } from 'crypto';
|
|
|
9
9
|
import { createSdkSessionSyncService } from '../services/sdk-session-sync-service.mjs';
|
|
10
10
|
import { serveFileWithRangeSupport } from '../services/file-serving.mjs';
|
|
11
11
|
import { stripRelayPromptContext } from '../services/relay-prompt-sanitizer.mjs';
|
|
12
|
+
import {
|
|
13
|
+
buildConversationHistoryPageInfo,
|
|
14
|
+
normalizeConversationHistoryLimit,
|
|
15
|
+
normalizeConversationTimestampMs,
|
|
16
|
+
} from '../services/conversation-history-page.mjs';
|
|
12
17
|
import { MESSAGE_PIN_LIMIT, buildPinList } from '../services/message-pin-list.mjs';
|
|
18
|
+
import { buildHiddenMessageList } from '../services/hidden-message-list.mjs';
|
|
13
19
|
import { persistConversationPreferences } from '../services/conversation-preferences-service.mjs';
|
|
14
20
|
import { parseAutoCompactWindow } from '../../shared/auto-compact-window.mjs';
|
|
21
|
+
import {
|
|
22
|
+
claudeBackgroundCommandLimitSettingsFields,
|
|
23
|
+
parseClaudeBackgroundCommandLimitUpdate,
|
|
24
|
+
} from '../../shared/claude-background-command-limit.mjs';
|
|
15
25
|
import { CLAUDE_ATTRIBUTION_MODES, DEFAULT_CLAUDE_ATTRIBUTION_MODE, claudeAttributionModelLabel, claudeAttributionTrailer, normalizeClaudeAttributionMode, resolveClaudeAttributionMode } from '../../shared/claude-attribution.mjs';
|
|
16
26
|
import { parseThinkingDisplay, parseThinkingEnabled } from '../../shared/claude-thinking.mjs';
|
|
17
27
|
import { isProviderModelAvailable, resolveProviderModelSelection } from '../services/provider-model-selection.mjs';
|
|
@@ -34,10 +44,19 @@ import {
|
|
|
34
44
|
readWorkspaceRootPathFromBody,
|
|
35
45
|
validateRequestedWorkspaceRoot, normalizeWorkspaceRootKey } from '../services/workspace-root-path-policy.mjs';
|
|
36
46
|
import { workspaceRootDisplayName } from '../workspace-root.mjs';
|
|
47
|
+
import { redactPreviewTokens } from '../services/preview-registry-service.mjs';
|
|
48
|
+
import { createSharePathRedactor } from '../services/share-path-redaction.mjs';
|
|
49
|
+
import { createSubagentTimelineService, TIMELINE_READ_DEFAULT_LIMIT } from '../services/subagent-timeline-service.mjs';
|
|
50
|
+
import { sanitizeSubagentRunId } from '../../shared/subagent-run-id.mjs';
|
|
37
51
|
import { createKeyedMutex } from '../services/keyed-mutex-service.mjs';
|
|
38
52
|
import { createFixedWindowRateLimiter } from '../services/rate-limit-service.mjs';
|
|
53
|
+
import { requestHostOf } from '../services/request-boundary.mjs';
|
|
39
54
|
import { parseRemoteRelayOriginJson } from '../repositories/remote-relay-repository.mjs';
|
|
40
|
-
import {
|
|
55
|
+
import { createShareRepository } from '../repositories/share-repository.mjs';
|
|
56
|
+
import { createShareAccessLog, readShareRequestViewer } from '../services/share-access-log-service.mjs';
|
|
57
|
+
import { resolveGroupDisplayName } from '../repositories/conversation-group-repository.mjs';
|
|
58
|
+
import { conversationGroupPosition, conversationGroupRole, groupActivityTimes, pageConversationRows } from '../public/app/conversation-group-order.mjs';
|
|
59
|
+
import { admitRemoteRelayRequest, publicRemoteRelayOrigin, readRemoteRelayRequest } from '../services/remote-relay-inbound.mjs';
|
|
41
60
|
import {
|
|
42
61
|
RELAUNCH_COALESCE_WINDOW_MS,
|
|
43
62
|
buildRelaunchRequestKey,
|
|
@@ -74,6 +93,12 @@ import {
|
|
|
74
93
|
SUBAGENT_RECORD_RETENTION_CHOICES,
|
|
75
94
|
parseSubagentRecordRetentionUpdate,
|
|
76
95
|
} from '../../shared/subagent-record-retention.mjs';
|
|
96
|
+
import {
|
|
97
|
+
DEFAULT_SHARE_PATH_PRIVACY_MODE,
|
|
98
|
+
SHARE_PATH_PRIVACY_LABELS,
|
|
99
|
+
SHARE_PATH_PRIVACY_MODES,
|
|
100
|
+
parseSharePathPrivacyUpdate,
|
|
101
|
+
} from '../../shared/share-path-privacy.mjs';
|
|
77
102
|
import {
|
|
78
103
|
DEFAULT_BACKGROUND_TASK_TIMEOUT_MINUTES,
|
|
79
104
|
BACKGROUND_TASK_TIMEOUT_MAX_MINUTES,
|
|
@@ -81,6 +106,12 @@ import {
|
|
|
81
106
|
BACKGROUND_TASK_TIMEOUT_STEP_MINUTES,
|
|
82
107
|
parseBackgroundTaskTimeoutUpdate,
|
|
83
108
|
} from '../../shared/background-task-timeout.mjs';
|
|
109
|
+
import {
|
|
110
|
+
DEFAULT_TASK_STALL_MINUTES,
|
|
111
|
+
TASK_STALL_MINUTES_MAX,
|
|
112
|
+
TASK_STALL_MINUTES_MIN,
|
|
113
|
+
parseTaskStallMinutesUpdate,
|
|
114
|
+
} from '../../shared/task-output-text.mjs';
|
|
84
115
|
import {
|
|
85
116
|
PWA_APP_NAME_DEFAULT,
|
|
86
117
|
PWA_APP_NAME_MAX_LENGTH,
|
|
@@ -270,9 +301,16 @@ export function parseClaudeSettingsUpdateRequest(body = {}) {
|
|
|
270
301
|
const hasEnabledModels = Array.isArray(payload.enabledModels);
|
|
271
302
|
const hasAttributionMode = Object.prototype.hasOwnProperty.call(payload, 'attributionMode');
|
|
272
303
|
const attributionMode = hasAttributionMode ? normalizeClaudeAttributionMode(payload.attributionMode) : undefined;
|
|
273
|
-
|
|
304
|
+
const hasBackgroundCommandLimit = Object.prototype.hasOwnProperty.call(payload, 'backgroundCommandLimitMinutes');
|
|
305
|
+
const backgroundCommandLimit = hasBackgroundCommandLimit
|
|
306
|
+
? parseClaudeBackgroundCommandLimitUpdate(payload.backgroundCommandLimitMinutes)
|
|
307
|
+
: null;
|
|
308
|
+
if (!hasEnabled && !hasModel && !hasModels && !hasEnabledModels && !hasAttributionMode && !hasBackgroundCommandLimit) {
|
|
274
309
|
return { ok: false, error: 'No Claude settings update provided' };
|
|
275
310
|
}
|
|
311
|
+
if (backgroundCommandLimit && !backgroundCommandLimit.ok) {
|
|
312
|
+
return { ok: false, error: backgroundCommandLimit.error };
|
|
313
|
+
}
|
|
276
314
|
if (hasModel && !isSafeClaudeModelId(model)) {
|
|
277
315
|
return { ok: false, error: 'Invalid Claude model ID' };
|
|
278
316
|
}
|
|
@@ -286,6 +324,8 @@ export function parseClaudeSettingsUpdateRequest(body = {}) {
|
|
|
286
324
|
...(hasModels ? { models: payload.models } : {}),
|
|
287
325
|
...(hasEnabledModels ? { enabledModels: payload.enabledModels } : {}),
|
|
288
326
|
...(hasAttributionMode ? { attributionMode } : {}),
|
|
327
|
+
// Clamped to 30 min … 8 h and snapped to 30-minute steps.
|
|
328
|
+
...(backgroundCommandLimit ? { backgroundCommandLimitMinutes: backgroundCommandLimit.minutes } : {}),
|
|
289
329
|
};
|
|
290
330
|
}
|
|
291
331
|
|
|
@@ -1488,19 +1528,6 @@ function isLikelyCanonicalDuplicateMessage(a, b, timestampWindowMs = 30_000) {
|
|
|
1488
1528
|
return Math.abs(aTs - bTs) <= Math.max(1_000, Number(timestampWindowMs) || 30_000);
|
|
1489
1529
|
}
|
|
1490
1530
|
|
|
1491
|
-
function normalizeConversationTimestampMs(value) {
|
|
1492
|
-
const text = String(value || '').trim();
|
|
1493
|
-
if (!text) return 0;
|
|
1494
|
-
const sqliteUtcLike = /^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?)$/;
|
|
1495
|
-
const hasExplicitTimezone = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(text);
|
|
1496
|
-
let normalized = text;
|
|
1497
|
-
if (!hasExplicitTimezone && sqliteUtcLike.test(text)) {
|
|
1498
|
-
const [, day, time] = text.match(sqliteUtcLike) || [];
|
|
1499
|
-
normalized = `${day}T${time}Z`;
|
|
1500
|
-
}
|
|
1501
|
-
const parsed = Date.parse(normalized);
|
|
1502
|
-
return Number.isFinite(parsed) ? parsed : 0;
|
|
1503
|
-
}
|
|
1504
1531
|
|
|
1505
1532
|
function normalizeConversationTimestampIso(value, fallback = null) {
|
|
1506
1533
|
const ms = normalizeConversationTimestampMs(value);
|
|
@@ -1547,11 +1574,7 @@ function compareConversationMessageOrder(a, b) {
|
|
|
1547
1574
|
return aId.localeCompare(bId);
|
|
1548
1575
|
}
|
|
1549
1576
|
|
|
1550
|
-
export
|
|
1551
|
-
const numeric = Number(value);
|
|
1552
|
-
if (!Number.isFinite(numeric)) return fallback;
|
|
1553
|
-
return Math.min(100, Math.max(1, Math.trunc(numeric)));
|
|
1554
|
-
}
|
|
1577
|
+
export { normalizeConversationHistoryLimit };
|
|
1555
1578
|
|
|
1556
1579
|
function resolveConversationHistoryCursor(messages = [], {
|
|
1557
1580
|
messageId = '',
|
|
@@ -1656,26 +1679,9 @@ export function selectConversationHistoryPage(messages = [], {
|
|
|
1656
1679
|
}
|
|
1657
1680
|
const hasMoreOlder = firstIdx > 0;
|
|
1658
1681
|
const hasMoreNewer = lastIdx >= 0 && lastIdx < (ordered.length - 1);
|
|
1659
|
-
const oldestMessage = pageMessages[0] || null;
|
|
1660
1682
|
return {
|
|
1661
1683
|
messages: pageMessages,
|
|
1662
|
-
pageInfo: {
|
|
1663
|
-
hasMore: hasMoreOlder,
|
|
1664
|
-
hasMoreOlder,
|
|
1665
|
-
hasMoreNewer,
|
|
1666
|
-
nextCursor: oldestMessage ? {
|
|
1667
|
-
beforeMessageId: String(oldestMessage.id || '').trim(),
|
|
1668
|
-
beforeTimestamp: String(oldestMessage.timestamp || '').trim() || null,
|
|
1669
|
-
} : null,
|
|
1670
|
-
olderCursor: oldestMessage ? {
|
|
1671
|
-
beforeMessageId: String(oldestMessage.id || '').trim(),
|
|
1672
|
-
beforeTimestamp: String(oldestMessage.timestamp || '').trim() || null,
|
|
1673
|
-
} : null,
|
|
1674
|
-
newerCursor: lastMessage ? {
|
|
1675
|
-
afterMessageId: String(lastMessage.id || '').trim(),
|
|
1676
|
-
afterTimestamp: String(lastMessage.timestamp || '').trim() || null,
|
|
1677
|
-
} : null,
|
|
1678
|
-
},
|
|
1684
|
+
pageInfo: buildConversationHistoryPageInfo(pageMessages, { hasMoreOlder, hasMoreNewer }),
|
|
1679
1685
|
};
|
|
1680
1686
|
}
|
|
1681
1687
|
|
|
@@ -1714,80 +1720,25 @@ export function normalizeConversationListLimit(value, fallback = DEFAULT_CONVERS
|
|
|
1714
1720
|
return Math.min(MAX_CONVERSATION_LIST_LIMIT, Math.max(1, Math.trunc(parsed)));
|
|
1715
1721
|
}
|
|
1716
1722
|
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
function resolveConversationListCursor(rows = [], {
|
|
1725
|
-
beforeConversationId = '',
|
|
1726
|
-
beforeUpdatedAt = '',
|
|
1727
|
-
} = {}) {
|
|
1728
|
-
const cursorConversationId = String(beforeConversationId || '').trim();
|
|
1729
|
-
const cursorUpdatedAt = String(beforeUpdatedAt || '').trim();
|
|
1730
|
-
const list = Array.isArray(rows) ? rows : [];
|
|
1731
|
-
|
|
1732
|
-
if (cursorConversationId) {
|
|
1733
|
-
const cursorRow = list.find((row) => String(row?.id || '').trim() === cursorConversationId) || null;
|
|
1734
|
-
if (cursorRow) {
|
|
1735
|
-
return {
|
|
1736
|
-
beforeConversationId: cursorConversationId,
|
|
1737
|
-
beforeUpdatedAt: normalizeConversationTimestampIso(
|
|
1738
|
-
cursorRow.updated_at || cursorRow.updatedAt,
|
|
1739
|
-
String(cursorRow.updated_at || cursorRow.updatedAt || '').trim() || null,
|
|
1740
|
-
),
|
|
1741
|
-
timestampMs: normalizeConversationTimestampMs(cursorRow.updated_at || cursorRow.updatedAt),
|
|
1742
|
-
};
|
|
1743
|
-
}
|
|
1744
|
-
}
|
|
1745
|
-
|
|
1746
|
-
if (cursorUpdatedAt) {
|
|
1747
|
-
return {
|
|
1748
|
-
beforeConversationId: cursorConversationId || null,
|
|
1749
|
-
beforeUpdatedAt: cursorUpdatedAt,
|
|
1750
|
-
timestampMs: normalizeConversationTimestampMs(cursorUpdatedAt),
|
|
1751
|
-
};
|
|
1752
|
-
}
|
|
1753
|
-
|
|
1754
|
-
return null;
|
|
1755
|
-
}
|
|
1756
|
-
|
|
1723
|
+
/**
|
|
1724
|
+
* One page of the conversation list, newest first, with session groups kept
|
|
1725
|
+
* whole: the order and the cursor are those of conversation-group-order.mjs,
|
|
1726
|
+
* which the sidebar uses too. `groups` is the `Map<id, group>` of the rows'
|
|
1727
|
+
* groups (the head of each group is needed for the order inside it).
|
|
1728
|
+
*/
|
|
1757
1729
|
export function selectConversationListPage(rows = [], {
|
|
1758
1730
|
limit = DEFAULT_CONVERSATION_LIST_LIMIT,
|
|
1759
1731
|
beforeConversationId = '',
|
|
1760
1732
|
beforeUpdatedAt = '',
|
|
1733
|
+
beforeGroupId = '',
|
|
1734
|
+
groups = null,
|
|
1761
1735
|
} = {}) {
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
? ordered.filter((row) => {
|
|
1769
|
-
const rowUpdatedAtMs = normalizeConversationTimestampMs(row?.updated_at || row?.updatedAt);
|
|
1770
|
-
if (rowUpdatedAtMs < cursor.timestampMs) return true;
|
|
1771
|
-
if (rowUpdatedAtMs > cursor.timestampMs) return false;
|
|
1772
|
-
if (!cursor.beforeConversationId) return false;
|
|
1773
|
-
return String(row?.id || '').trim().localeCompare(cursor.beforeConversationId) < 0;
|
|
1774
|
-
})
|
|
1775
|
-
: ordered;
|
|
1776
|
-
const pageRows = olderRows.slice(0, pageLimit);
|
|
1777
|
-
const oldestRow = pageRows[pageRows.length - 1] || null;
|
|
1778
|
-
return {
|
|
1779
|
-
rows: pageRows,
|
|
1780
|
-
pageInfo: {
|
|
1781
|
-
hasMore: olderRows.length > pageRows.length,
|
|
1782
|
-
nextCursor: oldestRow ? {
|
|
1783
|
-
beforeConversationId: String(oldestRow.id || '').trim(),
|
|
1784
|
-
beforeUpdatedAt: normalizeConversationTimestampIso(
|
|
1785
|
-
oldestRow.updated_at || oldestRow.updatedAt,
|
|
1786
|
-
String(oldestRow.updated_at || oldestRow.updatedAt || '').trim() || null,
|
|
1787
|
-
),
|
|
1788
|
-
} : null,
|
|
1789
|
-
},
|
|
1790
|
-
};
|
|
1736
|
+
return pageConversationRows(rows, groups, {
|
|
1737
|
+
limit: normalizeConversationListLimit(limit),
|
|
1738
|
+
beforeConversationId,
|
|
1739
|
+
beforeUpdatedAt,
|
|
1740
|
+
beforeGroupId,
|
|
1741
|
+
});
|
|
1791
1742
|
}
|
|
1792
1743
|
|
|
1793
1744
|
export function buildConversationMessages({
|
|
@@ -1801,6 +1752,10 @@ export function buildConversationMessages({
|
|
|
1801
1752
|
responseMessageToSourceId = new Map(),
|
|
1802
1753
|
queueRows = [],
|
|
1803
1754
|
usageByResponseMessageId = new Map(),
|
|
1755
|
+
// When dbMessages is one page: the conversation's rows liveResendsByOriginal
|
|
1756
|
+
// reads (id, role, source_message_id, resend_of_message_id), since a stopped
|
|
1757
|
+
// marker on the page may have its Resend outside it.
|
|
1758
|
+
resendIndexMessages = null,
|
|
1804
1759
|
} = {}) {
|
|
1805
1760
|
const queueRowsById = new Map(
|
|
1806
1761
|
(Array.isArray(queueRows) ? queueRows : [])
|
|
@@ -1809,7 +1764,7 @@ export function buildConversationMessages({
|
|
|
1809
1764
|
);
|
|
1810
1765
|
// A stopped steer's marker says "Resent" from the data, so it holds across
|
|
1811
1766
|
// reloads and devices (the relay refuses a second Resend on the same basis).
|
|
1812
|
-
const liveResends = liveResendsByOriginal(dbMessages, queueRows);
|
|
1767
|
+
const liveResends = liveResendsByOriginal(Array.isArray(resendIndexMessages) ? resendIndexMessages : dbMessages, queueRows);
|
|
1813
1768
|
const normalizedDbMessages = Array.isArray(dbMessages)
|
|
1814
1769
|
? dbMessages.map((message) => {
|
|
1815
1770
|
const id = String(message?.id || '').trim();
|
|
@@ -2039,7 +1994,10 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2039
1994
|
readContextFromSessionEvents,
|
|
2040
1995
|
inFlightStateForConversation,
|
|
2041
1996
|
backgroundTaskStore,
|
|
1997
|
+
backgroundWorkView = null,
|
|
2042
1998
|
listConversationPreviews,
|
|
1999
|
+
listKnownPreviewTokens,
|
|
2000
|
+
closeConversationPreviews,
|
|
2043
2001
|
createCompactedConversation,
|
|
2044
2002
|
collectOrphanedUploadsFromConversation,
|
|
2045
2003
|
deleteOrphanedUploads,
|
|
@@ -2140,6 +2098,9 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2140
2098
|
setGrokPlanAllowanceSettings = () => ({ ok: false, error: 'Grok allowance settings are unavailable' }),
|
|
2141
2099
|
sessionHistoryRefreshService,
|
|
2142
2100
|
sdkSessionImportService,
|
|
2101
|
+
// Session groups (migration 0014): null in older wiring and most tests,
|
|
2102
|
+
// where every conversation is ungrouped.
|
|
2103
|
+
conversationGroups = null,
|
|
2143
2104
|
ensureRuntimeSessionBinding,
|
|
2144
2105
|
bootstrapRuntimeSessionBindings,
|
|
2145
2106
|
configuredConversationSessionMode,
|
|
@@ -2167,12 +2128,17 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2167
2128
|
setTurnCeilingMinutes = () => ({ ok: false, error: 'Turn ceiling settings are unavailable' }),
|
|
2168
2129
|
getSubagentRecordRetentionDays = () => DEFAULT_SUBAGENT_RECORD_RETENTION_DAYS,
|
|
2169
2130
|
setSubagentRecordRetentionDays = () => ({ ok: false, error: 'Sub-agent record settings are unavailable' }),
|
|
2131
|
+
getSharePathPrivacyMode = () => DEFAULT_SHARE_PATH_PRIVACY_MODE,
|
|
2132
|
+
setSharePathPrivacyMode = () => ({ ok: false, error: 'Path privacy settings are unavailable' }),
|
|
2133
|
+
sharePathKnownRoots = () => ({ roots: [], userNames: [] }),
|
|
2170
2134
|
getFeatureFlagsSettingsState = () => [],
|
|
2171
2135
|
setFeatureFlagSetting = () => ({ ok: false, error: 'Feature settings are unavailable' }),
|
|
2172
2136
|
getPwaAppName = () => '',
|
|
2173
2137
|
setPwaAppName = () => ({ ok: false, status: 500, error: 'App name settings are unavailable' }),
|
|
2174
2138
|
getBackgroundTaskTimeoutMinutes = () => DEFAULT_BACKGROUND_TASK_TIMEOUT_MINUTES,
|
|
2175
2139
|
setBackgroundTaskTimeoutMinutes = () => ({ ok: false, error: 'Background task timeout settings are unavailable' }),
|
|
2140
|
+
getTaskStallMinutes = () => DEFAULT_TASK_STALL_MINUTES,
|
|
2141
|
+
setTaskStallMinutes = () => ({ ok: false, error: 'Task stall settings are unavailable' }),
|
|
2176
2142
|
markSharedViewerPresence,
|
|
2177
2143
|
getSharedWatcherCount,
|
|
2178
2144
|
statusEventService,
|
|
@@ -2192,8 +2158,16 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2192
2158
|
// subscriptions that end with an archived or deleted conversation.
|
|
2193
2159
|
relayEventService = null,
|
|
2194
2160
|
sessionActivityTracker = null,
|
|
2161
|
+
// Public shares and their access log (migration 0016); built on `db` when
|
|
2162
|
+
// the wiring does not pass one.
|
|
2163
|
+
shareRepository = null,
|
|
2195
2164
|
} = deps;
|
|
2196
2165
|
const sdkSessionSyncService = createSdkSessionSyncService(db);
|
|
2166
|
+
// Built on first use: many route tests hand in a database without tables.
|
|
2167
|
+
let shareRepositoryInstance = shareRepository;
|
|
2168
|
+
let shareAccessLogInstance = null;
|
|
2169
|
+
const shares = () => (shareRepositoryInstance ||= createShareRepository(db));
|
|
2170
|
+
const shareAccessLog = () => (shareAccessLogInstance ||= createShareAccessLog({ repository: shares() }));
|
|
2197
2171
|
let listRunningRunConversations = null;
|
|
2198
2172
|
try {
|
|
2199
2173
|
listRunningRunConversations = db.prepare(`SELECT DISTINCT conversation_id FROM subagent_runs WHERE status = 'running'`);
|
|
@@ -2272,19 +2246,47 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2272
2246
|
function listConversationPins(conversationId) {
|
|
2273
2247
|
return buildPinList(stmts.listPinnedMessages?.all?.(conversationId) || []);
|
|
2274
2248
|
}
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
// the revision the browser can tell the older list from the newer one. In
|
|
2278
|
-
// memory only: a restart starts above every revision handed out before.
|
|
2279
|
-
const pinRevisionFloor = Date.now();
|
|
2280
|
-
const pinRevisionByConversation = new Map();
|
|
2281
|
-
function conversationPinRevision(conversationId) {
|
|
2282
|
-
return pinRevisionByConversation.get(conversationId) || pinRevisionFloor;
|
|
2249
|
+
function listConversationHiddenMessages(conversationId) {
|
|
2250
|
+
return buildHiddenMessageList(stmts.listHiddenMessages?.all?.(conversationId) || []);
|
|
2283
2251
|
}
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2252
|
+
// Orders the per-conversation lists a browser receives (pins, hidden
|
|
2253
|
+
// messages). A conversation load that left before a change can arrive after
|
|
2254
|
+
// the change's own socket event; with the revision the browser can tell the
|
|
2255
|
+
// older list from the newer one. In memory only: a restart starts above
|
|
2256
|
+
// every revision handed out before.
|
|
2257
|
+
function createConversationListRevisions() {
|
|
2258
|
+
const floor = Date.now();
|
|
2259
|
+
const byConversation = new Map();
|
|
2260
|
+
const current = (conversationId) => byConversation.get(conversationId) || floor;
|
|
2261
|
+
return {
|
|
2262
|
+
current,
|
|
2263
|
+
bump(conversationId) {
|
|
2264
|
+
const next = Math.max(Date.now(), current(conversationId) + 1);
|
|
2265
|
+
byConversation.set(conversationId, next);
|
|
2266
|
+
return next;
|
|
2267
|
+
},
|
|
2268
|
+
};
|
|
2269
|
+
}
|
|
2270
|
+
const pinRevisions = createConversationListRevisions();
|
|
2271
|
+
const conversationPinRevision = pinRevisions.current;
|
|
2272
|
+
const bumpConversationPinRevision = pinRevisions.bump;
|
|
2273
|
+
const hiddenMessageRevisions = createConversationListRevisions();
|
|
2274
|
+
const conversationHiddenMessagesRevision = hiddenMessageRevisions.current;
|
|
2275
|
+
// Every open owner page follows a share-visibility change without a reload:
|
|
2276
|
+
// the 🙈 list and count, the bubbles' Hide / Unhide, and the pin list's
|
|
2277
|
+
// "hidden from shared" chip when a pinned message changed.
|
|
2278
|
+
function broadcastShareVisibilityChange(conversationId, { pinnedChanged = false } = {}) {
|
|
2279
|
+
const hiddenMessages = listConversationHiddenMessages(conversationId);
|
|
2280
|
+
const revision = hiddenMessageRevisions.bump(conversationId);
|
|
2281
|
+
io?.emit?.('conversation_hidden_messages_updated', { conversationId, hiddenMessages, revision });
|
|
2282
|
+
if (pinnedChanged) {
|
|
2283
|
+
io?.emit?.('conversation_pins_updated', {
|
|
2284
|
+
conversationId,
|
|
2285
|
+
pins: listConversationPins(conversationId),
|
|
2286
|
+
revision: bumpConversationPinRevision(conversationId),
|
|
2287
|
+
});
|
|
2288
|
+
}
|
|
2289
|
+
return { hiddenMessages, revision };
|
|
2288
2290
|
}
|
|
2289
2291
|
const listSessionWorkerQueueRows = db.prepare(`
|
|
2290
2292
|
SELECT id, conversation_id, runtime_session_id, owner_sdk_session_id, status
|
|
@@ -2359,6 +2361,8 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2359
2361
|
db.prepare(`DELETE FROM queue WHERE conversation_id = ?`).run(conversationId);
|
|
2360
2362
|
db.prepare(`DELETE FROM messages WHERE conversation_id = ?`).run(conversationId);
|
|
2361
2363
|
db.prepare(`DELETE FROM runtime_sessions WHERE conversation_id = ?`).run(conversationId);
|
|
2364
|
+
// The share access log goes with it; the share rows follow the foreign key.
|
|
2365
|
+
shares().forgetConversation(conversationId);
|
|
2362
2366
|
db.prepare(`DELETE FROM conversations WHERE id = ?`).run(conversationId);
|
|
2363
2367
|
});
|
|
2364
2368
|
const sharedPresenceRateLimiter = createFixedWindowRateLimiter({
|
|
@@ -2402,17 +2406,12 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2402
2406
|
io.emit('models_updated', buildModelCatalogWithProviders(getModelCatalogState()));
|
|
2403
2407
|
});
|
|
2404
2408
|
|
|
2409
|
+
// The client address as Express derives it through `trust proxy`: a
|
|
2410
|
+
// forwarded header counts only when a trusted proxy (by default one on
|
|
2411
|
+
// loopback) sent it, so a direct client cannot pick its own bucket.
|
|
2405
2412
|
function extractClientIp(req) {
|
|
2406
|
-
const
|
|
2407
|
-
|
|
2408
|
-
const firstHop = forwardedForHeader.split(',')[0].trim();
|
|
2409
|
-
const forwardedFor = firstHop.replace(/^for=/i, '').replace(/^"|"$/g, '').trim();
|
|
2410
|
-
if (forwardedFor) return forwardedFor.slice(0, 128);
|
|
2411
|
-
}
|
|
2412
|
-
const realIp = String(req.headers?.['x-real-ip'] || '').trim();
|
|
2413
|
-
if (realIp) return realIp.slice(0, 128);
|
|
2414
|
-
const reqIp = String(req.ip || req.socket?.remoteAddress || '').trim();
|
|
2415
|
-
return reqIp.slice(0, 128) || 'unknown';
|
|
2413
|
+
const ip = String(req.ip || req.socket?.remoteAddress || '').trim();
|
|
2414
|
+
return ip.slice(0, 128) || 'unknown';
|
|
2416
2415
|
}
|
|
2417
2416
|
|
|
2418
2417
|
function consumeSharedPresenceRateLimit(token, req, nowMs = Date.now()) {
|
|
@@ -2484,7 +2483,10 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2484
2483
|
: LIVE_WORKER_STATUSES.has(String(worker.status || '').trim().toLowerCase()));
|
|
2485
2484
|
const processingCount = Number(countProcessingConversationQueueRows.get(conversationId)?.count || 0);
|
|
2486
2485
|
const taskKeys = [...new Set([conversationId, workerSessionId])];
|
|
2487
|
-
|
|
2486
|
+
let liveTasks = workerAlive ? taskKeys.flatMap((key) => backgroundTaskStore?.get?.(key) || []) : [];
|
|
2487
|
+
// No published set, but the worker says it has background work the
|
|
2488
|
+
// relay was never told the details of: that counts, with no Stop button.
|
|
2489
|
+
if (workerAlive && !liveTasks.length && backgroundWorkView) liveTasks = backgroundWorkView.panelTasks(...taskKeys);
|
|
2488
2490
|
return {
|
|
2489
2491
|
turnRunning: processingCount > 0 && (workerAlive || !worker),
|
|
2490
2492
|
backgroundTaskCount: liveTasks.length,
|
|
@@ -2598,15 +2600,98 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2598
2600
|
resolveSessionStateRoot,
|
|
2599
2601
|
});
|
|
2600
2602
|
const orphanedUploads = collectOrphanedUploadsFromConversation(conversationId);
|
|
2603
|
+
const leftGroupId = conversationGroups?.forgetConversation?.(conversationId) || null;
|
|
2601
2604
|
hardDeleteConversationRows(conversationId);
|
|
2602
2605
|
deleteOrphanedUploads(orphanedUploads);
|
|
2603
2606
|
io.emit('conversation_deleted', { conversationId });
|
|
2607
|
+
if (leftGroupId) emitGroupChanged(leftGroupId, [conversationId]);
|
|
2604
2608
|
try { relayEventService?.noteConversationGone?.(conversationId, { deleted: true }); } catch {}
|
|
2605
2609
|
finalized.push(conversationId);
|
|
2606
2610
|
}
|
|
2607
2611
|
return finalized;
|
|
2608
2612
|
}
|
|
2609
2613
|
|
|
2614
|
+
/**
|
|
2615
|
+
* A session group as the list payload carries it: name, display name (the
|
|
2616
|
+
* head's current title when none was given), head and member counts.
|
|
2617
|
+
*/
|
|
2618
|
+
function groupSummaryPayload(group) {
|
|
2619
|
+
if (!group) return null;
|
|
2620
|
+
const members = conversationGroups?.membersOf?.(group.id) || [];
|
|
2621
|
+
const head = group.parentConversationId
|
|
2622
|
+
? members.find((member) => member.id === group.parentConversationId) || null
|
|
2623
|
+
: null;
|
|
2624
|
+
return {
|
|
2625
|
+
id: group.id,
|
|
2626
|
+
name: group.name || null,
|
|
2627
|
+
displayName: resolveGroupDisplayName({
|
|
2628
|
+
name: group.name,
|
|
2629
|
+
headTitle: head ? resolveConversationTitle({ title: head.title, titleSource: 'auto' }) : null,
|
|
2630
|
+
}),
|
|
2631
|
+
parentConversationId: group.parentConversationId || null,
|
|
2632
|
+
memberCount: members.length,
|
|
2633
|
+
liveMemberCount: members.filter((member) => !member.archived).length,
|
|
2634
|
+
};
|
|
2635
|
+
}
|
|
2636
|
+
|
|
2637
|
+
/** The `group` field of a single conversation payload: the summary plus the members. */
|
|
2638
|
+
/**
|
|
2639
|
+
* The `group` field of a single conversation payload: the summary and the
|
|
2640
|
+
* conversation's role. The live members, with what each is doing, only
|
|
2641
|
+
* when asked for (`groupMembers=1`, the remote_relay tool's read_session)
|
|
2642
|
+
* and never for a paired relay's agent: the browser does not read them,
|
|
2643
|
+
* and archived members' titles stay on this relay.
|
|
2644
|
+
*/
|
|
2645
|
+
function conversationGroupPayload(conversationId, req = null) {
|
|
2646
|
+
const group = conversationGroups?.groupOfConversation?.(conversationId) || null;
|
|
2647
|
+
if (!group) return null;
|
|
2648
|
+
const summary = groupSummaryPayload(group);
|
|
2649
|
+
const payload = {
|
|
2650
|
+
...summary,
|
|
2651
|
+
role: conversationId === group.parentConversationId ? 'head' : 'member',
|
|
2652
|
+
};
|
|
2653
|
+
const wantsMembers = String(req?.query?.groupMembers || '').trim() === '1';
|
|
2654
|
+
const request = req ? readRemoteRelayRequest(req, remoteRelayInbound) : { remote: false };
|
|
2655
|
+
if (wantsMembers && (!request.remote || request.local === true)) {
|
|
2656
|
+
payload.members = (conversationGroups.membersOf(group.id) || [])
|
|
2657
|
+
.filter((member) => member.archived !== true)
|
|
2658
|
+
.slice(0, 50)
|
|
2659
|
+
.map((member) => ({
|
|
2660
|
+
id: member.id,
|
|
2661
|
+
title: resolveConversationTitle({ title: member.title, titleSource: 'auto' }),
|
|
2662
|
+
role: member.id === group.parentConversationId ? 'head' : 'member',
|
|
2663
|
+
state: describeSessionActivity(member.id)?.sessionState || 'idle',
|
|
2664
|
+
}));
|
|
2665
|
+
}
|
|
2666
|
+
return payload;
|
|
2667
|
+
}
|
|
2668
|
+
|
|
2669
|
+
/** A conversation row's place in its group's hand order; null outside a group. */
|
|
2670
|
+
function conversationGroupPositionOf(conv) {
|
|
2671
|
+
if (!String(conv?.group_id || '').trim() || !conversationGroups?.getGroup?.(conv.group_id)) return null;
|
|
2672
|
+
return conversationGroupPosition(conv);
|
|
2673
|
+
}
|
|
2674
|
+
|
|
2675
|
+
/**
|
|
2676
|
+
* Tells every page a group changed, with what the page can apply in place:
|
|
2677
|
+
* the group's summary (null once dissolved), the named conversations'
|
|
2678
|
+
* membership, the group's order (head first) and the positions of its
|
|
2679
|
+
* members and of the named conversations.
|
|
2680
|
+
*/
|
|
2681
|
+
function emitGroupChanged(groupId, conversationIds = []) {
|
|
2682
|
+
const ids = [...new Set(conversationIds.map((id) => String(id || '').trim()).filter(Boolean))];
|
|
2683
|
+
const group = groupId ? groupSummaryPayload(conversationGroups?.getGroup?.(groupId) || null) : null;
|
|
2684
|
+
const order = group ? conversationGroups?.orderOf?.(groupId) || [] : [];
|
|
2685
|
+
io.emit('conversation_group_changed', {
|
|
2686
|
+
groupId: groupId || null,
|
|
2687
|
+
conversationIds: ids,
|
|
2688
|
+
group,
|
|
2689
|
+
memberships: conversationGroups?.membershipsOf?.(ids) || {},
|
|
2690
|
+
order,
|
|
2691
|
+
positions: conversationGroups?.positionsOf?.([...order, ...ids]) || {},
|
|
2692
|
+
});
|
|
2693
|
+
}
|
|
2694
|
+
|
|
2610
2695
|
/** The `cloud` field of a conversation payload; null unless it is a cloud one. */
|
|
2611
2696
|
function conversationCloudPayload(conversation, runtimeSession) {
|
|
2612
2697
|
return buildConversationCloudPayload({
|
|
@@ -2630,9 +2715,10 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2630
2715
|
function buildShareUrl(req, token) {
|
|
2631
2716
|
const shareToken = String(token || '').trim();
|
|
2632
2717
|
if (!shareToken) return '';
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
const
|
|
2718
|
+
// Host and scheme follow `trust proxy`: X-Forwarded-Host/-Proto only
|
|
2719
|
+
// from a trusted proxy, so a direct client cannot mint links elsewhere.
|
|
2720
|
+
const host = requestHostOf(req);
|
|
2721
|
+
const protocol = String(req.protocol || 'http').toLowerCase();
|
|
2636
2722
|
const basePath = String(remotePath || '').trim().replace(/\/+$/, '');
|
|
2637
2723
|
const relative = `${basePath}/shared/${shareToken}`.replace(/\/{2,}/g, '/');
|
|
2638
2724
|
if (!host) return relative;
|
|
@@ -2700,6 +2786,45 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2700
2786
|
};
|
|
2701
2787
|
}
|
|
2702
2788
|
|
|
2789
|
+
// One history page selected in SQL (conversation-history-page.mjs), so
|
|
2790
|
+
// only the page is hydrated; null when the statements are absent or the
|
|
2791
|
+
// conversation cannot be paged in SQL, and the caller loads every row.
|
|
2792
|
+
function selectConversationHistoryPageRows(conversationId, cursor = {}, { sharedOnly = false } = {}) {
|
|
2793
|
+
const pager = stmts.conversationHistoryPage;
|
|
2794
|
+
if (!pager || typeof pager.selectConversationHistoryPage !== 'function') return null;
|
|
2795
|
+
return pager.selectConversationHistoryPage(conversationId, { ...cursor, sharedOnly }) || null;
|
|
2796
|
+
}
|
|
2797
|
+
|
|
2798
|
+
// The usage snapshots of a page's replies, keyed by reply id.
|
|
2799
|
+
function usageSnapshotsForPage(conversationId, pageRows = []) {
|
|
2800
|
+
const usage = new Map();
|
|
2801
|
+
if (!stmts.getMessageUsageSnapshotByResponse) return usage;
|
|
2802
|
+
for (const row of pageRows) {
|
|
2803
|
+
if (row?.role !== 'assistant') continue;
|
|
2804
|
+
const id = String(row?.id || '').trim();
|
|
2805
|
+
const snapshot = id ? mapUsageSnapshotRow(stmts.getMessageUsageSnapshotByResponse.get(id, conversationId)) : null;
|
|
2806
|
+
if (snapshot) usage.set(id, snapshot);
|
|
2807
|
+
}
|
|
2808
|
+
return usage;
|
|
2809
|
+
}
|
|
2810
|
+
|
|
2811
|
+
// What liveResendsByOriginal needs beyond a page: only read when the page
|
|
2812
|
+
// holds a stopped marker, the one place its result is shown.
|
|
2813
|
+
function resendIndexForPage(conversationId, pageRows = [], { sharedOnly = false } = {}) {
|
|
2814
|
+
const hasStoppedMarker = pageRows.some((row) => row?.role === 'assistant' && row?.kind === 'stopped');
|
|
2815
|
+
if (!hasStoppedMarker) return [];
|
|
2816
|
+
return stmts.conversationHistoryPage?.listConversationResendIndex?.(conversationId, { sharedOnly }) || [];
|
|
2817
|
+
}
|
|
2818
|
+
|
|
2819
|
+
// A shared attachment is served by its share URL: the host path it is
|
|
2820
|
+
// stored at (and the `@path` reference built from it) stays on the relay.
|
|
2821
|
+
function shareSafeAttachment(attachment, shareToken) {
|
|
2822
|
+
const rewritten = rewriteSharedGeneratedImageAttachmentContentUrl(rewriteSharedAttachmentContentUrl(attachment, shareToken), shareToken);
|
|
2823
|
+
if (!rewritten || typeof rewritten !== 'object') return rewritten;
|
|
2824
|
+
const { path: _path, reference: _reference, ...rest } = rewritten;
|
|
2825
|
+
return rest;
|
|
2826
|
+
}
|
|
2827
|
+
|
|
2703
2828
|
function buildConversationPayloadForShare({
|
|
2704
2829
|
conv,
|
|
2705
2830
|
shareToken = '',
|
|
@@ -2709,6 +2834,9 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2709
2834
|
afterMessageId = '',
|
|
2710
2835
|
afterTimestamp = '',
|
|
2711
2836
|
aroundMessageId = '',
|
|
2837
|
+
// Filled with the tokens of the transcript's preview cards, for the route
|
|
2838
|
+
// to redact wherever a reply or tool line quotes them.
|
|
2839
|
+
previewTokens = null,
|
|
2712
2840
|
} = {}) {
|
|
2713
2841
|
const resolvedConversationId = String(conv?.id || '').trim();
|
|
2714
2842
|
if (!resolvedConversationId) return null;
|
|
@@ -2718,9 +2846,11 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2718
2846
|
});
|
|
2719
2847
|
const inFlight = inFlightStateForConversation(resolvedConversationId);
|
|
2720
2848
|
const sdkSessionId = String(conv.sdk_session_id || resolvedConversationId || '').trim();
|
|
2721
|
-
const
|
|
2722
|
-
|
|
2723
|
-
|
|
2849
|
+
const historyCursor = { limit, beforeMessageId, beforeTimestamp, afterMessageId, afterTimestamp, aroundMessageId };
|
|
2850
|
+
const sqlPage = selectConversationHistoryPageRows(resolvedConversationId, historyCursor, { sharedOnly: true });
|
|
2851
|
+
const dbMessages = filterMessagesVisibleToSharedView(sqlPage
|
|
2852
|
+
? sqlPage.rows
|
|
2853
|
+
: (stmts.getSharedMessages || stmts.getMessages).all(resolvedConversationId));
|
|
2724
2854
|
const queueRows = db.prepare(`
|
|
2725
2855
|
SELECT id, response_message_id, text, timestamp, retry_count, reasoning_effort, model, status
|
|
2726
2856
|
FROM queue
|
|
@@ -2751,12 +2881,38 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2751
2881
|
.filter((m) => m.role === 'assistant')
|
|
2752
2882
|
.map((m) => [m.id, workflowRunsForResponse ? workflowRunsForResponse(m.id) : []]),
|
|
2753
2883
|
);
|
|
2884
|
+
// The page hydrates only its own cards, but the tokens to redact are the
|
|
2885
|
+
// whole conversation's: a reply on this page can quote a card's link from
|
|
2886
|
+
// outside it. The page's cards below add theirs too (a relay without the
|
|
2887
|
+
// statement still redacts what it shows).
|
|
2888
|
+
if (previewTokens) {
|
|
2889
|
+
for (const row of stmts.listPreviewCardJsonByConversation?.all(resolvedConversationId) || []) {
|
|
2890
|
+
try {
|
|
2891
|
+
const token = JSON.parse(String(row?.preview_json || ''))?.token;
|
|
2892
|
+
if (token) previewTokens.add?.(String(token));
|
|
2893
|
+
} catch {
|
|
2894
|
+
// An unreadable snapshot renders no card, so it has no link to quote.
|
|
2895
|
+
}
|
|
2896
|
+
}
|
|
2897
|
+
}
|
|
2898
|
+
// A preview link is a capability that now outlives the turn and the
|
|
2899
|
+
// restart: a public transcript keeps only the label, never token or link.
|
|
2754
2900
|
const previewCardsByMessageId = new Map(
|
|
2755
2901
|
dbMessages
|
|
2756
2902
|
.filter((m) => m.role === 'assistant')
|
|
2757
|
-
.map((m) => [m.id, previewCardsForResponse ? previewCardsForResponse(m.id) : []
|
|
2903
|
+
.map((m) => [m.id, (previewCardsForResponse ? previewCardsForResponse(m.id) : [])
|
|
2904
|
+
.filter((card) => card && typeof card === 'object')
|
|
2905
|
+
.map((card) => {
|
|
2906
|
+
if (card.token) previewTokens?.add?.(String(card.token));
|
|
2907
|
+
return card;
|
|
2908
|
+
})
|
|
2909
|
+
.map((card) => ({
|
|
2910
|
+
label: String(card.label || '').trim().slice(0, 120) || 'Preview',
|
|
2911
|
+
mode: card.mode === 'static' ? 'static' : 'port',
|
|
2912
|
+
redacted: true,
|
|
2913
|
+
}))]),
|
|
2758
2914
|
);
|
|
2759
|
-
const usageByResponseMessageId = new Map(
|
|
2915
|
+
const usageByResponseMessageId = sqlPage ? usageSnapshotsForPage(conv.id, dbMessages) : new Map(
|
|
2760
2916
|
(stmts.listMessageUsageSnapshotsByConversation?.all(conv.id) || [])
|
|
2761
2917
|
.map((row) => [String(row?.response_message_id || '').trim(), mapUsageSnapshotRow(row)])
|
|
2762
2918
|
.filter(([messageId, usage]) => !!messageId && !!usage),
|
|
@@ -2767,7 +2923,7 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2767
2923
|
attachments: parseAttachments(message.attachments)
|
|
2768
2924
|
.map(hydrateAttachment)
|
|
2769
2925
|
.filter(Boolean)
|
|
2770
|
-
.map((attachment) =>
|
|
2926
|
+
.map((attachment) => shareSafeAttachment(attachment, shareToken)),
|
|
2771
2927
|
})),
|
|
2772
2928
|
transcriptMessages: [],
|
|
2773
2929
|
relayActivitiesByMessageId,
|
|
@@ -2778,6 +2934,7 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2778
2934
|
responseMessageToSourceId,
|
|
2779
2935
|
queueRows,
|
|
2780
2936
|
usageByResponseMessageId,
|
|
2937
|
+
resendIndexMessages: sqlPage ? resendIndexForPage(resolvedConversationId, dbMessages, { sharedOnly: true }) : null,
|
|
2781
2938
|
});
|
|
2782
2939
|
messages = messages.map((message) => {
|
|
2783
2940
|
const sourceMessageId = responseMessageToSourceId.get(String(message.id || '').trim()) || message.sourceMessageId || undefined;
|
|
@@ -2789,20 +2946,15 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2789
2946
|
? { ...withSource, origin: publicRemoteRelayOrigin(withSource.origin) }
|
|
2790
2947
|
: withSource;
|
|
2791
2948
|
const attachments = Array.isArray(nextMessage?.attachments)
|
|
2792
|
-
? nextMessage.attachments.map((attachment) =>
|
|
2949
|
+
? nextMessage.attachments.map((attachment) => shareSafeAttachment(attachment, shareToken))
|
|
2793
2950
|
: nextMessage?.attachments;
|
|
2794
2951
|
return attachments === nextMessage?.attachments
|
|
2795
2952
|
? nextMessage
|
|
2796
2953
|
: { ...nextMessage, attachments };
|
|
2797
2954
|
});
|
|
2798
|
-
const history =
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
beforeTimestamp,
|
|
2802
|
-
afterMessageId,
|
|
2803
|
-
afterTimestamp,
|
|
2804
|
-
aroundMessageId,
|
|
2805
|
-
});
|
|
2955
|
+
const history = sqlPage
|
|
2956
|
+
? { messages, pageInfo: sqlPage.pageInfo }
|
|
2957
|
+
: selectConversationHistoryPage(messages, historyCursor);
|
|
2806
2958
|
return {
|
|
2807
2959
|
id: resolvedConversationId,
|
|
2808
2960
|
sdkSessionId: conv.sdk_session_id || null,
|
|
@@ -2811,6 +2963,10 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2811
2963
|
compactedInto: conv.compacted_into || null,
|
|
2812
2964
|
compactedFrom: conv.compacted_from || null,
|
|
2813
2965
|
runtimeSession: null,
|
|
2966
|
+
// The provider alone, null when the conversation has none bound: the
|
|
2967
|
+
// view tells a reply that ran elsewhere from one that ran here by it.
|
|
2968
|
+
// Not private, the model chip names it as well.
|
|
2969
|
+
runtimeProviderType: String(stmts.getRuntimeSessionByConversation?.get?.(resolvedConversationId)?.provider_type || '').trim().toLowerCase() || null,
|
|
2814
2970
|
sessionRootPath: null,
|
|
2815
2971
|
sessionRootName: resolvedTitle || 'Session',
|
|
2816
2972
|
configuredWorkspaceRootPath: null,
|
|
@@ -2883,11 +3039,19 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2883
3039
|
const limit = normalizeConversationListLimit(req.query.limit, DEFAULT_CONVERSATION_LIST_LIMIT);
|
|
2884
3040
|
const beforeConversationId = String(req.query.beforeConversationId || '').trim();
|
|
2885
3041
|
const beforeUpdatedAt = String(req.query.beforeUpdatedAt || '').trim();
|
|
3042
|
+
const beforeGroupId = String(req.query.beforeGroupId || '').trim();
|
|
2886
3043
|
const rows = stmts.listConvs.all(archivedMode === 'true' ? 1 : 0, archivedMode === 'only' ? 1 : 0);
|
|
3044
|
+
// The groups of the listed rows: the order inside a group needs its head.
|
|
3045
|
+
const groups = conversationGroups?.listGroupsByIds?.(rows.map((row) => row?.group_id)) || new Map();
|
|
3046
|
+
// The time a row sorts by (its group's newest row): a caller that pages
|
|
3047
|
+
// by a row that is gone by then continues from this, not the row's own.
|
|
3048
|
+
const activityTimes = groupActivityTimes(rows);
|
|
2887
3049
|
const page = selectConversationListPage(rows, {
|
|
2888
3050
|
limit,
|
|
2889
3051
|
beforeConversationId,
|
|
2890
3052
|
beforeUpdatedAt,
|
|
3053
|
+
beforeGroupId,
|
|
3054
|
+
groups,
|
|
2891
3055
|
});
|
|
2892
3056
|
const activeTurnConversationIds = new Set(
|
|
2893
3057
|
(stmts.listConversationIdsWithActiveQueue?.all() || [])
|
|
@@ -2947,12 +3111,30 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2947
3111
|
cloudSourceJson: r.cloud_source_json,
|
|
2948
3112
|
costUsd: r.runtime_claude_cloud_cost_usd,
|
|
2949
3113
|
}),
|
|
3114
|
+
// Session group: its id and whether this row is the group's head.
|
|
3115
|
+
groupId: groups.has(String(r.group_id || '')) ? String(r.group_id) : null,
|
|
3116
|
+
groupRole: groups.has(String(r.group_id || '')) ? conversationGroupRole({ id: r.id, groupId: r.group_id }, groups) : null,
|
|
3117
|
+
// Its place in the group's hand order; null for the head, for a
|
|
3118
|
+
// group nobody reordered, and outside a group.
|
|
3119
|
+
groupPosition: groups.has(String(r.group_id || '')) ? conversationGroupPosition(r) : null,
|
|
3120
|
+
activityAt: (() => {
|
|
3121
|
+
const own = normalizeConversationTimestampMs(r.updated_at);
|
|
3122
|
+
const ms = groups.has(String(r.group_id || '')) ? Math.max(activityTimes.get(String(r.group_id)) || 0, own) : own;
|
|
3123
|
+
return ms ? new Date(ms).toISOString() : normalizeConversationTimestampIso(r.updated_at, r.updated_at);
|
|
3124
|
+
})(),
|
|
2950
3125
|
};
|
|
2951
3126
|
});
|
|
3127
|
+
// The groups that have a row on this page, with what the sidebar's head
|
|
3128
|
+
// row shows without the members' rows.
|
|
3129
|
+
const groupsPayload = {};
|
|
3130
|
+
for (const groupId of new Set(page.rows.map((row) => String(row?.group_id || '')).filter((id) => groups.has(id)))) {
|
|
3131
|
+
groupsPayload[groupId] = groupSummaryPayload(groups.get(groupId));
|
|
3132
|
+
}
|
|
2952
3133
|
|
|
2953
3134
|
return res.json({
|
|
2954
3135
|
conversations,
|
|
2955
3136
|
pageInfo: page.pageInfo,
|
|
3137
|
+
groups: groupsPayload,
|
|
2956
3138
|
});
|
|
2957
3139
|
});
|
|
2958
3140
|
|
|
@@ -3671,123 +3853,398 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
3671
3853
|
draftUpdatedByClientId: conv.draft_updated_by_client_id || null,
|
|
3672
3854
|
origin: parseRemoteRelayOriginJson(conv.origin_json),
|
|
3673
3855
|
cloud: conversationCloudPayload(conv, runtimeSession),
|
|
3856
|
+
group: conversationGroupPayload(conversationId),
|
|
3857
|
+
groupPosition: conversationGroupPositionOf(conv),
|
|
3674
3858
|
messages: history.messages,
|
|
3675
3859
|
pageInfo: history.pageInfo,
|
|
3676
3860
|
// A re-import can move a pin onto a new message id.
|
|
3677
3861
|
pins: listConversationPins(conversationId),
|
|
3678
3862
|
pinsRevision: conversationPinRevision(conversationId),
|
|
3863
|
+
// And a hidden mark.
|
|
3864
|
+
hiddenMessages: listConversationHiddenMessages(conversationId),
|
|
3865
|
+
hiddenMessagesRevision: conversationHiddenMessagesRevision(conversationId),
|
|
3679
3866
|
refreshed: true,
|
|
3680
3867
|
});
|
|
3681
3868
|
});
|
|
3682
3869
|
|
|
3870
|
+
function shareLinkPayload(req, share) {
|
|
3871
|
+
const token = String(share?.token || '').trim();
|
|
3872
|
+
return {
|
|
3873
|
+
token,
|
|
3874
|
+
shareUrl: buildShareUrl(req, token),
|
|
3875
|
+
path: `${String(remotePath || '').replace(/\/+$/, '')}/shared/${token}`.replace(/\/{2,}/g, '/'),
|
|
3876
|
+
};
|
|
3877
|
+
}
|
|
3878
|
+
|
|
3879
|
+
function findShareableConversation(conversationId) {
|
|
3880
|
+
const conv = conversationId ? stmts.getConvAnyStatus.get(conversationId) || null : null;
|
|
3881
|
+
if (!conv || String(conv.status || '').trim().toLowerCase() === 'deleted') return null;
|
|
3882
|
+
return conv;
|
|
3883
|
+
}
|
|
3884
|
+
|
|
3885
|
+
// Get-or-create: a conversation has one active share, and asking again
|
|
3886
|
+
// returns its link.
|
|
3683
3887
|
app.post('/api/conversation/:id/share', auth, (req, res) => {
|
|
3684
3888
|
const conversationId = String(req.params.id || '').trim();
|
|
3685
3889
|
if (!conversationId) return res.status(400).json({ error: 'Missing conversation id' });
|
|
3686
|
-
|
|
3687
|
-
if (!conv || String(conv.status || '').trim().toLowerCase() === 'deleted') {
|
|
3890
|
+
if (!findShareableConversation(conversationId)) {
|
|
3688
3891
|
return res.status(404).json({ error: 'Conversation not found' });
|
|
3689
3892
|
}
|
|
3690
|
-
|
|
3691
|
-
if (!share) {
|
|
3692
|
-
const now = new Date().toISOString();
|
|
3693
|
-
let attempts = 0;
|
|
3694
|
-
while (!share && attempts < 6) {
|
|
3695
|
-
const token = buildConversationShareToken();
|
|
3696
|
-
try {
|
|
3697
|
-
stmts.insertConversationShare.run(token, conversationId, now, now);
|
|
3698
|
-
share = stmts.getConversationShareByToken.get(token) || null;
|
|
3699
|
-
} catch (error) {
|
|
3700
|
-
if (String(error?.code || '') !== 'SQLITE_CONSTRAINT_PRIMARYKEY') throw error;
|
|
3701
|
-
}
|
|
3702
|
-
attempts += 1;
|
|
3703
|
-
}
|
|
3704
|
-
}
|
|
3893
|
+
const { share, created } = shares().getOrCreateShare(conversationId, buildConversationShareToken, new Date().toISOString());
|
|
3705
3894
|
if (!share?.token) return res.status(500).json({ error: 'Failed to create share token' });
|
|
3706
|
-
|
|
3707
|
-
stmts.touchConversationShare?.run(now, share.token);
|
|
3708
|
-
const token = String(share.token || '').trim();
|
|
3895
|
+
if (created) io.emit('share_created', { conversationId });
|
|
3709
3896
|
res.setHeader('Cache-Control', 'no-store');
|
|
3710
|
-
res.json({
|
|
3711
|
-
|
|
3712
|
-
|
|
3713
|
-
|
|
3714
|
-
|
|
3715
|
-
|
|
3716
|
-
|
|
3897
|
+
res.json({ ok: true, conversationId, created, ...shareLinkPayload(req, share) });
|
|
3898
|
+
});
|
|
3899
|
+
|
|
3900
|
+
// A new link for the active share: the old token answers 404 from now on,
|
|
3901
|
+
// the access log carries over.
|
|
3902
|
+
app.post('/api/conversation/:id/share/renew', auth, (req, res) => {
|
|
3903
|
+
const conversationId = String(req.params.id || '').trim();
|
|
3904
|
+
if (!conversationId) return res.status(400).json({ error: 'Missing conversation id' });
|
|
3905
|
+
if (!findShareableConversation(conversationId)) {
|
|
3906
|
+
return res.status(404).json({ error: 'Conversation not found' });
|
|
3907
|
+
}
|
|
3908
|
+
const renewed = shares().renewShare(conversationId, buildConversationShareToken, new Date().toISOString());
|
|
3909
|
+
if (!renewed?.share?.token) return res.status(404).json({ error: 'No active share to renew' });
|
|
3910
|
+
io.emit('share_renewed', { conversationId });
|
|
3911
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
3912
|
+
res.json({ ok: true, conversationId, renewed: true, ...shareLinkPayload(req, renewed.share) });
|
|
3717
3913
|
});
|
|
3718
3914
|
|
|
3719
|
-
//
|
|
3720
|
-
//
|
|
3721
|
-
// route ever set it), so a leaked URL could never be invalidated.
|
|
3915
|
+
// Stop sharing: every token of the conversation is revoked and its access
|
|
3916
|
+
// log deleted. An open shared page gets a 404 at its next poll.
|
|
3722
3917
|
app.delete('/api/conversation/:id/share', auth, (req, res) => {
|
|
3723
3918
|
const conversationId = String(req.params.id || '').trim();
|
|
3724
3919
|
if (!conversationId) return res.status(400).json({ error: 'Missing conversation id' });
|
|
3725
|
-
|
|
3726
|
-
if (!share?.token) {
|
|
3920
|
+
if (!shares().findActiveShare(conversationId)) {
|
|
3727
3921
|
return res.status(404).json({ error: 'No active share to revoke' });
|
|
3728
3922
|
}
|
|
3729
3923
|
const now = new Date().toISOString();
|
|
3730
|
-
const result =
|
|
3731
|
-
// Drop any live shared viewers so they lose access immediately.
|
|
3924
|
+
const result = shares().stopSharing(conversationId, now);
|
|
3732
3925
|
io.emit('share_revoked', { conversationId });
|
|
3733
3926
|
res.setHeader('Cache-Control', 'no-store');
|
|
3734
|
-
res.json({ ok: true, conversationId, revokedAt: now, revokedCount:
|
|
3927
|
+
res.json({ ok: true, conversationId, revokedAt: now, revokedCount: result.revokedCount, opensDeleted: result.opensDeleted });
|
|
3735
3928
|
});
|
|
3736
3929
|
|
|
3737
|
-
|
|
3738
|
-
|
|
3739
|
-
|
|
3740
|
-
|
|
3741
|
-
|
|
3742
|
-
|
|
3930
|
+
// Every active share, for Settings → Sharing.
|
|
3931
|
+
app.get('/api/shares', auth, (req, res) => {
|
|
3932
|
+
const list = shares().listActiveShares().map((share) => ({
|
|
3933
|
+
conversationId: share.conversationId,
|
|
3934
|
+
title: share.title,
|
|
3935
|
+
archived: share.archived,
|
|
3936
|
+
sharedSince: share.sharedSince,
|
|
3937
|
+
// The current link's date, when it is a renewed one.
|
|
3938
|
+
renewedAt: share.createdAt && share.createdAt !== share.sharedSince ? share.createdAt : null,
|
|
3939
|
+
lastOpenedAt: share.lastOpenedAt,
|
|
3940
|
+
openCount: share.openCount,
|
|
3941
|
+
viewerCount: share.viewerCount,
|
|
3942
|
+
...shareLinkPayload(req, share),
|
|
3943
|
+
}));
|
|
3944
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
3945
|
+
res.json({ shares: list, storeViewerIp: shares().readStoreViewerIp(), accessLog: shares().logAvailable });
|
|
3946
|
+
});
|
|
3947
|
+
|
|
3948
|
+
// The access log of a conversation's active share, newest first.
|
|
3949
|
+
app.get('/api/conversation/:id/share/opens', auth, (req, res) => {
|
|
3950
|
+
const conversationId = String(req.params.id || '').trim();
|
|
3951
|
+
const share = conversationId ? shares().findActiveShare(conversationId) : null;
|
|
3952
|
+
if (!share) return res.status(404).json({ error: 'No active share' });
|
|
3953
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
3954
|
+
res.json({ conversationId, ...shares().listOpens(share.lineageId, { limit: req.query.limit }) });
|
|
3955
|
+
});
|
|
3956
|
+
|
|
3957
|
+
app.get('/api/settings/shares', auth, (req, res) => {
|
|
3958
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
3959
|
+
res.json({ storeViewerIp: shares().readStoreViewerIp() });
|
|
3960
|
+
});
|
|
3961
|
+
|
|
3962
|
+
// "Store viewer IP addresses": off keeps the address out of new access log
|
|
3963
|
+
// rows and out of the status events of shared reads. Rows logged before
|
|
3964
|
+
// keep theirs.
|
|
3965
|
+
app.post('/api/settings/shares', auth, (req, res) => {
|
|
3966
|
+
if (typeof req.body?.storeViewerIp !== 'boolean') {
|
|
3967
|
+
return res.status(400).json({ error: 'storeViewerIp must be true or false' });
|
|
3743
3968
|
}
|
|
3744
|
-
const
|
|
3745
|
-
|
|
3746
|
-
|
|
3747
|
-
|
|
3748
|
-
|
|
3969
|
+
const storeViewerIp = shares().writeStoreViewerIp(req.body.storeViewerIp);
|
|
3970
|
+
io.emit('share_settings_changed', { storeViewerIp });
|
|
3971
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
3972
|
+
res.json({ storeViewerIp });
|
|
3973
|
+
});
|
|
3974
|
+
|
|
3975
|
+
// The viewer addresses stored before IP storage was switched off: how many,
|
|
3976
|
+
// and removing them (Settings → Sharing offers it when the switch goes off).
|
|
3977
|
+
app.get('/api/settings/shares/stored-viewer-ips', auth, (_req, res) => {
|
|
3978
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
3979
|
+
res.json({ count: shares().countStoredViewerIps() });
|
|
3980
|
+
});
|
|
3981
|
+
|
|
3982
|
+
app.delete('/api/settings/shares/stored-viewer-ips', auth, (_req, res) => {
|
|
3983
|
+
const cleared = shares().forgetStoredViewerIps();
|
|
3984
|
+
io.emit('share_viewer_ips_forgotten', cleared);
|
|
3985
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
3986
|
+
res.json({ ok: true, cleared, count: shares().countStoredViewerIps() });
|
|
3987
|
+
});
|
|
3988
|
+
|
|
3989
|
+
// ─── Path privacy for shared views ─────────────────────────────────────────
|
|
3990
|
+
// A share is served through one redactor per response
|
|
3991
|
+
// (services/share-path-redaction.mjs): the relay's own roots, the
|
|
3992
|
+
// conversation's workspace roots and the OS user name are what it knows to
|
|
3993
|
+
// look for. The owner's own routes never pass through it.
|
|
3994
|
+
function sharePathRedactorFor(conv, { annotate = false } = {}) {
|
|
3995
|
+
let known = null;
|
|
3996
|
+
try { known = sharePathKnownRoots?.() || null; } catch { known = null; }
|
|
3997
|
+
const roots = [...(Array.isArray(known?.roots) ? known.roots : [])];
|
|
3998
|
+
const conversationId = String(conv?.id || '').trim();
|
|
3999
|
+
try {
|
|
4000
|
+
const state = typeof resolveConversationWorkspaceState === 'function'
|
|
4001
|
+
? resolveConversationWorkspaceState({
|
|
4002
|
+
conversationId,
|
|
4003
|
+
sdkSessionId: conv?.sdk_session_id || conversationId,
|
|
4004
|
+
discoveredWorkspaceRootPath: '',
|
|
4005
|
+
})
|
|
4006
|
+
: null;
|
|
4007
|
+
roots.push(state?.configuredWorkspaceRootPath, state?.runtimeWorkspaceRootPath, state?.currentWorkspaceRootPath);
|
|
4008
|
+
} catch {}
|
|
4009
|
+
roots.push(...(Array.isArray(workspaceRootAllowList) ? workspaceRootAllowList : []));
|
|
4010
|
+
let mode = DEFAULT_SHARE_PATH_PRIVACY_MODE;
|
|
4011
|
+
try { mode = getSharePathPrivacyMode(); } catch {}
|
|
4012
|
+
return createSharePathRedactor({
|
|
4013
|
+
mode,
|
|
4014
|
+
roots: roots.filter(Boolean),
|
|
4015
|
+
userNames: Array.isArray(known?.userNames) ? known.userNames : [],
|
|
4016
|
+
relayPathPrefix: remotePath,
|
|
4017
|
+
annotate,
|
|
4018
|
+
});
|
|
4019
|
+
}
|
|
4020
|
+
|
|
4021
|
+
// Every preview token a shared response must not carry: the relay's known
|
|
4022
|
+
// previews and the conversation's own. Null when the list cannot be read;
|
|
4023
|
+
// then nothing is shared.
|
|
4024
|
+
function collectSharePreviewTokens(conversationId) {
|
|
4025
|
+
const tokens = new Set();
|
|
4026
|
+
// Every preview of the relay, not only this conversation's: the preview
|
|
4027
|
+
// tool's list covers them all, and a reply may quote any of its tokens.
|
|
4028
|
+
try {
|
|
4029
|
+
for (const knownToken of listKnownPreviewTokens?.() || []) tokens.add(String(knownToken));
|
|
4030
|
+
} catch {
|
|
4031
|
+
return null;
|
|
4032
|
+
}
|
|
4033
|
+
for (const preview of listConversationPreviews?.(conversationId) || []) {
|
|
4034
|
+
if (preview?.token) tokens.add(String(preview.token));
|
|
4035
|
+
}
|
|
4036
|
+
return tokens;
|
|
4037
|
+
}
|
|
4038
|
+
|
|
4039
|
+
function sharedHistoryPageQuery(query = {}) {
|
|
4040
|
+
return {
|
|
4041
|
+
limit: normalizeConversationHistoryLimit(query.limit, 120),
|
|
4042
|
+
beforeMessageId: String(query.beforeMessageId || '').trim(),
|
|
4043
|
+
beforeTimestamp: String(query.beforeTimestamp || '').trim(),
|
|
4044
|
+
afterMessageId: String(query.afterMessageId || '').trim(),
|
|
4045
|
+
afterTimestamp: String(query.afterTimestamp || '').trim(),
|
|
4046
|
+
aroundMessageId: String(query.aroundMessageId || '').trim(),
|
|
4047
|
+
};
|
|
4048
|
+
}
|
|
4049
|
+
|
|
4050
|
+
// A revoked or renewed token is a plain 404, like one that never existed.
|
|
4051
|
+
function resolveActiveShare(rawToken) {
|
|
4052
|
+
const token = normalizeShareToken(rawToken);
|
|
4053
|
+
if (!token) return null;
|
|
4054
|
+
const share = shares().findLiveShare(token);
|
|
4055
|
+
const conversationId = String(share?.conversationId || '').trim();
|
|
4056
|
+
if (!conversationId) return null;
|
|
4057
|
+
return { token, share, conversationId };
|
|
4058
|
+
}
|
|
4059
|
+
|
|
4060
|
+
function resolveShareableConversation(conversationId) {
|
|
4061
|
+
const conv = stmts.getConvAnyStatus.get(conversationId) || null;
|
|
4062
|
+
if (!conv || String(conv.status || '').trim().toLowerCase() === 'deleted') return null;
|
|
4063
|
+
return conv;
|
|
4064
|
+
}
|
|
4065
|
+
|
|
4066
|
+
app.get('/api/shared/:token', (req, res) => {
|
|
4067
|
+
const active = resolveActiveShare(req.params.token);
|
|
4068
|
+
if (!active) return res.status(404).json({ error: 'Shared conversation not found' });
|
|
4069
|
+
const { token, share, conversationId: convId } = active;
|
|
4070
|
+
const conv = resolveShareableConversation(convId);
|
|
4071
|
+
if (!conv) return res.status(404).json({ error: 'Shared conversation not found' });
|
|
4072
|
+
const previewTokens = collectSharePreviewTokens(convId);
|
|
4073
|
+
if (!previewTokens) {
|
|
4074
|
+
res.setHeader('Retry-After', '15');
|
|
4075
|
+
return res.status(503).json({ error: 'Shared conversation is not available right now' });
|
|
3749
4076
|
}
|
|
3750
|
-
const limit = normalizeConversationHistoryLimit(req.query.limit, 120);
|
|
3751
|
-
const beforeMessageId = String(req.query.beforeMessageId || '').trim();
|
|
3752
|
-
const beforeTimestamp = String(req.query.beforeTimestamp || '').trim();
|
|
3753
|
-
const afterMessageId = String(req.query.afterMessageId || '').trim();
|
|
3754
|
-
const afterTimestamp = String(req.query.afterTimestamp || '').trim();
|
|
3755
|
-
const aroundMessageId = String(req.query.aroundMessageId || '').trim();
|
|
3756
4077
|
const payload = buildConversationPayloadForShare({
|
|
3757
4078
|
conv,
|
|
3758
4079
|
shareToken: token,
|
|
3759
|
-
|
|
3760
|
-
|
|
3761
|
-
beforeTimestamp,
|
|
3762
|
-
afterMessageId,
|
|
3763
|
-
afterTimestamp,
|
|
3764
|
-
aroundMessageId,
|
|
4080
|
+
...sharedHistoryPageQuery(req.query),
|
|
4081
|
+
previewTokens,
|
|
3765
4082
|
});
|
|
3766
4083
|
if (!payload) return res.status(404).json({ error: 'Shared conversation not found' });
|
|
4084
|
+
// The access log: one row per open, not per poll (the page's first fetch
|
|
4085
|
+
// says `open=1`; see share-access-log-service.mjs). A failing log never
|
|
4086
|
+
// keeps a reader out.
|
|
4087
|
+
const viewer = readShareRequestViewer(req);
|
|
4088
|
+
const storeIp = shares().readStoreViewerIp();
|
|
4089
|
+
let viewerKey = '';
|
|
4090
|
+
try {
|
|
4091
|
+
const noted = shareAccessLog().noteFetch({ share, viewer, opened: String(req.query.open || '') === '1', storeIp });
|
|
4092
|
+
viewerKey = noted.viewerKey;
|
|
4093
|
+
if (noted.recorded) io.emit('share_opened', { conversationId: convId });
|
|
4094
|
+
} catch (error) {
|
|
4095
|
+
console.warn(`[shares] could not log a shared read: ${error?.message || error}`);
|
|
4096
|
+
}
|
|
3767
4097
|
const sharedAccess = statusEventService.recordSharedAccess({
|
|
3768
4098
|
shareToken: token,
|
|
3769
|
-
viewerIp:
|
|
4099
|
+
viewerIp: storeIp ? viewer.ip : null,
|
|
4100
|
+
viewerKey,
|
|
3770
4101
|
conversationId: convId,
|
|
3771
4102
|
});
|
|
3772
4103
|
if (sharedAccess.event) {
|
|
3773
4104
|
const details = sharedAccess.event.details;
|
|
3774
|
-
console.log(`SHARED ACCESS shareId=${details.shareId} ip=${details.viewerIp || 'unknown'}`);
|
|
4105
|
+
console.log(`SHARED ACCESS shareId=${details.shareId} ip=${storeIp ? details.viewerIp || 'unknown' : 'not stored'}`);
|
|
3775
4106
|
io.emit('shared_access', sharedAccess.event);
|
|
3776
4107
|
}
|
|
3777
4108
|
const now = new Date().toISOString();
|
|
3778
4109
|
stmts.touchConversationShare?.run(now, token);
|
|
3779
4110
|
res.setHeader('Cache-Control', 'no-store');
|
|
3780
4111
|
res.setHeader('X-Robots-Tag', 'noindex, nofollow, noarchive');
|
|
3781
|
-
|
|
4112
|
+
// A preview link is a public capability that outlives the turn: no string
|
|
4113
|
+
// of a shared transcript (reply text, thoughts, tool lines, sub-agent runs)
|
|
4114
|
+
// carries one. One pass over the whole response, not a list of fields;
|
|
4115
|
+
// then the host's paths, by the relay's path privacy setting.
|
|
4116
|
+
const redactor = sharePathRedactorFor(conv);
|
|
4117
|
+
res.json(redactor.payload(redactPreviewTokens({
|
|
3782
4118
|
...payload,
|
|
3783
4119
|
shared: {
|
|
3784
4120
|
token,
|
|
3785
4121
|
readOnly: true,
|
|
3786
4122
|
watcherCount: Number(getSharedWatcherCount?.(convId) || 0),
|
|
3787
4123
|
},
|
|
4124
|
+
}, { tokens: [...previewTokens] })));
|
|
4125
|
+
});
|
|
4126
|
+
|
|
4127
|
+
// The owner's "Preview as shared": the payload a share link would serve, with
|
|
4128
|
+
// every redaction and every leftover that still looks like a path marked
|
|
4129
|
+
// (SHARE_PREVIEW_MARKS). Works without a share link and never creates one;
|
|
4130
|
+
// records no access. Content URLs stay the owner's.
|
|
4131
|
+
app.get('/api/conversation/:id/share-preview', auth, (req, res) => {
|
|
4132
|
+
const conversationId = String(req.params.id || '').trim();
|
|
4133
|
+
const conv = conversationId ? resolveShareableConversation(conversationId) : null;
|
|
4134
|
+
if (!conv) return res.status(404).json({ error: 'Conversation not found' });
|
|
4135
|
+
const previewTokens = collectSharePreviewTokens(conversationId);
|
|
4136
|
+
if (!previewTokens) {
|
|
4137
|
+
res.setHeader('Retry-After', '15');
|
|
4138
|
+
return res.status(503).json({ error: 'The preview is not available right now' });
|
|
4139
|
+
}
|
|
4140
|
+
const payload = buildConversationPayloadForShare({
|
|
4141
|
+
conv,
|
|
4142
|
+
shareToken: '',
|
|
4143
|
+
...sharedHistoryPageQuery(req.query),
|
|
4144
|
+
previewTokens,
|
|
4145
|
+
});
|
|
4146
|
+
if (!payload) return res.status(404).json({ error: 'Conversation not found' });
|
|
4147
|
+
const redactor = sharePathRedactorFor(conv, { annotate: true });
|
|
4148
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
4149
|
+
res.json({
|
|
4150
|
+
...redactor.payload(redactPreviewTokens(payload, { tokens: [...previewTokens] })),
|
|
4151
|
+
shared: {
|
|
4152
|
+
token: null,
|
|
4153
|
+
readOnly: true,
|
|
4154
|
+
preview: true,
|
|
4155
|
+
pathPrivacy: redactor.mode,
|
|
4156
|
+
watcherCount: 0,
|
|
4157
|
+
},
|
|
3788
4158
|
});
|
|
3789
4159
|
});
|
|
3790
4160
|
|
|
4161
|
+
// ─── Sub-agent records in a shared view ────────────────────────────────────
|
|
4162
|
+
const sharedSubagentTimeline = createSubagentTimelineService({ db, stmts });
|
|
4163
|
+
|
|
4164
|
+
// The runs a shared view shows a row for: those of the replies it shows,
|
|
4165
|
+
// and those of the turn in flight.
|
|
4166
|
+
function sharedConversationRunIds(conversationId) {
|
|
4167
|
+
const ids = new Set();
|
|
4168
|
+
const add = (run) => {
|
|
4169
|
+
const id = String(run?.subagentRunId || run?.id || '').trim();
|
|
4170
|
+
if (id) ids.add(id);
|
|
4171
|
+
};
|
|
4172
|
+
const rows = filterMessagesVisibleToSharedView(
|
|
4173
|
+
(stmts.getSharedMessages || stmts.getMessages).all(conversationId),
|
|
4174
|
+
);
|
|
4175
|
+
for (const row of rows) {
|
|
4176
|
+
if (row?.role !== 'assistant') continue;
|
|
4177
|
+
for (const run of (subagentRunsForResponse ? subagentRunsForResponse(row.id) : []) || []) add(run);
|
|
4178
|
+
}
|
|
4179
|
+
for (const run of inFlightStateForConversation(conversationId)?.subagentRuns || []) add(run);
|
|
4180
|
+
return ids;
|
|
4181
|
+
}
|
|
4182
|
+
|
|
4183
|
+
// A run of the conversation whose row the shared view shows, or a child of
|
|
4184
|
+
// one: a share never opens a run of another conversation or of a reply the
|
|
4185
|
+
// owner hid from shares.
|
|
4186
|
+
function shareMayReadSubagentRun(conversationId, run) {
|
|
4187
|
+
if (!run || String(run.conversation_id || '') !== conversationId) return false;
|
|
4188
|
+
const visible = sharedConversationRunIds(conversationId);
|
|
4189
|
+
let current = run;
|
|
4190
|
+
for (let depth = 0; current && depth < 16; depth += 1) {
|
|
4191
|
+
if (visible.has(String(current.id || ''))) return true;
|
|
4192
|
+
const parentId = String(current.parent_subagent_id || '').trim();
|
|
4193
|
+
if (!parentId) return false;
|
|
4194
|
+
current = stmts.getSubagentRun?.get?.(parentId) || null;
|
|
4195
|
+
if (current && String(current.conversation_id || '') !== conversationId) return false;
|
|
4196
|
+
}
|
|
4197
|
+
return false;
|
|
4198
|
+
}
|
|
4199
|
+
|
|
4200
|
+
// A reader cannot stop a run: no stop flag, no task handle.
|
|
4201
|
+
function readerRunHeader(header) {
|
|
4202
|
+
if (!header || typeof header !== 'object') return header;
|
|
4203
|
+
const { taskId: _taskId, ...rest } = header;
|
|
4204
|
+
return { ...rest, stoppable: false };
|
|
4205
|
+
}
|
|
4206
|
+
|
|
4207
|
+
function sharedSubagentRecord(req, res, { conv, conversationId, annotate }) {
|
|
4208
|
+
const runId = sanitizeSubagentRunId(req.params.subagentRunId);
|
|
4209
|
+
const run = runId ? stmts.getSubagentRun?.get?.(runId) || null : null;
|
|
4210
|
+
if (!run || !shareMayReadSubagentRun(conversationId, run)) {
|
|
4211
|
+
return res.status(404).json({ error: 'Sub-agent run not found' });
|
|
4212
|
+
}
|
|
4213
|
+
const previewTokens = collectSharePreviewTokens(conversationId);
|
|
4214
|
+
if (!previewTokens) {
|
|
4215
|
+
res.setHeader('Retry-After', '15');
|
|
4216
|
+
return res.status(503).json({ error: 'Shared conversation is not available right now' });
|
|
4217
|
+
}
|
|
4218
|
+
const page = sharedSubagentTimeline.readTimeline(run, {
|
|
4219
|
+
afterSeq: Number(req.query?.after) || 0,
|
|
4220
|
+
limit: Number(req.query?.limit) || TIMELINE_READ_DEFAULT_LIMIT,
|
|
4221
|
+
isStoppable: () => false,
|
|
4222
|
+
});
|
|
4223
|
+
const readerPage = {
|
|
4224
|
+
...page,
|
|
4225
|
+
run: readerRunHeader(page.run),
|
|
4226
|
+
children: (Array.isArray(page.children) ? page.children : []).map(readerRunHeader),
|
|
4227
|
+
};
|
|
4228
|
+
const redactor = sharePathRedactorFor(conv, { annotate });
|
|
4229
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
4230
|
+
res.setHeader('X-Robots-Tag', 'noindex, nofollow, noarchive');
|
|
4231
|
+
return res.json(redactor.payload(redactPreviewTokens(readerPage, { tokens: [...previewTokens] })));
|
|
4232
|
+
}
|
|
4233
|
+
|
|
4234
|
+
app.get('/api/shared/:token/subagent/:subagentRunId', (req, res) => {
|
|
4235
|
+
const active = resolveActiveShare(req.params.token);
|
|
4236
|
+
const conv = active ? resolveShareableConversation(active.conversationId) : null;
|
|
4237
|
+
if (!conv) return res.status(404).json({ error: 'Sub-agent run not found' });
|
|
4238
|
+
return sharedSubagentRecord(req, res, { conv, conversationId: active.conversationId, annotate: false });
|
|
4239
|
+
});
|
|
4240
|
+
|
|
4241
|
+
app.get('/api/conversation/:id/share-preview/subagent/:subagentRunId', auth, (req, res) => {
|
|
4242
|
+
const conversationId = String(req.params.id || '').trim();
|
|
4243
|
+
const conv = conversationId ? resolveShareableConversation(conversationId) : null;
|
|
4244
|
+
if (!conv) return res.status(404).json({ error: 'Sub-agent run not found' });
|
|
4245
|
+
return sharedSubagentRecord(req, res, { conv, conversationId, annotate: true });
|
|
4246
|
+
});
|
|
4247
|
+
|
|
3791
4248
|
app.get('/api/status/events', auth, (req, res) => {
|
|
3792
4249
|
const beforeTimestamp = Number(req.query.beforeTimestamp);
|
|
3793
4250
|
const page = statusEventService.getEventsPage({
|
|
@@ -3802,12 +4259,9 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
3802
4259
|
app.post('/api/shared/:token/presence', (req, res) => {
|
|
3803
4260
|
const token = normalizeShareToken(req.params.token);
|
|
3804
4261
|
if (!token) return res.status(404).json({ error: 'Shared conversation not found' });
|
|
3805
|
-
const share =
|
|
3806
|
-
if (!share
|
|
3807
|
-
|
|
3808
|
-
}
|
|
3809
|
-
const conversationId = String(share.conversation_id || '').trim();
|
|
3810
|
-
if (!conversationId) return res.status(404).json({ error: 'Shared conversation not found' });
|
|
4262
|
+
const share = shares().findLiveShare(token);
|
|
4263
|
+
if (!share) return res.status(404).json({ error: 'Shared conversation not found' });
|
|
4264
|
+
const conversationId = share.conversationId;
|
|
3811
4265
|
const rateLimit = consumeSharedPresenceRateLimit(token, req);
|
|
3812
4266
|
if (!rateLimit.ok) {
|
|
3813
4267
|
res.setHeader('Cache-Control', 'no-store');
|
|
@@ -3835,17 +4289,24 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
3835
4289
|
});
|
|
3836
4290
|
});
|
|
3837
4291
|
|
|
4292
|
+
// The download name of a shared file: with names hidden, only its type.
|
|
4293
|
+
function sharedDownloadName(fileName) {
|
|
4294
|
+
let mode = DEFAULT_SHARE_PATH_PRIVACY_MODE;
|
|
4295
|
+
try { mode = getSharePathPrivacyMode(); } catch {}
|
|
4296
|
+
if (mode !== 'hide' || !fileName) return fileName;
|
|
4297
|
+
const extension = path.extname(fileName).replace(/[^.A-Za-z0-9]/g, '').slice(0, 12);
|
|
4298
|
+
return `attachment${extension}`;
|
|
4299
|
+
}
|
|
4300
|
+
|
|
3838
4301
|
app.get('/api/shared/:token/upload/:sha256/content', (req, res) => {
|
|
3839
4302
|
const token = normalizeShareToken(req.params.token);
|
|
3840
4303
|
const sha256 = String(req.params.sha256 || '').trim().toLowerCase();
|
|
3841
4304
|
if (!token || !isSha256(sha256)) {
|
|
3842
4305
|
return res.status(404).json({ error: 'Shared attachment not found' });
|
|
3843
4306
|
}
|
|
3844
|
-
const share =
|
|
3845
|
-
if (!share
|
|
3846
|
-
|
|
3847
|
-
}
|
|
3848
|
-
const conversationId = String(share.conversation_id || '').trim();
|
|
4307
|
+
const share = shares().findLiveShare(token);
|
|
4308
|
+
if (!share) return res.status(404).json({ error: 'Shared attachment not found' });
|
|
4309
|
+
const conversationId = share.conversationId;
|
|
3849
4310
|
if (!conversationId || !conversationReferencesUploadSha(conversationId, sha256)) {
|
|
3850
4311
|
return res.status(404).json({ error: 'Shared attachment not found' });
|
|
3851
4312
|
}
|
|
@@ -3858,7 +4319,7 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
3858
4319
|
// as script on this origin; ranges let a shared video seek.
|
|
3859
4320
|
res.setHeader('X-Robots-Tag', 'noindex, nofollow, noarchive');
|
|
3860
4321
|
serveFileWithRangeSupport(req, res, filePath, { contentType: file.mime_type }, {
|
|
3861
|
-
safeName: String(file.name || file.file_name || '').replace(/"/g, ''),
|
|
4322
|
+
safeName: sharedDownloadName(String(file.name || file.file_name || '').replace(/"/g, '')),
|
|
3862
4323
|
cacheControl: SHARED_CONTENT_CACHE_CONTROL,
|
|
3863
4324
|
});
|
|
3864
4325
|
});
|
|
@@ -3870,11 +4331,9 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
3870
4331
|
if (!token || !messageId || !imageId) {
|
|
3871
4332
|
return res.status(404).json({ error: 'Shared attachment not found' });
|
|
3872
4333
|
}
|
|
3873
|
-
const share =
|
|
3874
|
-
if (!share
|
|
3875
|
-
|
|
3876
|
-
}
|
|
3877
|
-
const conversationId = String(share.conversation_id || '').trim();
|
|
4334
|
+
const share = shares().findLiveShare(token);
|
|
4335
|
+
if (!share) return res.status(404).json({ error: 'Shared attachment not found' });
|
|
4336
|
+
const conversationId = share.conversationId;
|
|
3878
4337
|
if (!conversationId) return res.status(404).json({ error: 'Shared attachment not found' });
|
|
3879
4338
|
const rows = filterMessagesVisibleToSharedView(
|
|
3880
4339
|
(stmts.getSharedMessages || stmts.getMessages).all(conversationId),
|
|
@@ -3956,7 +4415,10 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
3956
4415
|
resolveClaudeSessionRoot,
|
|
3957
4416
|
resolveCursorSessionRoot,
|
|
3958
4417
|
});
|
|
3959
|
-
const
|
|
4418
|
+
const historyCursor = { limit, beforeMessageId, beforeTimestamp, afterMessageId, afterTimestamp, aroundMessageId };
|
|
4419
|
+
// The page's rows only: every later lookup is per page, not per conversation.
|
|
4420
|
+
const sqlPage = selectConversationHistoryPageRows(resolvedConversationId, historyCursor);
|
|
4421
|
+
const dbMessages = sqlPage ? sqlPage.rows : stmts.getMessages.all(resolvedConversationId);
|
|
3960
4422
|
const queueRows = db.prepare(`
|
|
3961
4423
|
SELECT id, response_message_id, text, timestamp, retry_count, reasoning_effort, model, status
|
|
3962
4424
|
FROM queue
|
|
@@ -3992,7 +4454,7 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
3992
4454
|
.filter((m) => m.role === 'assistant')
|
|
3993
4455
|
.map((m) => [m.id, previewCardsForResponse ? previewCardsForResponse(m.id) : []]),
|
|
3994
4456
|
);
|
|
3995
|
-
const usageByResponseMessageId = new Map(
|
|
4457
|
+
const usageByResponseMessageId = sqlPage ? usageSnapshotsForPage(conv.id, dbMessages) : new Map(
|
|
3996
4458
|
(stmts.listMessageUsageSnapshotsByConversation?.all(conv.id) || [])
|
|
3997
4459
|
.map((row) => [String(row?.response_message_id || '').trim(), mapUsageSnapshotRow(row)])
|
|
3998
4460
|
.filter(([messageId, usage]) => !!messageId && !!usage),
|
|
@@ -4011,20 +4473,16 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
4011
4473
|
responseMessageToSourceId,
|
|
4012
4474
|
queueRows,
|
|
4013
4475
|
usageByResponseMessageId,
|
|
4476
|
+
resendIndexMessages: sqlPage ? resendIndexForPage(resolvedConversationId, dbMessages, { sharedOnly: false }) : null,
|
|
4014
4477
|
});
|
|
4015
4478
|
messages = messages.map((message) => {
|
|
4016
4479
|
if (message.role !== 'assistant') return message;
|
|
4017
4480
|
const sourceMessageId = responseMessageToSourceId.get(String(message.id || '').trim()) || message.sourceMessageId || undefined;
|
|
4018
4481
|
return sourceMessageId ? { ...message, sourceMessageId } : message;
|
|
4019
4482
|
});
|
|
4020
|
-
const history =
|
|
4021
|
-
|
|
4022
|
-
|
|
4023
|
-
beforeTimestamp,
|
|
4024
|
-
afterMessageId,
|
|
4025
|
-
afterTimestamp,
|
|
4026
|
-
aroundMessageId,
|
|
4027
|
-
});
|
|
4483
|
+
const history = sqlPage
|
|
4484
|
+
? { messages, pageInfo: sqlPage.pageInfo }
|
|
4485
|
+
: selectConversationHistoryPage(messages, historyCursor);
|
|
4028
4486
|
res.json({
|
|
4029
4487
|
id: conv.id,
|
|
4030
4488
|
sdkSessionId: conv.sdk_session_id || null,
|
|
@@ -4060,12 +4518,30 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
4060
4518
|
// "cancelled" with it.
|
|
4061
4519
|
activeTurn: Number(stmts.getConversationActiveQueueCount?.get?.(resolvedConversationId)?.count || 0) > 0,
|
|
4062
4520
|
...(describeSessionActivity(resolvedConversationId) || {}),
|
|
4063
|
-
|
|
4521
|
+
// The published set, else the placeholder for background work a live
|
|
4522
|
+
// worker reports without this runtime knowing its details.
|
|
4523
|
+
// Each with the health poller's last reading (`health`); the tasks
|
|
4524
|
+
// that ended within the hour with a console beside them.
|
|
4525
|
+
backgroundTasks: (() => {
|
|
4526
|
+
const taskKeys = [resolvedConversationId, runtimeSession?.sdk_session_id];
|
|
4527
|
+
const tasks = backgroundWorkView
|
|
4528
|
+
? backgroundWorkView.panelTasks(...taskKeys)
|
|
4529
|
+
: (backgroundTaskStore?.get?.(resolvedConversationId) || []);
|
|
4530
|
+
return backgroundTaskStore?.withHealth ? backgroundTaskStore.withHealth(tasks, ...taskKeys) : tasks;
|
|
4531
|
+
})(),
|
|
4532
|
+
endedBackgroundTasks: backgroundTaskStore?.ended?.(resolvedConversationId, runtimeSession?.sdk_session_id) || [],
|
|
4533
|
+
// Minutes without output after which the panel calls a task quiet.
|
|
4534
|
+
taskStallMinutes: (() => {
|
|
4535
|
+
try { return getTaskStallMinutes(); } catch { return DEFAULT_TASK_STALL_MINUTES; }
|
|
4536
|
+
})(),
|
|
4064
4537
|
usageLimitPause: usageLimitPauseService?.getPause?.(resolvedConversationId) || null,
|
|
4065
4538
|
previews: listConversationPreviews?.(resolvedConversationId) || [],
|
|
4066
4539
|
// Every pin of the conversation, also those outside this history page.
|
|
4067
4540
|
pins: listConversationPins(resolvedConversationId),
|
|
4068
4541
|
pinsRevision: conversationPinRevision(resolvedConversationId),
|
|
4542
|
+
// Every message hidden from shared viewers, also those outside the page.
|
|
4543
|
+
hiddenMessages: listConversationHiddenMessages(resolvedConversationId),
|
|
4544
|
+
hiddenMessagesRevision: conversationHiddenMessagesRevision(resolvedConversationId),
|
|
4069
4545
|
preferredRelayMode: preferences.preferredRelayMode,
|
|
4070
4546
|
preferredModel: preferences.preferredModel,
|
|
4071
4547
|
preferredReasoningEffort: preferences.preferredReasoningEffort,
|
|
@@ -4077,6 +4553,10 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
4077
4553
|
origin: parseRemoteRelayOriginJson(conv.origin_json),
|
|
4078
4554
|
// Set on Claude Cloud conversations: repository, branch, session link.
|
|
4079
4555
|
cloud: conversationCloudPayload(conv, runtimeSession),
|
|
4556
|
+
// The session group it is in, with the other members; null when none.
|
|
4557
|
+
group: conversationGroupPayload(resolvedConversationId, req),
|
|
4558
|
+
// Its place in the group's hand order, as on the list rows.
|
|
4559
|
+
groupPosition: conversationGroupPositionOf(conv),
|
|
4080
4560
|
messages: history.messages,
|
|
4081
4561
|
pageInfo: history.pageInfo,
|
|
4082
4562
|
});
|
|
@@ -4104,6 +4584,7 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
4104
4584
|
|
|
4105
4585
|
const hiddenFromShares = req.body.hiddenFromShares === true;
|
|
4106
4586
|
const currentlyHidden = Number(message.hidden_from_shares || 0) === 1;
|
|
4587
|
+
let change = null;
|
|
4107
4588
|
if (currentlyHidden !== hiddenFromShares) {
|
|
4108
4589
|
if (!stmts.setMessageShareVisibility) {
|
|
4109
4590
|
return res.status(503).json({ error: 'Message share visibility is unavailable until the relay restarts' });
|
|
@@ -4115,6 +4596,7 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
4115
4596
|
messageId,
|
|
4116
4597
|
conversationId,
|
|
4117
4598
|
);
|
|
4599
|
+
change = broadcastShareVisibilityChange(conversationId, { pinnedChanged: !!message.pinned_at });
|
|
4118
4600
|
}
|
|
4119
4601
|
|
|
4120
4602
|
return res.json({
|
|
@@ -4122,6 +4604,52 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
4122
4604
|
conversationId,
|
|
4123
4605
|
messageId,
|
|
4124
4606
|
hiddenFromShares,
|
|
4607
|
+
hiddenMessages: change?.hiddenMessages || listConversationHiddenMessages(conversationId),
|
|
4608
|
+
hiddenMessagesRevision: change?.revision || conversationHiddenMessagesRevision(conversationId),
|
|
4609
|
+
});
|
|
4610
|
+
});
|
|
4611
|
+
|
|
4612
|
+
// Shows every hidden message to shared viewers again. A message whose turn
|
|
4613
|
+
// is still running stays hidden (the single route refuses it the same way)
|
|
4614
|
+
// and is named in `skipped`; the rest are unhidden.
|
|
4615
|
+
app.post('/api/conversation/:id/share-visibility/unhide-all', auth, (req, res) => {
|
|
4616
|
+
const conversationId = String(req.params.id || '').trim();
|
|
4617
|
+
if (!conversationId) return res.status(400).json({ error: 'Missing conversation id' });
|
|
4618
|
+
const conv = stmts.getConvAnyStatus.get(conversationId);
|
|
4619
|
+
if (!conv || String(conv.status || '').trim().toLowerCase() === 'deleted') {
|
|
4620
|
+
return res.status(404).json({ error: 'Conversation not found' });
|
|
4621
|
+
}
|
|
4622
|
+
const hiddenRows = stmts.listHiddenMessages?.all?.(conversationId) || [];
|
|
4623
|
+
if (hiddenRows.length && !stmts.setMessageShareVisibility) {
|
|
4624
|
+
return res.status(503).json({ error: 'Message share visibility is unavailable until the relay restarts' });
|
|
4625
|
+
}
|
|
4626
|
+
const unhidden = [];
|
|
4627
|
+
const skipped = [];
|
|
4628
|
+
const unhideRows = db.transaction((rows) => {
|
|
4629
|
+
for (const row of rows) {
|
|
4630
|
+
const messageId = String(row?.id || '').trim();
|
|
4631
|
+
if (!messageId) continue;
|
|
4632
|
+
if (getActiveQueueForMessage.get(conversationId, messageId, messageId)) {
|
|
4633
|
+
skipped.push({ messageId, reason: 'active-turn' });
|
|
4634
|
+
continue;
|
|
4635
|
+
}
|
|
4636
|
+
stmts.setMessageShareVisibility.run(0, null, messageId, conversationId);
|
|
4637
|
+
unhidden.push(messageId);
|
|
4638
|
+
}
|
|
4639
|
+
});
|
|
4640
|
+
unhideRows(hiddenRows);
|
|
4641
|
+
let change = null;
|
|
4642
|
+
if (unhidden.length) {
|
|
4643
|
+
const pinnedChanged = unhidden.some((messageId) => !!stmts.getMessageByConversation?.get(messageId, conversationId)?.pinned_at);
|
|
4644
|
+
change = broadcastShareVisibilityChange(conversationId, { pinnedChanged });
|
|
4645
|
+
}
|
|
4646
|
+
return res.json({
|
|
4647
|
+
ok: true,
|
|
4648
|
+
conversationId,
|
|
4649
|
+
unhidden,
|
|
4650
|
+
skipped,
|
|
4651
|
+
hiddenMessages: change?.hiddenMessages || listConversationHiddenMessages(conversationId),
|
|
4652
|
+
hiddenMessagesRevision: change?.revision || conversationHiddenMessagesRevision(conversationId),
|
|
4125
4653
|
});
|
|
4126
4654
|
});
|
|
4127
4655
|
|
|
@@ -4828,7 +5356,9 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
4828
5356
|
// Re-armed after the stop, which can outlast the kill block's grace
|
|
4829
5357
|
// window: nothing may respawn a worker for the rows deleted below.
|
|
4830
5358
|
sessionWorkerSupervisor?.markKilled?.(workerSessionId);
|
|
4831
|
-
|
|
5359
|
+
try { sessionActivityTracker?.forget?.(id); } catch {}
|
|
5360
|
+
if (typeof backgroundTaskStore?.forget === 'function') backgroundTaskStore.forget(id, workerSessionId);
|
|
5361
|
+
else for (const key of new Set([id, workerSessionId])) backgroundTaskStore?.replace?.(key, []);
|
|
4832
5362
|
}
|
|
4833
5363
|
|
|
4834
5364
|
// A Claude Cloud conversation takes its cloud session along: archived
|
|
@@ -4843,6 +5373,13 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
4843
5373
|
|
|
4844
5374
|
const deletedAt = new Date().toISOString();
|
|
4845
5375
|
markConversationDeleted.run(deletedAt, id);
|
|
5376
|
+
// A deleted conversation takes its public preview links with it (archive
|
|
5377
|
+
// keeps them: an archived conversation can come back).
|
|
5378
|
+
try {
|
|
5379
|
+
closeConversationPreviews?.(id);
|
|
5380
|
+
} catch (error) {
|
|
5381
|
+
console.warn(`[preview] closing the previews of a deleted conversation failed: ${error?.message || error}`);
|
|
5382
|
+
}
|
|
4846
5383
|
// Tombstones keep the SDK session importer from bringing it back.
|
|
4847
5384
|
stmts.markDeletedSdkSession.run(sdkSessionId || id, deletedAt);
|
|
4848
5385
|
if (sdkSessionId && sdkSessionId !== id) stmts.markDeletedSdkSession.run(id, deletedAt);
|
|
@@ -4865,11 +5402,17 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
4865
5402
|
resolveSessionStateRoot,
|
|
4866
5403
|
});
|
|
4867
5404
|
const orphanedUploads = collectOrphanedUploadsFromConversation(id);
|
|
5405
|
+
// Before the row goes: a head leaves its group with its title frozen as
|
|
5406
|
+
// the name, and a group left empty is removed.
|
|
5407
|
+
const leftGroupId = conversationGroups?.forgetConversation?.(id, deletedAt) || null;
|
|
5408
|
+
const wasShared = !!shares().findActiveShare(id);
|
|
4868
5409
|
hardDeleteConversationRows(id);
|
|
4869
5410
|
remoteRelayInbound?.repository?.forgetConversation?.(id);
|
|
4870
5411
|
deleteOrphanedUploads(orphanedUploads);
|
|
4871
5412
|
if (sdkSessionId && !cliSession.queued) stmts.deleteSdkDeleteRequest?.run?.(sdkSessionId);
|
|
4872
5413
|
io.emit('conversation_deleted', { conversationId: id });
|
|
5414
|
+
if (wasShared) io.emit('share_revoked', { conversationId: id });
|
|
5415
|
+
if (leftGroupId) emitGroupChanged(leftGroupId, [id]);
|
|
4873
5416
|
try { relayEventService?.noteConversationGone?.(id, { deleted: true }); } catch {}
|
|
4874
5417
|
return { statusCode: 200, body: { ok: true, workerStopped, cliSessionDeleted: cliSession.deleted } };
|
|
4875
5418
|
}
|
|
@@ -4884,14 +5427,24 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
4884
5427
|
const id = String(req.params.id || '').trim();
|
|
4885
5428
|
if (!id) return res.status(400).json({ error: 'Missing conversation id' });
|
|
4886
5429
|
try {
|
|
5430
|
+
const outcome = await archiveConversationNow(id);
|
|
5431
|
+
return res.status(outcome.statusCode).json(outcome.body);
|
|
5432
|
+
} catch (error) {
|
|
5433
|
+
console.warn(`[archive] Archive failed for ${id}: ${error?.message || error}`);
|
|
5434
|
+
return res.status(500).json({ error: 'Failed to archive conversation' });
|
|
5435
|
+
}
|
|
5436
|
+
});
|
|
5437
|
+
|
|
5438
|
+
/** POST /api/conversation/:id/archive under its session's lock; see the route. */
|
|
5439
|
+
async function archiveConversationNow(id) {
|
|
4887
5440
|
const existing = stmts.getConvAnyStatus.get(id);
|
|
4888
5441
|
if (!existing || String(existing.status || '').trim() === 'deleted') {
|
|
4889
|
-
return
|
|
5442
|
+
return { statusCode: 200, body: { ok: true, alreadyDeleted: true } };
|
|
4890
5443
|
}
|
|
4891
|
-
if (Number(existing.archived || 0) === 1) return
|
|
5444
|
+
if (Number(existing.archived || 0) === 1) return { statusCode: 200, body: { ok: true, alreadyArchived: true } };
|
|
4892
5445
|
const sdkSessionId = String(existing.sdk_session_id || '').trim() || null;
|
|
4893
5446
|
const workerSessionId = sdkSessionId || id;
|
|
4894
|
-
|
|
5447
|
+
return sessionLaunchMutex.runExclusive(workerSessionId, async () => {
|
|
4895
5448
|
const activity = describeConversationActivity(id, workerSessionId);
|
|
4896
5449
|
if (activity.turnRunning) {
|
|
4897
5450
|
return {
|
|
@@ -4965,11 +5518,41 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
4965
5518
|
}
|
|
4966
5519
|
return { statusCode: 200, body: { ok: true, workerStopped } };
|
|
4967
5520
|
});
|
|
4968
|
-
|
|
5521
|
+
}
|
|
5522
|
+
|
|
5523
|
+
// POST /api/conversation-groups/:id/archive — archive every live member of
|
|
5524
|
+
// a session group that can be archived now. A member with a running turn
|
|
5525
|
+
// or background work is skipped and named in the answer, so the owner can
|
|
5526
|
+
// stop it ("Stop all running") and archive again.
|
|
5527
|
+
app.post('/api/conversation-groups/:id/archive', auth, async (req, res) => {
|
|
5528
|
+
if (readRemoteRelayRequest(req, remoteRelayInbound).remote) {
|
|
5529
|
+
return res.status(403).json({ error: 'Session groups are managed by this relay\'s owner only.' });
|
|
5530
|
+
}
|
|
5531
|
+
const groupId = String(req.params.id || '').trim();
|
|
5532
|
+
const group = groupId ? conversationGroups?.getGroup?.(groupId) : null;
|
|
5533
|
+
if (!group) return res.status(404).json({ ok: false, error: 'Group not found' });
|
|
5534
|
+
const archived = [];
|
|
5535
|
+
const skipped = [];
|
|
5536
|
+
try {
|
|
5537
|
+
for (const member of conversationGroups.membersOf(groupId)) {
|
|
5538
|
+
if (member.archived) continue;
|
|
5539
|
+
const outcome = await archiveConversationNow(member.id);
|
|
5540
|
+
if (outcome.statusCode === 200) {
|
|
5541
|
+
archived.push(member.id);
|
|
5542
|
+
} else {
|
|
5543
|
+
skipped.push({
|
|
5544
|
+
id: member.id,
|
|
5545
|
+
title: resolveConversationTitle({ title: member.title, titleSource: 'auto' }),
|
|
5546
|
+
reason: String(outcome.body?.reason || outcome.body?.error || 'refused'),
|
|
5547
|
+
});
|
|
5548
|
+
}
|
|
5549
|
+
}
|
|
4969
5550
|
} catch (error) {
|
|
4970
|
-
console.warn(`[archive]
|
|
4971
|
-
return res.status(500).json({ error: 'Failed to archive
|
|
5551
|
+
console.warn(`[archive] Group archive failed for ${groupId}: ${error?.message || error}`);
|
|
5552
|
+
return res.status(500).json({ ok: false, error: 'Failed to archive the group', archived, skipped });
|
|
4972
5553
|
}
|
|
5554
|
+
if (archived.length) emitGroupChanged(groupId, archived);
|
|
5555
|
+
return res.json({ ok: true, archived, skipped });
|
|
4973
5556
|
});
|
|
4974
5557
|
|
|
4975
5558
|
// POST /api/conversation/:id/unarchive — back to the live list. A Claude
|
|
@@ -5355,9 +5938,16 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
5355
5938
|
// One transaction so a conversation can never exist without its runtime
|
|
5356
5939
|
// binding and preferences: every later reader treats these three as a
|
|
5357
5940
|
// single canonical tuple.
|
|
5941
|
+
let attachedGroupId = null;
|
|
5358
5942
|
const commitBootstrapState = db.transaction(() => {
|
|
5359
5943
|
stmts.insertConv.run(conversationId, title, now, now);
|
|
5360
5944
|
if (remoteRequest.origin) remoteRelayInbound?.repository?.setConversationOrigin?.(conversationId, remoteRequest.origin);
|
|
5945
|
+
// A session an agent of this relay created joins its creator's group
|
|
5946
|
+
// (or starts one with the creator as head). A creator on a paired
|
|
5947
|
+
// relay has no local flag and groups nothing.
|
|
5948
|
+
if (remoteRequest.origin?.local === true && remoteRequest.origin.conversationId) {
|
|
5949
|
+
attachedGroupId = conversationGroups?.attachCreatedConversation?.(conversationId, remoteRequest.origin.conversationId, now) || null;
|
|
5950
|
+
}
|
|
5361
5951
|
const boundRuntimeSession = ensureRuntimeSessionBinding(
|
|
5362
5952
|
conversationId,
|
|
5363
5953
|
selectedModel,
|
|
@@ -5386,6 +5976,7 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
5386
5976
|
return { boundRuntimeSession, preferences };
|
|
5387
5977
|
});
|
|
5388
5978
|
const { boundRuntimeSession, preferences } = commitBootstrapState();
|
|
5979
|
+
if (attachedGroupId) emitGroupChanged(attachedGroupId, [conversationId, remoteRequest.origin.conversationId]);
|
|
5389
5980
|
const runtimeSession = boundRuntimeSession;
|
|
5390
5981
|
const preferredRelayMode = preferences?.preferredRelayMode || bootstrapRelayMode;
|
|
5391
5982
|
const preferredModel = preferences?.preferredModel || selectedModel;
|
|
@@ -5748,6 +6339,7 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
5748
6339
|
models: Array.isArray(settings?.models) ? settings.models : [],
|
|
5749
6340
|
availableModels: Array.isArray(settings?.availableModels) ? settings.availableModels : [],
|
|
5750
6341
|
...claudeAttributionSettingsFields(settings),
|
|
6342
|
+
...claudeBackgroundCommandLimitSettingsFields(settings),
|
|
5751
6343
|
});
|
|
5752
6344
|
});
|
|
5753
6345
|
|
|
@@ -5797,6 +6389,7 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
5797
6389
|
models: Array.isArray(currentSettings?.models) ? currentSettings.models : [],
|
|
5798
6390
|
availableModels: Array.isArray(currentSettings?.availableModels) ? currentSettings.availableModels : [],
|
|
5799
6391
|
...claudeAttributionSettingsFields(currentSettings),
|
|
6392
|
+
...claudeBackgroundCommandLimitSettingsFields(currentSettings),
|
|
5800
6393
|
reconciliation,
|
|
5801
6394
|
};
|
|
5802
6395
|
io.emit('models_updated', buildModelCatalogWithProviders(getModelCatalogState()));
|
|
@@ -6489,6 +7082,25 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
6489
7082
|
return res.json(subagentRecordRetentionPayload(result.retentionDays));
|
|
6490
7083
|
});
|
|
6491
7084
|
|
|
7085
|
+
// What a shared conversation shows of the host's paths and file names.
|
|
7086
|
+
const sharePathPrivacyPayload = (mode) => ({
|
|
7087
|
+
mode,
|
|
7088
|
+
choices: SHARE_PATH_PRIVACY_MODES.map((value) => ({ value, label: SHARE_PATH_PRIVACY_LABELS[value] })),
|
|
7089
|
+
defaultMode: DEFAULT_SHARE_PATH_PRIVACY_MODE,
|
|
7090
|
+
});
|
|
7091
|
+
|
|
7092
|
+
app.get('/api/settings/share-path-privacy', auth, (_req, res) => {
|
|
7093
|
+
res.json(sharePathPrivacyPayload(getSharePathPrivacyMode()));
|
|
7094
|
+
});
|
|
7095
|
+
|
|
7096
|
+
app.post('/api/settings/share-path-privacy', auth, (req, res) => {
|
|
7097
|
+
const parsed = parseSharePathPrivacyUpdate(req.body?.mode);
|
|
7098
|
+
if (!parsed.ok) return res.status(400).json({ error: parsed.error });
|
|
7099
|
+
const result = setSharePathPrivacyMode(parsed.mode);
|
|
7100
|
+
if (!result.ok) return res.status(500).json({ error: result.error });
|
|
7101
|
+
return res.json(sharePathPrivacyPayload(result.mode));
|
|
7102
|
+
});
|
|
7103
|
+
|
|
6492
7104
|
app.get('/api/settings/background-task-timeout', auth, (_req, res) => {
|
|
6493
7105
|
res.json({
|
|
6494
7106
|
timeoutMinutes: getBackgroundTaskTimeoutMinutes(),
|
|
@@ -6513,6 +7125,26 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
6513
7125
|
});
|
|
6514
7126
|
});
|
|
6515
7127
|
|
|
7128
|
+
// "Mark a background task as quiet after N minutes" (task_stall_minutes).
|
|
7129
|
+
const taskStallMinutesPayload = (minutes) => ({
|
|
7130
|
+
minutes,
|
|
7131
|
+
min: TASK_STALL_MINUTES_MIN,
|
|
7132
|
+
max: TASK_STALL_MINUTES_MAX,
|
|
7133
|
+
default: DEFAULT_TASK_STALL_MINUTES,
|
|
7134
|
+
});
|
|
7135
|
+
|
|
7136
|
+
app.get('/api/settings/task-stall-minutes', auth, (_req, res) => {
|
|
7137
|
+
res.json(taskStallMinutesPayload(getTaskStallMinutes()));
|
|
7138
|
+
});
|
|
7139
|
+
|
|
7140
|
+
app.post('/api/settings/task-stall-minutes', auth, (req, res) => {
|
|
7141
|
+
const parsed = parseTaskStallMinutesUpdate(req.body?.minutes);
|
|
7142
|
+
if (!parsed.ok) return res.status(400).json({ error: parsed.error });
|
|
7143
|
+
const result = setTaskStallMinutes(parsed.minutes);
|
|
7144
|
+
if (!result.ok) return res.status(500).json({ error: result.error });
|
|
7145
|
+
return res.json(taskStallMinutesPayload(result.minutes));
|
|
7146
|
+
});
|
|
7147
|
+
|
|
6516
7148
|
const pwaAppNamePayload = (appName) => ({
|
|
6517
7149
|
appName,
|
|
6518
7150
|
defaultName: PWA_APP_NAME_DEFAULT,
|
|
@@ -6566,9 +7198,18 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
6566
7198
|
*/
|
|
6567
7199
|
async function windowsAutostartMergedState() {
|
|
6568
7200
|
const signin = windowsAutostartService.getState();
|
|
6569
|
-
|
|
6570
|
-
|
|
6571
|
-
|
|
7201
|
+
// A boot task that cannot be read is reported (and logged), not allowed
|
|
7202
|
+
// to fail the whole setting; the sign-in half above still does, since
|
|
7203
|
+
// "off" would be a guess there.
|
|
7204
|
+
let boot = { supported: false };
|
|
7205
|
+
if (windowsBootAutostartService) {
|
|
7206
|
+
try {
|
|
7207
|
+
boot = await windowsBootAutostartService.getState();
|
|
7208
|
+
} catch (error) {
|
|
7209
|
+
console.warn(`[autostart] could not read the boot task: ${error?.message || error}`);
|
|
7210
|
+
boot = { supported: true, taskStatus: 'unknown', taskName: null, legacyTaskPresent: false, readError: 'The boot task could not be read; the relay log has the details.' };
|
|
7211
|
+
}
|
|
7212
|
+
}
|
|
6572
7213
|
const bootReady = boot.supported && boot.taskStatus === 'ready';
|
|
6573
7214
|
const mode = bootReady ? 'boot' : (signin.enabled ? 'signin' : 'off');
|
|
6574
7215
|
return {
|
|
@@ -6582,9 +7223,13 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
6582
7223
|
taskStatus: boot.taskStatus,
|
|
6583
7224
|
taskName: boot.taskName,
|
|
6584
7225
|
legacyTaskPresent: boot.legacyTaskPresent,
|
|
6585
|
-
|
|
6586
|
-
|
|
6587
|
-
|
|
7226
|
+
customised: boot.customised === true,
|
|
7227
|
+
customisations: Array.isArray(boot.customisations) ? boot.customisations : [],
|
|
7228
|
+
launcherBackupPath: boot.launcherBackupPath || null,
|
|
7229
|
+
readError: boot.readError || null,
|
|
7230
|
+
pendingElevation: boot.pendingElevation || null,
|
|
7231
|
+
lastError: boot.lastError || null,
|
|
7232
|
+
manualCommand: boot.manualCommand || null,
|
|
6588
7233
|
} : null,
|
|
6589
7234
|
signinEnabled: signin.enabled,
|
|
6590
7235
|
};
|
|
@@ -6596,7 +7241,9 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
6596
7241
|
}
|
|
6597
7242
|
try {
|
|
6598
7243
|
return res.json(await windowsAutostartMergedState());
|
|
6599
|
-
} catch {
|
|
7244
|
+
} catch (error) {
|
|
7245
|
+
// The details stay in the log: they name folders on the host.
|
|
7246
|
+
console.warn(`[autostart] could not read the Windows autostart setting: ${error?.message || error}`);
|
|
6600
7247
|
return res.status(500).json({
|
|
6601
7248
|
error: 'Unable to read Windows autostart. Check access to your user Startup folder.',
|
|
6602
7249
|
});
|
|
@@ -6646,7 +7293,8 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
6646
7293
|
}
|
|
6647
7294
|
windowsAutostartService.setEnabled(mode === 'signin');
|
|
6648
7295
|
return res.json({ ...(await windowsAutostartMergedState()), accepted });
|
|
6649
|
-
} catch {
|
|
7296
|
+
} catch (error) {
|
|
7297
|
+
console.warn(`[autostart] could not change the Windows autostart setting to ${mode}: ${error?.message || error}`);
|
|
6650
7298
|
return res.status(500).json({
|
|
6651
7299
|
error: 'Unable to update Windows autostart. Check access to your user Startup folder.',
|
|
6652
7300
|
});
|