@oar-sh/oar 0.9.9 → 0.9.11
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 +561 -1
- package/README.md +162 -909
- package/bin/bat/oar.bat +46 -46
- package/package.json +14 -14
- package/server/README.md +369 -13
- package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
- package/server/claude-worker/claude-session-process.mjs +121 -9
- package/server/claude-worker/claude-session-worker.mjs +9 -1
- package/server/claude-worker/claude-turn-publisher.mjs +3 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +31 -4
- package/server/claude-worker/subagent-timeline-recorder.mjs +572 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +79 -12
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +6 -0
- package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
- package/server/db-schema.mjs +95 -2
- package/server/grok-worker/acp-client.mjs +2 -1
- package/server/grok-worker/acp-host-services.mjs +4 -3
- package/server/migrations/0009-subagent-timeline.mjs +135 -0
- package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
- package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
- package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -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/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 +187 -0
- package/server/public/app/api-client.js +312 -16
- package/server/public/app/archive-confirm.mjs +28 -0
- package/server/public/app/ask-user-view.js +17 -12
- package/server/public/app/attachments-view.js +516 -6
- package/server/public/app/background-tasks-view.mjs +18 -26
- package/server/public/app/bootstrap.js +336 -59
- package/server/public/app/chat-actions-menu-state.mjs +126 -0
- package/server/public/app/claude-cloud-conversation-ui.js +10 -22
- package/server/public/app/composer-control-state.mjs +16 -0
- package/server/public/app/composer-keyboard.mjs +54 -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-turn-state.mjs +81 -0
- package/server/public/app/conversation-view.js +436 -117
- package/server/public/app/cwd-picker.js +1 -1
- package/server/public/app/explorer-edit-state.mjs +68 -0
- package/server/public/app/hidden-messages-view.mjs +312 -0
- package/server/public/app/host-suspend-ui.js +13 -4
- package/server/public/app/journal-view.js +930 -45
- package/server/public/app/no-conversation-state.mjs +58 -0
- package/server/public/app/preview-cards.mjs +117 -31
- package/server/public/app/preview-command.mjs +10 -1
- package/server/public/app/provider-readiness.mjs +120 -0
- package/server/public/app/relay-board-view.js +19 -3
- package/server/public/app/relay-event-subscriptions-ui.js +184 -0
- package/server/public/app/relay-event-view.mjs +234 -0
- package/server/public/app/relay-restart-error.mjs +16 -0
- package/server/public/app/settings-modal.js +93 -9
- 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 +137 -40
- package/server/public/app/store.js +115 -32
- package/server/public/app/subagent-row.mjs +138 -0
- package/server/public/app/subagent-timeline-view.mjs +186 -0
- package/server/public/app/subagent-viewer-watch.mjs +50 -0
- package/server/public/app/subagent-viewer.mjs +558 -0
- package/server/public/app/task-row-format.mjs +29 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +464 -0
- package/server/public/app/transcript-jump-bottom.mjs +210 -0
- package/server/public/app/transcript-reload-freshness.mjs +25 -0
- package/server/public/app/transcript-viewport-anchor.mjs +140 -0
- package/server/public/app/update-ui.js +1 -1
- package/server/public/app/viewer-history.mjs +5 -3
- package/server/public/index.html +774 -12
- package/server/relay-tools.md +2 -1
- package/server/relay.mjs +2 -1
- package/server/repositories/conversation-group-repository.mjs +444 -0
- package/server/repositories/message-repository.mjs +30 -3
- package/server/repositories/question-repository.mjs +9 -3
- package/server/repositories/remote-relay-repository.mjs +6 -2
- 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/repositories/subagent-timeline-statements.mjs +170 -0
- package/server/routes/about-routes.mjs +15 -0
- package/server/routes/ask-user-routes.mjs +3 -0
- package/server/routes/cache-routes.mjs +4 -0
- package/server/routes/conversation-group-routes.mjs +171 -0
- package/server/routes/messages-routes.mjs +698 -114
- package/server/routes/preview-routes.mjs +9 -1
- package/server/routes/relay-event-routes.mjs +157 -0
- package/server/routes/remote-relay-routes.mjs +15 -0
- package/server/routes/sessions-routes.mjs +843 -193
- package/server/server-runtime.mjs +559 -160
- package/server/services/about-service.mjs +461 -0
- package/server/services/background-task-store.mjs +128 -0
- package/server/services/background-work-view.mjs +228 -0
- package/server/services/cache-rebuild-service.mjs +30 -0
- package/server/services/delete-archive-service.mjs +4 -1
- package/server/services/detached-worker-watch-service.mjs +150 -0
- package/server/services/explorer-entry-name.mjs +26 -0
- package/server/services/explorer-mutations.mjs +122 -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 +49 -27
- 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/push-dispatch-service.mjs +18 -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-link-service.mjs +289 -0
- package/server/services/relay-event-service.mjs +1952 -0
- package/server/services/relay-shutdown-gate.mjs +31 -0
- package/server/services/remote-relay-caller-context.mjs +52 -7
- package/server/services/remote-relay-dispatcher.mjs +551 -7
- package/server/services/remote-relay-registry-service.mjs +11 -1
- package/server/services/request-boundary.mjs +183 -0
- package/server/services/session-activity-tracker.mjs +695 -0
- package/server/services/session-history-refresh-service.mjs +7 -0
- 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/stale-attempt-log-limiter.mjs +96 -0
- package/server/services/stale-recovery-clock-guard.mjs +57 -0
- package/server/services/stale-subagent-run-sweep.mjs +109 -0
- package/server/services/status-event-service.mjs +7 -3
- package/server/services/subagent-record-retention.mjs +164 -0
- package/server/services/subagent-timeline-service.mjs +328 -0
- package/server/services/subagent-viewer-rooms.mjs +119 -0
- package/server/services/tunnel-worker-path-guard.mjs +24 -41
- package/server/services/upgrade-socket-guard.mjs +16 -0
- package/server/services/windows-autostart-service.mjs +6 -1
- package/server/services/windows-boot-autostart-service.mjs +207 -23
- package/shared/preview-tool-core.mjs +15 -5
- package/shared/relay-events.mjs +371 -0
- package/shared/remote-relay-contract.mjs +160 -6
- package/shared/share-path-privacy.mjs +30 -0
- package/shared/subagent-record-retention.mjs +31 -0
- package/shared/subagent-timeline.mjs +79 -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/worker-websocket-link.mjs +47 -15
|
@@ -24,6 +24,16 @@ export function createSessionRepository(db) {
|
|
|
24
24
|
? 'rs.claude_cloud_cost_usd AS runtime_claude_cloud_cost_usd'
|
|
25
25
|
: 'NULL AS runtime_claude_cloud_cost_usd',
|
|
26
26
|
].join(', ');
|
|
27
|
+
// Session groups (migration 0014). NULL on a schema that predates it.
|
|
28
|
+
const conversationsHaveGroup = db.prepare(`PRAGMA table_info(conversations)`).all()
|
|
29
|
+
.some((column) => String(column?.name || '').trim() === 'group_id');
|
|
30
|
+
// The hand order inside a group (migration 0015).
|
|
31
|
+
const conversationsHaveGroupPosition = db.prepare(`PRAGMA table_info(conversations)`).all()
|
|
32
|
+
.some((column) => String(column?.name || '').trim() === 'group_position');
|
|
33
|
+
const conversationGroupSelect = [
|
|
34
|
+
conversationsHaveGroup ? 'c.group_id' : 'NULL AS group_id',
|
|
35
|
+
conversationsHaveGroupPosition ? 'c.group_position' : 'NULL AS group_position',
|
|
36
|
+
].join(', ');
|
|
27
37
|
return {
|
|
28
38
|
// conversations
|
|
29
39
|
getConv: db.prepare(`SELECT * FROM conversations WHERE id = ? AND status != 'deleted'`),
|
|
@@ -31,7 +41,7 @@ export function createSessionRepository(db) {
|
|
|
31
41
|
getConvBySdkSessionId: db.prepare(`SELECT * FROM conversations WHERE sdk_session_id = ? AND status != 'deleted' ORDER BY updated_at DESC LIMIT 1`),
|
|
32
42
|
listConvIdsMissingRuntimeSession: db.prepare(`SELECT c.id AS id FROM conversations c LEFT JOIN runtime_sessions rs ON rs.conversation_id = c.id WHERE rs.id IS NULL AND c.status != 'deleted'`),
|
|
33
43
|
runtimeSessionsSupportProviders,
|
|
34
|
-
listConvs: db.prepare(`SELECT c.id, c.title, c.title_source, c.archived, c.compacted_into, c.compacted_from, c.sdk_session_id, c.preferred_relay_mode, c.preferred_model, c.preferred_reasoning_effort, c.configured_workspace_root_path, c.runtime_workspace_root_path, c.draft_text, c.draft_updated_at, c.draft_updated_by_client_id, c.draft_attachments, ${conversationOriginSelect}, ${conversationCloudSelect}, c.created_at, c.updated_at, rs.id AS runtime_session_id, rs.strategy AS runtime_strategy, rs.status AS runtime_status, rs.last_used_at AS runtime_last_used_at, ${runtimeProviderSelect}, COUNT(m.id) as message_count FROM conversations c LEFT JOIN messages m ON m.conversation_id = c.id LEFT JOIN runtime_sessions rs ON rs.conversation_id = c.id WHERE c.status != 'deleted' AND (? = 1 OR c.archived = ?) GROUP BY c.id ORDER BY CASE WHEN c.sdk_session_id IS NULL OR c.sdk_session_id = '' THEN 1 ELSE 0 END ASC, c.updated_at DESC`),
|
|
44
|
+
listConvs: db.prepare(`SELECT c.id, c.title, c.title_source, c.archived, c.compacted_into, c.compacted_from, c.sdk_session_id, c.preferred_relay_mode, c.preferred_model, c.preferred_reasoning_effort, c.configured_workspace_root_path, c.runtime_workspace_root_path, c.draft_text, c.draft_updated_at, c.draft_updated_by_client_id, c.draft_attachments, ${conversationOriginSelect}, ${conversationCloudSelect}, ${conversationGroupSelect}, c.created_at, c.updated_at, rs.id AS runtime_session_id, rs.strategy AS runtime_strategy, rs.status AS runtime_status, rs.last_used_at AS runtime_last_used_at, ${runtimeProviderSelect}, COUNT(m.id) as message_count FROM conversations c LEFT JOIN messages m ON m.conversation_id = c.id LEFT JOIN runtime_sessions rs ON rs.conversation_id = c.id WHERE c.status != 'deleted' AND (? = 1 OR c.archived = ?) GROUP BY c.id ORDER BY CASE WHEN c.sdk_session_id IS NULL OR c.sdk_session_id = '' THEN 1 ELSE 0 END ASC, c.updated_at DESC`),
|
|
35
45
|
insertConv: db.prepare(`INSERT OR IGNORE INTO conversations (id, title, created_at, updated_at) VALUES (?, ?, ?, ?)`),
|
|
36
46
|
updateConvTime: db.prepare(`UPDATE conversations SET updated_at = ? WHERE id = ?`),
|
|
37
47
|
updateConvTitle: db.prepare(`UPDATE conversations SET title = ?, title_source = 'manual' WHERE id = ?`),
|
|
@@ -301,47 +311,12 @@ export function createSessionRepository(db) {
|
|
|
301
311
|
FROM conversations
|
|
302
312
|
WHERE status = 'deleted' AND sdk_session_id = ?
|
|
303
313
|
`),
|
|
304
|
-
// public conversation shares
|
|
305
|
-
getConversationShareByConversationId: db.prepare(`
|
|
306
|
-
SELECT token, conversation_id, created_at, last_accessed_at, revoked_at
|
|
307
|
-
FROM conversation_shares
|
|
308
|
-
WHERE conversation_id = ?
|
|
309
|
-
AND (revoked_at IS NULL OR revoked_at = '')
|
|
310
|
-
ORDER BY created_at DESC
|
|
311
|
-
LIMIT 1
|
|
312
|
-
`),
|
|
313
|
-
getConversationShareByToken: db.prepare(`
|
|
314
|
-
SELECT token, conversation_id, created_at, last_accessed_at, revoked_at
|
|
315
|
-
FROM conversation_shares
|
|
316
|
-
WHERE token = ?
|
|
317
|
-
LIMIT 1
|
|
318
|
-
`),
|
|
319
|
-
insertConversationShare: db.prepare(`
|
|
320
|
-
INSERT INTO conversation_shares (
|
|
321
|
-
token,
|
|
322
|
-
conversation_id,
|
|
323
|
-
created_at,
|
|
324
|
-
last_accessed_at,
|
|
325
|
-
revoked_at
|
|
326
|
-
) VALUES (?, ?, ?, ?, NULL)
|
|
327
|
-
`),
|
|
314
|
+
// public conversation shares (the rest lives in share-repository.mjs)
|
|
328
315
|
touchConversationShare: db.prepare(`
|
|
329
316
|
UPDATE conversation_shares
|
|
330
317
|
SET last_accessed_at = ?
|
|
331
318
|
WHERE token = ?
|
|
332
319
|
AND (revoked_at IS NULL OR revoked_at = '')
|
|
333
320
|
`),
|
|
334
|
-
revokeConversationShareByToken: db.prepare(`
|
|
335
|
-
UPDATE conversation_shares
|
|
336
|
-
SET revoked_at = ?
|
|
337
|
-
WHERE token = ?
|
|
338
|
-
AND (revoked_at IS NULL OR revoked_at = '')
|
|
339
|
-
`),
|
|
340
|
-
revokeConversationSharesByConversationId: db.prepare(`
|
|
341
|
-
UPDATE conversation_shares
|
|
342
|
-
SET revoked_at = ?
|
|
343
|
-
WHERE conversation_id = ?
|
|
344
|
-
AND (revoked_at IS NULL OR revoked_at = '')
|
|
345
|
-
`),
|
|
346
321
|
};
|
|
347
322
|
}
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import { randomBytes } from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
// Public conversation shares and their access log (migration 0016).
|
|
6
|
+
//
|
|
7
|
+
// A conversation has at most one active share: its newest unrevoked row in
|
|
8
|
+
// `conversation_shares`. A share is a lineage of tokens: renewing it adds a
|
|
9
|
+
// row with a new token and the same `lineage_id` and revokes the old token in
|
|
10
|
+
// the same transaction, so the access log, keyed to the lineage, carries over.
|
|
11
|
+
// Stopping a share revokes it and deletes its log.
|
|
12
|
+
//
|
|
13
|
+
// Every reader tolerates a schema without the migration: a share then has its
|
|
14
|
+
// token as its lineage, nothing is logged, and the log reads as empty.
|
|
15
|
+
|
|
16
|
+
export const SHARE_STORE_VIEWER_IP_SETTING_KEY = 'shares_store_viewer_ip';
|
|
17
|
+
const SHARE_VIEWER_KEY_SECRET_SETTING_KEY = 'shares_viewer_key_secret';
|
|
18
|
+
|
|
19
|
+
export const SHARE_OPENS_PAGE_DEFAULT = 200;
|
|
20
|
+
export const SHARE_OPENS_PAGE_MAX = 1000;
|
|
21
|
+
|
|
22
|
+
function tableExists(db, name) {
|
|
23
|
+
return !!db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`).get(name);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isRevoked(row) {
|
|
27
|
+
return !!String(row?.revoked_at || '').trim();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function mapShareRow(row) {
|
|
31
|
+
if (!row?.token) return null;
|
|
32
|
+
return {
|
|
33
|
+
token: String(row.token),
|
|
34
|
+
conversationId: String(row.conversation_id || ''),
|
|
35
|
+
lineageId: String(row.lineage_id || '').trim() || String(row.token),
|
|
36
|
+
createdAt: row.created_at || null,
|
|
37
|
+
revokedAt: row.revoked_at || null,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function mapOpenRow(row) {
|
|
42
|
+
return {
|
|
43
|
+
openedAt: row.opened_at,
|
|
44
|
+
ip: row.ip || null,
|
|
45
|
+
country: row.country || null,
|
|
46
|
+
userAgent: row.user_agent || null,
|
|
47
|
+
// Enough to tell viewers apart in a list; the key itself stays here.
|
|
48
|
+
viewer: String(row.viewer_key || '').slice(0, 8) || null,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function createShareRepository(db) {
|
|
53
|
+
const shareColumns = new Set(db.prepare(`PRAGMA table_info(conversation_shares)`).all().map((column) => column.name));
|
|
54
|
+
const hasLineage = shareColumns.has('lineage_id');
|
|
55
|
+
const hasLog = tableExists(db, 'conversation_share_opens');
|
|
56
|
+
const lineageSelect = hasLineage ? 'lineage_id' : 'NULL AS lineage_id';
|
|
57
|
+
const shareSelect = `token, conversation_id, created_at, revoked_at, ${lineageSelect}`;
|
|
58
|
+
|
|
59
|
+
const stmts = {
|
|
60
|
+
activeByConversation: db.prepare(`
|
|
61
|
+
SELECT ${shareSelect}
|
|
62
|
+
FROM conversation_shares
|
|
63
|
+
WHERE conversation_id = ?
|
|
64
|
+
AND (revoked_at IS NULL OR revoked_at = '')
|
|
65
|
+
ORDER BY created_at DESC
|
|
66
|
+
LIMIT 1
|
|
67
|
+
`),
|
|
68
|
+
byToken: db.prepare(`SELECT ${shareSelect} FROM conversation_shares WHERE token = ? LIMIT 1`),
|
|
69
|
+
insert: hasLineage
|
|
70
|
+
? db.prepare(`
|
|
71
|
+
INSERT INTO conversation_shares (token, conversation_id, created_at, last_accessed_at, revoked_at, lineage_id)
|
|
72
|
+
VALUES (?, ?, ?, NULL, NULL, ?)
|
|
73
|
+
`)
|
|
74
|
+
: db.prepare(`
|
|
75
|
+
INSERT INTO conversation_shares (token, conversation_id, created_at, last_accessed_at, revoked_at)
|
|
76
|
+
VALUES (?, ?, ?, NULL, NULL)
|
|
77
|
+
`),
|
|
78
|
+
revokeOthers: db.prepare(`
|
|
79
|
+
UPDATE conversation_shares
|
|
80
|
+
SET revoked_at = ?
|
|
81
|
+
WHERE conversation_id = ?
|
|
82
|
+
AND token <> ?
|
|
83
|
+
AND (revoked_at IS NULL OR revoked_at = '')
|
|
84
|
+
`),
|
|
85
|
+
revokeAll: db.prepare(`
|
|
86
|
+
UPDATE conversation_shares
|
|
87
|
+
SET revoked_at = ?
|
|
88
|
+
WHERE conversation_id = ?
|
|
89
|
+
AND (revoked_at IS NULL OR revoked_at = '')
|
|
90
|
+
`),
|
|
91
|
+
listActive: db.prepare(`
|
|
92
|
+
SELECT s.token, s.conversation_id, s.created_at, s.revoked_at, ${hasLineage ? 's.lineage_id' : 'NULL AS lineage_id'},
|
|
93
|
+
c.title AS conversation_title, c.archived AS conversation_archived
|
|
94
|
+
FROM conversation_shares s
|
|
95
|
+
JOIN conversations c ON c.id = s.conversation_id
|
|
96
|
+
WHERE (s.revoked_at IS NULL OR s.revoked_at = '')
|
|
97
|
+
AND c.status != 'deleted'
|
|
98
|
+
ORDER BY s.created_at DESC
|
|
99
|
+
`),
|
|
100
|
+
conversationStatus: db.prepare(`SELECT id, status FROM conversations WHERE id = ?`),
|
|
101
|
+
getSetting: db.prepare(`SELECT value FROM app_settings WHERE key = ? LIMIT 1`),
|
|
102
|
+
upsertSetting: db.prepare(`
|
|
103
|
+
INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)
|
|
104
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
|
|
105
|
+
`),
|
|
106
|
+
};
|
|
107
|
+
// Addresses already stored: the access log's `ip` and the `viewerIp` of the
|
|
108
|
+
// status log's shared reads. A row whose payload is not JSON is left alone.
|
|
109
|
+
const hasEvents = tableExists(db, 'status_events');
|
|
110
|
+
const EVENT_IP = `CASE WHEN json_valid(payload_json) THEN json_extract(payload_json, '$.viewerIp') END`;
|
|
111
|
+
const ipSources = [
|
|
112
|
+
...(hasLog ? [`SELECT ip AS address FROM conversation_share_opens WHERE ip IS NOT NULL AND ip <> ''`] : []),
|
|
113
|
+
...(hasEvents ? [`SELECT ${EVENT_IP} AS address FROM status_events WHERE type = 'shared-access-opened' AND COALESCE(${EVENT_IP}, '') <> ''`] : []),
|
|
114
|
+
];
|
|
115
|
+
const ipStmts = {
|
|
116
|
+
count: ipSources.length ? db.prepare(`SELECT COUNT(*) AS n FROM (${ipSources.join(' UNION ')})`) : null,
|
|
117
|
+
clearOpens: hasLog ? db.prepare(`UPDATE conversation_share_opens SET ip = NULL WHERE ip IS NOT NULL`) : null,
|
|
118
|
+
clearEvents: hasEvents ? db.prepare(`
|
|
119
|
+
UPDATE status_events
|
|
120
|
+
SET payload_json = json_remove(payload_json, '$.viewerIp')
|
|
121
|
+
WHERE type = 'shared-access-opened' AND ${EVENT_IP} IS NOT NULL
|
|
122
|
+
`) : null,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const logStmts = hasLog ? {
|
|
126
|
+
insert: db.prepare(`
|
|
127
|
+
INSERT INTO conversation_share_opens (lineage_id, conversation_id, opened_at, ip, user_agent, country, viewer_key)
|
|
128
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
129
|
+
`),
|
|
130
|
+
stats: db.prepare(`
|
|
131
|
+
SELECT COUNT(*) AS opens, COUNT(DISTINCT viewer_key) AS viewers, MAX(opened_at) AS last_opened_at
|
|
132
|
+
FROM conversation_share_opens
|
|
133
|
+
WHERE lineage_id = ?
|
|
134
|
+
`),
|
|
135
|
+
page: db.prepare(`
|
|
136
|
+
SELECT opened_at, ip, user_agent, country, viewer_key
|
|
137
|
+
FROM conversation_share_opens
|
|
138
|
+
WHERE lineage_id = ?
|
|
139
|
+
ORDER BY opened_at DESC, id DESC
|
|
140
|
+
LIMIT ?
|
|
141
|
+
`),
|
|
142
|
+
lastOpenOf: db.prepare(`
|
|
143
|
+
SELECT MAX(opened_at) AS opened_at
|
|
144
|
+
FROM conversation_share_opens
|
|
145
|
+
WHERE lineage_id = ? AND viewer_key = ?
|
|
146
|
+
`),
|
|
147
|
+
deleteForConversation: db.prepare(`DELETE FROM conversation_share_opens WHERE conversation_id = ?`),
|
|
148
|
+
} : null;
|
|
149
|
+
|
|
150
|
+
function findActiveShare(conversationId) {
|
|
151
|
+
return mapShareRow(stmts.activeByConversation.get(String(conversationId || '').trim()));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function findShareByToken(token) {
|
|
155
|
+
return mapShareRow(stmts.byToken.get(String(token || '').trim()));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The share a public request may read: the token's row, neither revoked nor
|
|
160
|
+
* renewed, of a conversation that still exists. Null otherwise, and every
|
|
161
|
+
* share route answers that with a 404.
|
|
162
|
+
*/
|
|
163
|
+
function findLiveShare(token) {
|
|
164
|
+
const row = stmts.byToken.get(String(token || '').trim());
|
|
165
|
+
if (!row || isRevoked(row)) return null;
|
|
166
|
+
const share = mapShareRow(row);
|
|
167
|
+
const conversation = stmts.conversationStatus.get(share.conversationId);
|
|
168
|
+
if (!conversation || String(conversation.status || '').trim().toLowerCase() === 'deleted') return null;
|
|
169
|
+
return share;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function insertShare(conversationId, token, now, lineageId) {
|
|
173
|
+
if (hasLineage) stmts.insert.run(token, conversationId, now, lineageId || token);
|
|
174
|
+
else stmts.insert.run(token, conversationId, now);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The active share, or a new one; `created` tells which. */
|
|
178
|
+
const getOrCreateShare = db.transaction((conversationId, makeToken, now) => {
|
|
179
|
+
const existing = findActiveShare(conversationId);
|
|
180
|
+
if (existing) return { share: existing, created: false };
|
|
181
|
+
const token = makeToken();
|
|
182
|
+
insertShare(conversationId, token, now, token);
|
|
183
|
+
return { share: findShareByToken(token), created: true };
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* A new token for the conversation's active share, same lineage; every other
|
|
188
|
+
* token of the conversation is revoked with it. Null without an active share.
|
|
189
|
+
*/
|
|
190
|
+
const renewShare = db.transaction((conversationId, makeToken, now) => {
|
|
191
|
+
const existing = findActiveShare(conversationId);
|
|
192
|
+
if (!existing) return null;
|
|
193
|
+
const token = makeToken();
|
|
194
|
+
insertShare(conversationId, token, now, existing.lineageId);
|
|
195
|
+
stmts.revokeOthers.run(now, conversationId, token);
|
|
196
|
+
return { share: findShareByToken(token), previousToken: existing.token };
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
/** Revokes every token of the conversation and deletes its access log. */
|
|
200
|
+
const stopSharing = db.transaction((conversationId, now) => {
|
|
201
|
+
const revoked = stmts.revokeAll.run(now, conversationId).changes;
|
|
202
|
+
const opensDeleted = logStmts ? logStmts.deleteForConversation.run(conversationId).changes : 0;
|
|
203
|
+
return { revokedCount: Number(revoked || 0), opensDeleted: Number(opensDeleted || 0) };
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
function forgetConversation(conversationId) {
|
|
207
|
+
return logStmts ? Number(logStmts.deleteForConversation.run(conversationId).changes || 0) : 0;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function shareStats(lineageId) {
|
|
211
|
+
const row = logStmts ? logStmts.stats.get(lineageId) : null;
|
|
212
|
+
return {
|
|
213
|
+
openCount: Number(row?.opens || 0),
|
|
214
|
+
viewerCount: Number(row?.viewers || 0),
|
|
215
|
+
lastOpenedAt: row?.last_opened_at || null,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** One entry per conversation with an active share, newest share first. */
|
|
220
|
+
function listActiveShares() {
|
|
221
|
+
const seen = new Set();
|
|
222
|
+
const shares = [];
|
|
223
|
+
for (const row of stmts.listActive.all()) {
|
|
224
|
+
const conversationId = String(row.conversation_id || '');
|
|
225
|
+
if (seen.has(conversationId)) continue;
|
|
226
|
+
seen.add(conversationId);
|
|
227
|
+
const share = mapShareRow(row);
|
|
228
|
+
// The lineage started with its first token; that row holds the date.
|
|
229
|
+
const first = share.lineageId === share.token ? row : stmts.byToken.get(share.lineageId);
|
|
230
|
+
shares.push({
|
|
231
|
+
...share,
|
|
232
|
+
title: String(row.conversation_title || '').trim(),
|
|
233
|
+
archived: Number(row.conversation_archived || 0) === 1,
|
|
234
|
+
sharedSince: first?.created_at || share.createdAt,
|
|
235
|
+
...shareStats(share.lineageId),
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
return shares;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function recordOpen({ lineageId, conversationId, openedAt, ip = null, userAgent = null, country = null, viewerKey }) {
|
|
242
|
+
if (!logStmts) return false;
|
|
243
|
+
logStmts.insert.run(lineageId, conversationId, openedAt, ip || null, userAgent || null, country || null, viewerKey);
|
|
244
|
+
return true;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function lastOpenAt(lineageId, viewerKey) {
|
|
248
|
+
return logStmts ? logStmts.lastOpenOf.get(lineageId, viewerKey)?.opened_at || null : null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function listOpens(lineageId, { limit = SHARE_OPENS_PAGE_DEFAULT } = {}) {
|
|
252
|
+
const pageSize = Math.max(1, Math.min(SHARE_OPENS_PAGE_MAX, Math.trunc(Number(limit) || SHARE_OPENS_PAGE_DEFAULT)));
|
|
253
|
+
if (!logStmts) return { opens: [], total: 0, limit: pageSize };
|
|
254
|
+
return {
|
|
255
|
+
opens: logStmts.page.all(lineageId, pageSize).map(mapOpenRow),
|
|
256
|
+
total: shareStats(lineageId).openCount,
|
|
257
|
+
limit: pageSize,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Default on: a relay records the address of a public reader unless told not to.
|
|
262
|
+
function readStoreViewerIp() {
|
|
263
|
+
return String(stmts.getSetting.get(SHARE_STORE_VIEWER_IP_SETTING_KEY)?.value || '').trim() !== 'false';
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function writeStoreViewerIp(enabled) {
|
|
267
|
+
stmts.upsertSetting.run(SHARE_STORE_VIEWER_IP_SETTING_KEY, enabled ? 'true' : 'false', new Date().toISOString());
|
|
268
|
+
return readStoreViewerIp();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** How many distinct viewer addresses the relay still holds. */
|
|
272
|
+
function countStoredViewerIps() {
|
|
273
|
+
return Number(ipStmts.count?.get()?.n || 0);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Drops every stored viewer address: the access log keeps its rows and
|
|
278
|
+
* viewer keys (so viewer counts stay), the status log its events.
|
|
279
|
+
*/
|
|
280
|
+
const forgetStoredViewerIps = db.transaction(() => ({
|
|
281
|
+
opens: Number(ipStmts.clearOpens?.run().changes || 0),
|
|
282
|
+
events: Number(ipStmts.clearEvents?.run().changes || 0),
|
|
283
|
+
}));
|
|
284
|
+
|
|
285
|
+
// The key of the viewer hash, made once per relay. Kept next to the log, so
|
|
286
|
+
// it keeps addresses out of the log but does not hide them from someone
|
|
287
|
+
// holding the whole database.
|
|
288
|
+
let viewerKeySecret = '';
|
|
289
|
+
function readViewerKeySecret() {
|
|
290
|
+
if (viewerKeySecret) return viewerKeySecret;
|
|
291
|
+
const stored = String(stmts.getSetting.get(SHARE_VIEWER_KEY_SECRET_SETTING_KEY)?.value || '').trim();
|
|
292
|
+
if (stored) {
|
|
293
|
+
viewerKeySecret = stored;
|
|
294
|
+
return stored;
|
|
295
|
+
}
|
|
296
|
+
const created = randomBytes(32).toString('hex');
|
|
297
|
+
stmts.upsertSetting.run(SHARE_VIEWER_KEY_SECRET_SETTING_KEY, created, new Date().toISOString());
|
|
298
|
+
viewerKeySecret = created;
|
|
299
|
+
return created;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return {
|
|
303
|
+
logAvailable: hasLog,
|
|
304
|
+
findActiveShare,
|
|
305
|
+
findShareByToken,
|
|
306
|
+
findLiveShare,
|
|
307
|
+
getOrCreateShare,
|
|
308
|
+
renewShare,
|
|
309
|
+
stopSharing,
|
|
310
|
+
forgetConversation,
|
|
311
|
+
shareStats,
|
|
312
|
+
listActiveShares,
|
|
313
|
+
recordOpen,
|
|
314
|
+
lastOpenAt,
|
|
315
|
+
listOpens,
|
|
316
|
+
readStoreViewerIp,
|
|
317
|
+
writeStoreViewerIp,
|
|
318
|
+
countStoredViewerIps,
|
|
319
|
+
forgetStoredViewerIps,
|
|
320
|
+
readViewerKeySecret,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
@@ -20,5 +20,15 @@ export function createShareVisibilityStatements(db) {
|
|
|
20
20
|
WHERE id = ? AND conversation_id = ?
|
|
21
21
|
`)
|
|
22
22
|
: null,
|
|
23
|
+
// Conversation order, like the pin list. Null on a database without the
|
|
24
|
+
// columns: every caller treats that as "nothing hidden".
|
|
25
|
+
listHiddenMessages: messagesSupportShareVisibility
|
|
26
|
+
? db.prepare(`
|
|
27
|
+
SELECT id, role, text, mode, attachments, timestamp, share_hidden_at
|
|
28
|
+
FROM messages
|
|
29
|
+
WHERE conversation_id = ? AND hidden_from_shares = 1
|
|
30
|
+
ORDER BY timestamp ASC
|
|
31
|
+
`)
|
|
32
|
+
: null,
|
|
23
33
|
};
|
|
24
34
|
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Sub-agent viewer: the timeline table, the run header columns and their SQL
|
|
4
|
+
// in one place. On a database the 0009 migration has not reached yet the
|
|
5
|
+
// timeline statements are null and the header columns read as absent; every
|
|
6
|
+
// caller treats that as "no timeline" and falls back to the activity, thought
|
|
7
|
+
// and stream rows a run has always had.
|
|
8
|
+
|
|
9
|
+
const HEADER_COLUMNS = [
|
|
10
|
+
'model',
|
|
11
|
+
'model_inherited',
|
|
12
|
+
'subagent_type',
|
|
13
|
+
'task_id',
|
|
14
|
+
'total_tokens',
|
|
15
|
+
'background',
|
|
16
|
+
'truncated',
|
|
17
|
+
'timeline_owner',
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const BASE_RUN_COLUMNS = [
|
|
21
|
+
'id',
|
|
22
|
+
'queue_message_id',
|
|
23
|
+
'conversation_id',
|
|
24
|
+
'parent_subagent_id',
|
|
25
|
+
'display_name',
|
|
26
|
+
'status',
|
|
27
|
+
'started_at',
|
|
28
|
+
'updated_at',
|
|
29
|
+
'completed_at',
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
export function createSubagentTimelineStatements(db) {
|
|
33
|
+
const tableNames = new Set(
|
|
34
|
+
db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table'`).all().map((row) => String(row.name)),
|
|
35
|
+
);
|
|
36
|
+
if (!tableNames.has('subagent_runs')) {
|
|
37
|
+
return { subagentRunsSupportHeader: false, subagentTimelineSupported: false, subagentRunSelectColumns: BASE_RUN_COLUMNS.join(', ') };
|
|
38
|
+
}
|
|
39
|
+
const runColumns = new Set(db.prepare(`PRAGMA table_info(subagent_runs)`).all().map((column) => String(column.name)));
|
|
40
|
+
const subagentRunsSupportHeader = HEADER_COLUMNS.every((name) => runColumns.has(name));
|
|
41
|
+
const subagentTimelineSupported = subagentRunsSupportHeader && tableNames.has('subagent_timeline');
|
|
42
|
+
const subagentRunSelectColumns = [...BASE_RUN_COLUMNS, ...(subagentRunsSupportHeader ? HEADER_COLUMNS : [])].join(', ');
|
|
43
|
+
|
|
44
|
+
const statements = {
|
|
45
|
+
subagentRunsSupportHeader,
|
|
46
|
+
subagentTimelineSupported,
|
|
47
|
+
// The column list every run read uses, so the header travels with the
|
|
48
|
+
// run wherever runs are listed (conversation payload, in-flight snapshot).
|
|
49
|
+
subagentRunSelectColumns,
|
|
50
|
+
updateSubagentRunHeader: null,
|
|
51
|
+
setSubagentRunTruncated: null,
|
|
52
|
+
listChildSubagentRuns: null,
|
|
53
|
+
getSubagentRunByTaskId: null,
|
|
54
|
+
listRunningBackgroundSubagentRunsForSession: null,
|
|
55
|
+
getTimelineEntryByKey: null,
|
|
56
|
+
getMaxTimelineSeq: null,
|
|
57
|
+
countTimelineEntries: null,
|
|
58
|
+
insertTimelineEntry: null,
|
|
59
|
+
updateTimelineEntryByKey: null,
|
|
60
|
+
listTimelineEntries: null,
|
|
61
|
+
deleteConvSubagentTimeline: null,
|
|
62
|
+
listLegacyActivityBySubagentRun: null,
|
|
63
|
+
listLegacyThoughtsBySubagentRun: null,
|
|
64
|
+
listLegacyStreamBySubagentRun: null,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// What a run recorded before the timeline existed: its tagged activity
|
|
68
|
+
// lines, thoughts and reply text. The viewer reads these for an old run.
|
|
69
|
+
const hasRunColumn = (table) => tableNames.has(table)
|
|
70
|
+
&& db.prepare(`PRAGMA table_info(${table})`).all().some((column) => column.name === 'subagent_run_id');
|
|
71
|
+
if (hasRunColumn('relay_activity')) {
|
|
72
|
+
statements.listLegacyActivityBySubagentRun = db.prepare(`
|
|
73
|
+
SELECT id, text, metadata_json, created_at FROM relay_activity
|
|
74
|
+
WHERE subagent_run_id = ? ORDER BY id ASC LIMIT ?
|
|
75
|
+
`);
|
|
76
|
+
}
|
|
77
|
+
if (hasRunColumn('relay_thought')) {
|
|
78
|
+
statements.listLegacyThoughtsBySubagentRun = db.prepare(`
|
|
79
|
+
SELECT reasoning_id, seq, text, done, created_at FROM relay_thought
|
|
80
|
+
WHERE subagent_run_id = ? ORDER BY seq ASC, id ASC LIMIT ?
|
|
81
|
+
`);
|
|
82
|
+
}
|
|
83
|
+
if (hasRunColumn('relay_stream_events')) {
|
|
84
|
+
statements.listLegacyStreamBySubagentRun = db.prepare(`
|
|
85
|
+
SELECT text, done, created_at FROM relay_stream_events
|
|
86
|
+
WHERE subagent_run_id = ? ORDER BY seq ASC, id ASC LIMIT ?
|
|
87
|
+
`);
|
|
88
|
+
}
|
|
89
|
+
if (!subagentRunsSupportHeader) return statements;
|
|
90
|
+
|
|
91
|
+
// A patch, not a replace: every field the writer did not name (NULL) keeps
|
|
92
|
+
// what the row has. `background` and `truncated` only ever turn on, and the
|
|
93
|
+
// timeline owner is claimed once.
|
|
94
|
+
statements.updateSubagentRunHeader = db.prepare(`
|
|
95
|
+
UPDATE subagent_runs
|
|
96
|
+
SET model = COALESCE(@model, model),
|
|
97
|
+
model_inherited = COALESCE(@modelInherited, model_inherited),
|
|
98
|
+
subagent_type = COALESCE(@subagentType, subagent_type),
|
|
99
|
+
task_id = COALESCE(@taskId, task_id),
|
|
100
|
+
total_tokens = COALESCE(@totalTokens, total_tokens),
|
|
101
|
+
background = MAX(background, COALESCE(@background, 0)),
|
|
102
|
+
truncated = MAX(truncated, COALESCE(@truncated, 0)),
|
|
103
|
+
timeline_owner = COALESCE(timeline_owner, @timelineOwner),
|
|
104
|
+
updated_at = @updatedAt
|
|
105
|
+
WHERE id = @id
|
|
106
|
+
`);
|
|
107
|
+
statements.setSubagentRunTruncated = db.prepare(`UPDATE subagent_runs SET truncated = 1, updated_at = ? WHERE id = ?`);
|
|
108
|
+
statements.listChildSubagentRuns = db.prepare(`
|
|
109
|
+
SELECT ${subagentRunSelectColumns}
|
|
110
|
+
FROM subagent_runs
|
|
111
|
+
WHERE parent_subagent_id = ?
|
|
112
|
+
ORDER BY started_at ASC, id ASC
|
|
113
|
+
`);
|
|
114
|
+
// A worker's live background agents: what dies with it.
|
|
115
|
+
if (tableNames.has('conversations')) {
|
|
116
|
+
statements.listRunningBackgroundSubagentRunsForSession = db.prepare(`
|
|
117
|
+
SELECT ${subagentRunSelectColumns}
|
|
118
|
+
FROM subagent_runs
|
|
119
|
+
WHERE status = 'running' AND background = 1
|
|
120
|
+
AND conversation_id IN (SELECT id FROM conversations WHERE sdk_session_id = ? OR id = ?)
|
|
121
|
+
`);
|
|
122
|
+
}
|
|
123
|
+
statements.getSubagentRunByTaskId = db.prepare(`
|
|
124
|
+
SELECT ${subagentRunSelectColumns}
|
|
125
|
+
FROM subagent_runs
|
|
126
|
+
WHERE conversation_id = ? AND task_id = ?
|
|
127
|
+
ORDER BY started_at DESC
|
|
128
|
+
LIMIT 1
|
|
129
|
+
`);
|
|
130
|
+
|
|
131
|
+
if (!subagentTimelineSupported) return statements;
|
|
132
|
+
|
|
133
|
+
statements.getTimelineEntryByKey = db.prepare(`SELECT id, seq, done FROM subagent_timeline WHERE run_id = ? AND entry_key = ?`);
|
|
134
|
+
statements.getMaxTimelineSeq = db.prepare(`SELECT COALESCE(MAX(seq), 0) AS max_seq FROM subagent_timeline WHERE run_id = ?`);
|
|
135
|
+
statements.countTimelineEntries = db.prepare(`SELECT COUNT(*) AS cnt FROM subagent_timeline WHERE run_id = ?`);
|
|
136
|
+
statements.insertTimelineEntry = db.prepare(`
|
|
137
|
+
INSERT INTO subagent_timeline (
|
|
138
|
+
run_id, conversation_id, queue_message_id, entry_key, seq, kind, text, tool_name, tool_use_id,
|
|
139
|
+
metadata_json, done, created_at, updated_at
|
|
140
|
+
) VALUES (
|
|
141
|
+
@runId, @conversationId, @queueMessageId, @entryKey, @seq, @kind, @text, @toolName, @toolUseId,
|
|
142
|
+
@metadataJson, @done, @createdAt, @updatedAt
|
|
143
|
+
)
|
|
144
|
+
`);
|
|
145
|
+
// In place: the entry keeps its seq (its place in the order). A closed
|
|
146
|
+
// entry stays closed, though its text may still be corrected (the complete
|
|
147
|
+
// copy of a block that streamed).
|
|
148
|
+
statements.updateTimelineEntryByKey = db.prepare(`
|
|
149
|
+
UPDATE subagent_timeline
|
|
150
|
+
SET text = @text,
|
|
151
|
+
tool_name = COALESCE(@toolName, tool_name),
|
|
152
|
+
tool_use_id = COALESCE(@toolUseId, tool_use_id),
|
|
153
|
+
metadata_json = COALESCE(@metadataJson, metadata_json),
|
|
154
|
+
done = MAX(done, @done),
|
|
155
|
+
updated_at = @updatedAt
|
|
156
|
+
WHERE run_id = @runId AND entry_key = @entryKey
|
|
157
|
+
`);
|
|
158
|
+
// Everything after `afterSeq`, plus whatever is still open before it: an
|
|
159
|
+
// open entry changes in place, so a reader that already has its seq still
|
|
160
|
+
// needs its newer text.
|
|
161
|
+
statements.listTimelineEntries = db.prepare(`
|
|
162
|
+
SELECT seq, kind, text, tool_name, tool_use_id, metadata_json, done, created_at, updated_at
|
|
163
|
+
FROM subagent_timeline
|
|
164
|
+
WHERE run_id = @runId AND (seq > @afterSeq OR done = 0)
|
|
165
|
+
ORDER BY seq ASC
|
|
166
|
+
LIMIT @limit
|
|
167
|
+
`);
|
|
168
|
+
statements.deleteConvSubagentTimeline = db.prepare(`DELETE FROM subagent_timeline WHERE conversation_id = ?`);
|
|
169
|
+
return statements;
|
|
170
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// GET /api/about: the About OAR modal's data (about-service.mjs). The owner's
|
|
4
|
+
// only; a shared viewer never sees it.
|
|
5
|
+
|
|
6
|
+
export function registerAboutRoutes(app, { auth, aboutService = null } = {}) {
|
|
7
|
+
app.get('/api/about', auth, async (_req, res) => {
|
|
8
|
+
if (!aboutService) return res.status(503).json({ error: 'About is unavailable on this relay' });
|
|
9
|
+
try {
|
|
10
|
+
return res.json(await aboutService.describe());
|
|
11
|
+
} catch (error) {
|
|
12
|
+
return res.status(500).json({ error: `Reading the relay's facts failed: ${String(error?.message || error)}` });
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
}
|
|
@@ -116,6 +116,8 @@ export function registerAskUserRoutes(app, deps) {
|
|
|
116
116
|
DEFAULT_RELAY_MODE,
|
|
117
117
|
sessionWorkerRegistry,
|
|
118
118
|
pushDispatchService,
|
|
119
|
+
// Relay event subscriptions: a question is an event for its watchers.
|
|
120
|
+
relayEventService = null,
|
|
119
121
|
// Remote relays: another relay's agent answering a question is refused
|
|
120
122
|
// while the inbound switch is off. Absent in older wiring (no-op).
|
|
121
123
|
remoteRelayInbound = null,
|
|
@@ -246,6 +248,7 @@ export function registerAskUserRoutes(app, deps) {
|
|
|
246
248
|
console.log(`[${ts()}] QUESTION ${questionId.slice(0,8)} conv=${question.conversationId.slice(0,8)} mode=${relayMode} fields=${fieldCount} prompt="${promptText.slice(0,60)}"`);
|
|
247
249
|
io.emit('relay_question', { question });
|
|
248
250
|
void pushDispatchService?.notifyQuestion?.(question);
|
|
251
|
+
try { relayEventService?.noteQuestion?.(question); } catch {}
|
|
249
252
|
res.json({ question });
|
|
250
253
|
});
|
|
251
254
|
|
|
@@ -14,6 +14,8 @@ export function registerCacheRoutes(app, deps) {
|
|
|
14
14
|
bootstrapRuntimeSessionBindings,
|
|
15
15
|
collectOrphanedUploadsFromConversation,
|
|
16
16
|
deleteOrphanedUploads,
|
|
17
|
+
closeConversationPreviews,
|
|
18
|
+
conversationGroups = null,
|
|
17
19
|
} = deps;
|
|
18
20
|
|
|
19
21
|
const cacheRebuildService = createCacheRebuildService({
|
|
@@ -26,6 +28,8 @@ export function registerCacheRoutes(app, deps) {
|
|
|
26
28
|
bootstrapRuntimeSessionBindings,
|
|
27
29
|
collectOrphanedUploadsFromConversation,
|
|
28
30
|
deleteOrphanedUploads,
|
|
31
|
+
closeConversationPreviews,
|
|
32
|
+
conversationGroups,
|
|
29
33
|
});
|
|
30
34
|
|
|
31
35
|
app.post('/api/cache/rebuild', auth, (req, res) => {
|