@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
|
@@ -37,10 +37,6 @@ const OTHER_ESCAPE_PATTERN = /\x1b[@-Z\\-_]/g;
|
|
|
37
37
|
// differently-sized terminal this degrades to the pre-wrap behaviour.
|
|
38
38
|
const PTY_WRAP_COLUMNS = 80;
|
|
39
39
|
|
|
40
|
-
function normalizeText(value) {
|
|
41
|
-
return String(value == null ? '' : value).trim();
|
|
42
|
-
}
|
|
43
|
-
|
|
44
40
|
/**
|
|
45
41
|
* Removes OSC sequences (including OSC-8 hyperlink wrappers), ANSI CSI
|
|
46
42
|
* sequences and the CR the PTY adds to every line, leaving the human-visible
|
|
@@ -74,23 +70,10 @@ export function joinWrappedLines(value) {
|
|
|
74
70
|
return joined.join('\n');
|
|
75
71
|
}
|
|
76
72
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
* URL get caught by the generic rule too, which is fine: the URL is already
|
|
82
|
-
* carried in the login payload.
|
|
83
|
-
*/
|
|
84
|
-
export function scrubSecrets(text, submittedCode = '') {
|
|
85
|
-
let output = String(text == null ? '' : text);
|
|
86
|
-
const code = normalizeText(submittedCode);
|
|
87
|
-
if (code.length >= 4) {
|
|
88
|
-
output = output.split(code).join('[redacted]');
|
|
89
|
-
}
|
|
90
|
-
return output
|
|
91
|
-
.replace(/sk-ant-[A-Za-z0-9_-]+/g, '[redacted]')
|
|
92
|
-
.replace(/[A-Za-z0-9_-]{32,}/g, '[redacted]');
|
|
93
|
-
}
|
|
73
|
+
// Redacts anything token-shaped plus an exact secret the caller handed over.
|
|
74
|
+
// The rules live in the shared text pipeline, which also scrubs background
|
|
75
|
+
// task output (browser-safe, so it cannot import this module).
|
|
76
|
+
export { scrubSecrets } from '../../shared/task-output-text.mjs';
|
|
94
77
|
|
|
95
78
|
/** Collapses captured output into one short single-line tail for an error. */
|
|
96
79
|
export function tailOf(text, limit = MAX_ERROR_TAIL_CHARS) {
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// One page of a conversation's history, selected in SQL.
|
|
4
|
+
//
|
|
5
|
+
// The history route used to load every message, hydrate every reply and only
|
|
6
|
+
// then cut the page out (selectConversationHistoryPage in sessions-routes.mjs),
|
|
7
|
+
// so each page and each live poll paid for the whole conversation. This module
|
|
8
|
+
// selects the page's rows in SQL with the same cursor rules and the same
|
|
9
|
+
// pageInfo, so only the page is hydrated.
|
|
10
|
+
//
|
|
11
|
+
// The JS order is (timestamp in ms, then id by localeCompare). SQL orders by
|
|
12
|
+
// (timestamp text, id text). The two agree when every timestamp has the fixed
|
|
13
|
+
// ISO shape the relay writes (YYYY-MM-DDTHH:MM:SS.mmmZ) and every id is made
|
|
14
|
+
// of lowercase letters, digits and dashes (the relay's UUIDs). A conversation
|
|
15
|
+
// with any other row is not paged here: the selector returns null and the
|
|
16
|
+
// caller keeps the in-memory path, which handles every shape.
|
|
17
|
+
|
|
18
|
+
const ISO_TIMESTAMP_GLOB = '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9].[0-9][0-9][0-9]Z';
|
|
19
|
+
const ISO_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
|
20
|
+
const ORDERABLE_ID_PATTERN = /^[-0-9a-z]+$/;
|
|
21
|
+
|
|
22
|
+
export function normalizeConversationTimestampMs(value) {
|
|
23
|
+
const text = String(value || '').trim();
|
|
24
|
+
if (!text) return 0;
|
|
25
|
+
const sqliteUtcLike = /^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?)$/;
|
|
26
|
+
const hasExplicitTimezone = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(text);
|
|
27
|
+
let normalized = text;
|
|
28
|
+
if (!hasExplicitTimezone && sqliteUtcLike.test(text)) {
|
|
29
|
+
const [, day, time] = text.match(sqliteUtcLike) || [];
|
|
30
|
+
normalized = `${day}T${time}Z`;
|
|
31
|
+
}
|
|
32
|
+
const parsed = Date.parse(normalized);
|
|
33
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function normalizeConversationHistoryLimit(value, fallback = 20) {
|
|
37
|
+
const numeric = Number(value);
|
|
38
|
+
if (!Number.isFinite(numeric)) return fallback;
|
|
39
|
+
return Math.min(100, Math.max(1, Math.trunc(numeric)));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The pageInfo of a history page: `pageMessages` in conversation order, and
|
|
44
|
+
* whether the conversation has rows before the first and after the last.
|
|
45
|
+
*/
|
|
46
|
+
export function buildConversationHistoryPageInfo(pageMessages = [], { hasMoreOlder = false, hasMoreNewer = false } = {}) {
|
|
47
|
+
const firstMessage = pageMessages[0] || null;
|
|
48
|
+
const lastMessage = pageMessages[pageMessages.length - 1] || null;
|
|
49
|
+
return {
|
|
50
|
+
hasMore: hasMoreOlder,
|
|
51
|
+
hasMoreOlder,
|
|
52
|
+
hasMoreNewer,
|
|
53
|
+
nextCursor: firstMessage ? {
|
|
54
|
+
beforeMessageId: String(firstMessage.id || '').trim(),
|
|
55
|
+
beforeTimestamp: String(firstMessage.timestamp || '').trim() || null,
|
|
56
|
+
} : null,
|
|
57
|
+
olderCursor: firstMessage ? {
|
|
58
|
+
beforeMessageId: String(firstMessage.id || '').trim(),
|
|
59
|
+
beforeTimestamp: String(firstMessage.timestamp || '').trim() || null,
|
|
60
|
+
} : null,
|
|
61
|
+
newerCursor: lastMessage ? {
|
|
62
|
+
afterMessageId: String(lastMessage.id || '').trim(),
|
|
63
|
+
afterTimestamp: String(lastMessage.timestamp || '').trim() || null,
|
|
64
|
+
} : null,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The prepared statements for paging one conversation. `sharedOnly` pages
|
|
70
|
+
* over the rows a shared view shows (not hidden from shares).
|
|
71
|
+
*/
|
|
72
|
+
export function createConversationHistoryPageStatements(db) {
|
|
73
|
+
const messageColumns = new Set(db.prepare(`PRAGMA table_info(messages)`).all().map((column) => column.name));
|
|
74
|
+
const sharedFilter = messageColumns.has('hidden_from_shares') ? 'AND hidden_from_shares = 0' : '';
|
|
75
|
+
const hasResendColumns = messageColumns.has('source_message_id') && messageColumns.has('resend_of_message_id');
|
|
76
|
+
const build = (visibility) => {
|
|
77
|
+
const base = `FROM messages WHERE conversation_id = @conversationId ${visibility}`;
|
|
78
|
+
const older = `(timestamp < @timestamp OR (timestamp = @timestamp AND @id IS NOT NULL AND id < @id))`;
|
|
79
|
+
const newer = `(timestamp > @timestamp OR (timestamp = @timestamp AND @id IS NOT NULL AND id > @id))`;
|
|
80
|
+
return {
|
|
81
|
+
irregular: db.prepare(`
|
|
82
|
+
SELECT EXISTS (
|
|
83
|
+
SELECT 1 ${base}
|
|
84
|
+
AND NOT (timestamp GLOB '${ISO_TIMESTAMP_GLOB}' AND id <> '' AND id NOT GLOB '*[^-0-9a-z]*')
|
|
85
|
+
) AS irregular
|
|
86
|
+
`),
|
|
87
|
+
findById: db.prepare(`SELECT id, timestamp ${base} AND id = @id`),
|
|
88
|
+
older: db.prepare(`SELECT * ${base} AND ${older} ORDER BY timestamp DESC, id DESC LIMIT @limit`),
|
|
89
|
+
newer: db.prepare(`SELECT * ${base} AND ${newer} ORDER BY timestamp ASC, id ASC LIMIT @limit`),
|
|
90
|
+
fromInclusive: db.prepare(`
|
|
91
|
+
SELECT * ${base} AND (timestamp > @timestamp OR (timestamp = @timestamp AND id >= @id))
|
|
92
|
+
ORDER BY timestamp ASC, id ASC LIMIT @limit
|
|
93
|
+
`),
|
|
94
|
+
tail: db.prepare(`SELECT * ${base} ORDER BY timestamp DESC, id DESC LIMIT @limit`),
|
|
95
|
+
existsOlder: db.prepare(`SELECT EXISTS (SELECT 1 ${base} AND ${older}) AS found`),
|
|
96
|
+
existsNewer: db.prepare(`SELECT EXISTS (SELECT 1 ${base} AND ${newer}) AS found`),
|
|
97
|
+
// What liveResendsByOriginal reads, for the whole conversation: a
|
|
98
|
+
// stopped marker on the page may have its Resend outside it.
|
|
99
|
+
resendIndex: hasResendColumns
|
|
100
|
+
? db.prepare(`SELECT id, role, source_message_id, resend_of_message_id ${base} ORDER BY timestamp ASC`)
|
|
101
|
+
: null,
|
|
102
|
+
};
|
|
103
|
+
};
|
|
104
|
+
const all = build('');
|
|
105
|
+
const shared = build(sharedFilter);
|
|
106
|
+
return {
|
|
107
|
+
selectConversationHistoryPage(conversationId, options = {}) {
|
|
108
|
+
return selectConversationHistoryPageFromStatements(options.sharedOnly ? shared : all, conversationId, options);
|
|
109
|
+
},
|
|
110
|
+
listConversationResendIndex(conversationId, { sharedOnly = false } = {}) {
|
|
111
|
+
const statement = (sharedOnly ? shared : all).resendIndex;
|
|
112
|
+
return statement ? statement.all({ conversationId }) : [];
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// A cursor as the in-memory selector resolves it, in the SQL shape: the
|
|
118
|
+
// message's own timestamp when its id is in the conversation, else the given
|
|
119
|
+
// timestamp. Null when there is no cursor; `unsupported` when the given
|
|
120
|
+
// values cannot be compared in SQL the way the selector compares them.
|
|
121
|
+
function resolveCursor(statements, conversationId, { messageId = '', timestamp = '' } = {}) {
|
|
122
|
+
const cursorMessageId = String(messageId || '').trim();
|
|
123
|
+
const cursorTimestampText = String(timestamp || '').trim();
|
|
124
|
+
if (cursorMessageId) {
|
|
125
|
+
const row = statements.findById.get({ conversationId, id: cursorMessageId });
|
|
126
|
+
if (row) return { id: cursorMessageId, timestamp: String(row.timestamp) };
|
|
127
|
+
}
|
|
128
|
+
if (!cursorTimestampText) return null;
|
|
129
|
+
if (cursorMessageId && !ORDERABLE_ID_PATTERN.test(cursorMessageId)) return 'unsupported';
|
|
130
|
+
let canonical = cursorTimestampText;
|
|
131
|
+
if (!ISO_TIMESTAMP_PATTERN.test(canonical)) {
|
|
132
|
+
canonical = new Date(normalizeConversationTimestampMs(cursorTimestampText)).toISOString();
|
|
133
|
+
if (!ISO_TIMESTAMP_PATTERN.test(canonical)) return 'unsupported';
|
|
134
|
+
}
|
|
135
|
+
return { id: cursorMessageId || null, timestamp: canonical };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The page of rows (raw message rows, in conversation order) and its
|
|
140
|
+
* pageInfo, by the rules of selectConversationHistoryPage; null when the
|
|
141
|
+
* conversation or the cursor cannot be paged in SQL (see the top note).
|
|
142
|
+
*/
|
|
143
|
+
function selectConversationHistoryPageFromStatements(statements, conversationId, {
|
|
144
|
+
limit = 20,
|
|
145
|
+
beforeMessageId = '',
|
|
146
|
+
beforeTimestamp = '',
|
|
147
|
+
afterMessageId = '',
|
|
148
|
+
afterTimestamp = '',
|
|
149
|
+
aroundMessageId = '',
|
|
150
|
+
} = {}) {
|
|
151
|
+
if (statements.irregular.get({ conversationId })?.irregular) return null;
|
|
152
|
+
const pageLimit = normalizeConversationHistoryLimit(limit);
|
|
153
|
+
const aroundId = String(aroundMessageId || '').trim();
|
|
154
|
+
const requestedAroundWindow = !!aroundId;
|
|
155
|
+
const requestedAfterWindow = !!String(afterMessageId || '').trim();
|
|
156
|
+
const beforeCursor = resolveCursor(statements, conversationId, { messageId: beforeMessageId, timestamp: beforeTimestamp });
|
|
157
|
+
const afterCursor = resolveCursor(statements, conversationId, { messageId: afterMessageId, timestamp: afterTimestamp });
|
|
158
|
+
if (beforeCursor === 'unsupported' || afterCursor === 'unsupported') return null;
|
|
159
|
+
|
|
160
|
+
let rows = [];
|
|
161
|
+
if (aroundId) {
|
|
162
|
+
const anchor = statements.findById.get({ conversationId, id: aroundId });
|
|
163
|
+
if (anchor) {
|
|
164
|
+
// Enough rows on both sides for the in-memory window rules to give the
|
|
165
|
+
// same window as over the whole conversation.
|
|
166
|
+
const before = statements.older
|
|
167
|
+
.all({ conversationId, timestamp: anchor.timestamp, id: anchor.id, limit: pageLimit - 1 })
|
|
168
|
+
.reverse();
|
|
169
|
+
const fromAnchor = statements.fromInclusive
|
|
170
|
+
.all({ conversationId, timestamp: anchor.timestamp, id: anchor.id, limit: pageLimit });
|
|
171
|
+
const local = [...before, ...fromAnchor];
|
|
172
|
+
const aroundIndex = before.length;
|
|
173
|
+
const halfWindow = Math.floor((pageLimit - 1) / 2);
|
|
174
|
+
let start = Math.max(0, aroundIndex - halfWindow);
|
|
175
|
+
const end = Math.min(local.length, start + pageLimit);
|
|
176
|
+
if ((end - start) < pageLimit) start = Math.max(0, end - pageLimit);
|
|
177
|
+
rows = local.slice(start, end);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (!rows.length && !requestedAroundWindow && afterCursor) {
|
|
181
|
+
rows = statements.newer.all({ conversationId, ...afterCursor, limit: pageLimit });
|
|
182
|
+
}
|
|
183
|
+
if (!rows.length && !requestedAfterWindow && !requestedAroundWindow) {
|
|
184
|
+
rows = (beforeCursor
|
|
185
|
+
? statements.older.all({ conversationId, ...beforeCursor, limit: pageLimit })
|
|
186
|
+
: statements.tail.all({ conversationId, limit: pageLimit })
|
|
187
|
+
).reverse();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const first = rows[0] || null;
|
|
191
|
+
const last = rows[rows.length - 1] || null;
|
|
192
|
+
const hasMoreOlder = first
|
|
193
|
+
? !!statements.existsOlder.get({ conversationId, timestamp: first.timestamp, id: first.id })?.found
|
|
194
|
+
: false;
|
|
195
|
+
const hasMoreNewer = last
|
|
196
|
+
? !!statements.existsNewer.get({ conversationId, timestamp: last.timestamp, id: last.id })?.found
|
|
197
|
+
: false;
|
|
198
|
+
return { rows, pageInfo: buildConversationHistoryPageInfo(rows, { hasMoreOlder, hasMoreNewer }) };
|
|
199
|
+
}
|
|
@@ -25,7 +25,7 @@ function createSdkDeleteSessionCaller(sdkClient) {
|
|
|
25
25
|
|
|
26
26
|
import { cleanupGeneratedImagesForConversation } from './generated-image-cleanup-service.mjs';
|
|
27
27
|
|
|
28
|
-
export function createDeleteArchiveService(db, sdkClient, { resolveSessionStateRoot = null } = {}) {
|
|
28
|
+
export function createDeleteArchiveService(db, sdkClient, { resolveSessionStateRoot = null, conversationGroups = null } = {}) {
|
|
29
29
|
const callDeleteSession = createSdkDeleteSessionCaller(sdkClient);
|
|
30
30
|
|
|
31
31
|
const stmts = {
|
|
@@ -87,6 +87,9 @@ export function createDeleteArchiveService(db, sdkClient, { resolveSessionStateR
|
|
|
87
87
|
messageRows: stmts.listConversationMessages.all(conversationId),
|
|
88
88
|
resolveSessionStateRoot,
|
|
89
89
|
});
|
|
90
|
+
// A head leaves its session group (title frozen as the name) before its
|
|
91
|
+
// row goes; a group left empty goes with it.
|
|
92
|
+
try { conversationGroups?.forgetConversation?.(conversationId); } catch {}
|
|
90
93
|
stmts.hardDeleteConversation(conversationId);
|
|
91
94
|
}
|
|
92
95
|
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Detached-worker watch
|
|
3
|
+
//
|
|
4
|
+
// A worker whose process lives on but whose websocket to the relay is gone
|
|
5
|
+
// gets no deliveries, and the relay spawns no replacement while its process
|
|
6
|
+
// is alive: a message for its session would wait for ever. A worker on Node
|
|
7
|
+
// 22 ended up like that after a relay restart, its one reconnect having met a
|
|
8
|
+
// runtime that was not ready yet.
|
|
9
|
+
//
|
|
10
|
+
// The watch looks at the sessions that have a message waiting. One whose
|
|
11
|
+
// worker process is found alive with no socket, no turn running and no
|
|
12
|
+
// background work is replaced once it has been detached for the grace time:
|
|
13
|
+
// the old process is stopped and a new worker spawned, which takes the
|
|
14
|
+
// waiting message at its hello. The grace time counts from the latest of the
|
|
15
|
+
// watch's start (a relay start), the session's last socket end and its last
|
|
16
|
+
// spawn; a worker still booting gets twice as long.
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
export const DETACHED_WORKER_GRACE_MS = 60_000;
|
|
20
|
+
// A replacement that did not help (the new worker cannot connect either)
|
|
21
|
+
// is not repeated at once.
|
|
22
|
+
export const DETACHED_WORKER_REPLACE_COOLDOWN_MS = 5 * 60_000;
|
|
23
|
+
|
|
24
|
+
function normalizeSessionId(value) {
|
|
25
|
+
return String(value || '').trim();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function createDetachedWorkerWatch({
|
|
29
|
+
graceMs = DETACHED_WORKER_GRACE_MS,
|
|
30
|
+
replaceCooldownMs = DETACHED_WORKER_REPLACE_COOLDOWN_MS,
|
|
31
|
+
now = () => Date.now(),
|
|
32
|
+
// Whether the watch may act at all (routing on, no shutdown under way).
|
|
33
|
+
isEnabled = () => true,
|
|
34
|
+
// The session ids that have a message waiting for their worker.
|
|
35
|
+
listWaitingSessionIds = () => [],
|
|
36
|
+
hasWorkerSocket = () => true,
|
|
37
|
+
hasRunningTurn = () => true,
|
|
38
|
+
hasBackgroundWork = () => true,
|
|
39
|
+
// A kill just asked for, or a launch in flight: not the watch's business.
|
|
40
|
+
isLaunchBlocked = () => false,
|
|
41
|
+
// ({ launchMode, launchAtMs, awaitingHeartbeat }) for the session, or null.
|
|
42
|
+
getLaunchState = () => null,
|
|
43
|
+
// Resolves to whether a process of the session's worker is running.
|
|
44
|
+
isWorkerProcessAlive = async () => false,
|
|
45
|
+
// Stops the session's worker processes and spawns a new worker; resolves
|
|
46
|
+
// to { ok, error }.
|
|
47
|
+
replaceWorker = async () => ({ ok: false, error: 'not-configured' }),
|
|
48
|
+
log = () => {},
|
|
49
|
+
} = {}) {
|
|
50
|
+
const startedAtMs = Number(now());
|
|
51
|
+
const socketEndedAtMs = new Map();
|
|
52
|
+
const replacedAtMs = new Map();
|
|
53
|
+
const replacing = new Set();
|
|
54
|
+
let sweepInFlight = false;
|
|
55
|
+
|
|
56
|
+
function nowMs() {
|
|
57
|
+
const value = Number(now());
|
|
58
|
+
return Number.isFinite(value) ? value : Date.now();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function noteSocketEnded(sessionId) {
|
|
62
|
+
const sid = normalizeSessionId(sessionId);
|
|
63
|
+
if (sid) socketEndedAtMs.set(sid, nowMs());
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function forgetOld(atMs) {
|
|
67
|
+
const horizon = Math.max(graceMs * 2, replaceCooldownMs);
|
|
68
|
+
for (const [sid, at] of socketEndedAtMs) {
|
|
69
|
+
if (atMs - at > horizon) socketEndedAtMs.delete(sid);
|
|
70
|
+
}
|
|
71
|
+
for (const [sid, at] of replacedAtMs) {
|
|
72
|
+
if (atMs - at > horizon) replacedAtMs.delete(sid);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// How long the session's worker has been detached for the watch's
|
|
77
|
+
// purposes, and the grace it gets.
|
|
78
|
+
function detachedFor(sid, atMs) {
|
|
79
|
+
const launch = getLaunchState(sid) || null;
|
|
80
|
+
const launchAtMs = Number(launch?.launchAtMs || 0);
|
|
81
|
+
const spawnedAtMs = launch?.launchMode === 'spawned' ? launchAtMs : 0;
|
|
82
|
+
const anchorMs = Math.max(startedAtMs, Number(socketEndedAtMs.get(sid) || 0), spawnedAtMs);
|
|
83
|
+
const booting = launch?.launchMode === 'spawned' && launch?.awaitingHeartbeat === true;
|
|
84
|
+
return { detachedMs: atMs - anchorMs, graceMs: booting ? graceMs * 2 : graceMs };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function stillDetached(sid) {
|
|
88
|
+
return !hasWorkerSocket(sid) && !hasRunningTurn(sid) && !hasBackgroundWork(sid) && !isLaunchBlocked(sid);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function considerSession(sid, atMs) {
|
|
92
|
+
if (replacing.has(sid)) return false;
|
|
93
|
+
if (!stillDetached(sid)) return false;
|
|
94
|
+
const lastReplacedAtMs = replacedAtMs.get(sid);
|
|
95
|
+
if (lastReplacedAtMs && atMs - lastReplacedAtMs < replaceCooldownMs) return false;
|
|
96
|
+
const { detachedMs, graceMs: grace } = detachedFor(sid, atMs);
|
|
97
|
+
if (detachedMs < grace) return false;
|
|
98
|
+
if (!(await isWorkerProcessAlive(sid))) return false;
|
|
99
|
+
// The process probe can take a moment (a process list): the worker may
|
|
100
|
+
// have reconnected or taken a turn meanwhile.
|
|
101
|
+
if (!isEnabled() || !stillDetached(sid)) return false;
|
|
102
|
+
|
|
103
|
+
replacing.add(sid);
|
|
104
|
+
replacedAtMs.set(sid, atMs);
|
|
105
|
+
log(`WORKER DETACHED session=${sid.slice(0, 8)} detachedMs=${detachedMs} — no socket while a message waits; replacing the worker`);
|
|
106
|
+
try {
|
|
107
|
+
const result = await replaceWorker(sid, { detachedMs });
|
|
108
|
+
if (result?.ok) {
|
|
109
|
+
log(`WORKER DETACHED session=${sid.slice(0, 8)} replaced`);
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
log(`WORKER DETACHED session=${sid.slice(0, 8)} replace failed: ${String(result?.error || 'unknown')}`);
|
|
113
|
+
return false;
|
|
114
|
+
} catch (error) {
|
|
115
|
+
log(`WORKER DETACHED session=${sid.slice(0, 8)} replace failed: ${error?.message || error}`);
|
|
116
|
+
return false;
|
|
117
|
+
} finally {
|
|
118
|
+
replacing.delete(sid);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// One sweep at a time: a slow process list must not pile sweeps up.
|
|
123
|
+
async function sweep() {
|
|
124
|
+
if (sweepInFlight) return 0;
|
|
125
|
+
if (!isEnabled()) return 0;
|
|
126
|
+
sweepInFlight = true;
|
|
127
|
+
try {
|
|
128
|
+
const atMs = nowMs();
|
|
129
|
+
forgetOld(atMs);
|
|
130
|
+
const sessionIds = [...new Set((listWaitingSessionIds() || []).map(normalizeSessionId).filter(Boolean))];
|
|
131
|
+
let replaced = 0;
|
|
132
|
+
for (const sid of sessionIds) {
|
|
133
|
+
if (!isEnabled()) break;
|
|
134
|
+
try {
|
|
135
|
+
if (await considerSession(sid, atMs)) replaced += 1;
|
|
136
|
+
} catch (error) {
|
|
137
|
+
log(`WORKER DETACHED check failed session=${sid.slice(0, 8)} err=${error?.message || error}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return replaced;
|
|
141
|
+
} finally {
|
|
142
|
+
sweepInFlight = false;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
noteSocketEnded,
|
|
148
|
+
sweep,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The list of messages a conversation hides from its shared viewers: one
|
|
4
|
+
// short plain-text preview per message, the same shape as the pin list, so
|
|
5
|
+
// the owner's 🙈 list and its count cover messages outside the loaded page.
|
|
6
|
+
// Owner-only: the shared payload never carries it.
|
|
7
|
+
|
|
8
|
+
import { buildPinPreview, countAttachments } from './message-pin-list.mjs';
|
|
9
|
+
|
|
10
|
+
export function buildHiddenMessageList(rows) {
|
|
11
|
+
return (Array.isArray(rows) ? rows : [])
|
|
12
|
+
.filter((row) => String(row?.id || '').trim())
|
|
13
|
+
.map((row) => ({
|
|
14
|
+
messageId: String(row.id).trim(),
|
|
15
|
+
role: String(row.role || '').trim(),
|
|
16
|
+
preview: buildPinPreview(row.text, row.mode),
|
|
17
|
+
timestamp: row.timestamp || null,
|
|
18
|
+
hiddenAt: row.share_hidden_at || null,
|
|
19
|
+
attachmentCount: countAttachments(row.attachments),
|
|
20
|
+
}));
|
|
21
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
import { spawn } from 'node:child_process';
|
|
4
|
+
import { windowsSystemToolPath } from '../../shared/windows-system-tools.mjs';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* The actual suspend-to-RAM call. Windows only: `SetSuspendState 0,0,0` puts
|
|
@@ -25,7 +26,7 @@ export function runHostSuspendToRam({
|
|
|
25
26
|
return { ok: true, command, dryRun: true };
|
|
26
27
|
}
|
|
27
28
|
try {
|
|
28
|
-
const child = spawnImpl('rundll32.exe', ['powrprof.dll,SetSuspendState', '0,0,0'], {
|
|
29
|
+
const child = spawnImpl(windowsSystemToolPath('rundll32.exe'), ['powrprof.dll,SetSuspendState', '0,0,0'], {
|
|
29
30
|
detached: true,
|
|
30
31
|
stdio: 'ignore',
|
|
31
32
|
windowsHide: true,
|
|
@@ -9,7 +9,7 @@ import { stripRelayPromptContext } from './relay-prompt-sanitizer.mjs';
|
|
|
9
9
|
export const MESSAGE_PIN_LIMIT = 100;
|
|
10
10
|
export const PIN_PREVIEW_MAX_LENGTH = 160;
|
|
11
11
|
|
|
12
|
-
function countAttachments(raw) {
|
|
12
|
+
export function countAttachments(raw) {
|
|
13
13
|
if (Array.isArray(raw)) return raw.length;
|
|
14
14
|
if (typeof raw !== 'string' || !raw) return 0;
|
|
15
15
|
try {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import net from 'net';
|
|
4
4
|
|
|
5
|
-
// Liveness for preview cards. Previews never
|
|
5
|
+
// Liveness for preview cards. Previews never expire, so the card has to say
|
|
6
6
|
// whether the dev server behind it is still up — a TCP connect is enough and,
|
|
7
7
|
// unlike an HTTP request, leaves nothing in the app's request log.
|
|
8
8
|
|
|
@@ -55,6 +55,10 @@ export function createPreviewHealthProbe({
|
|
|
55
55
|
// A slow sweep must not overlap itself; with the registry capped at 8
|
|
56
56
|
// previews and a 250ms connect budget, one pass is bounded at ~2s anyway.
|
|
57
57
|
if (sweeping) return;
|
|
58
|
+
// Store writes that failed (a locked database) are retried on this beat,
|
|
59
|
+
// so the table catches up with memory without a timer of its own, and
|
|
60
|
+
// rows a restore could not judge get their check again.
|
|
61
|
+
try { registry.maintain?.(); } catch {}
|
|
58
62
|
const previews = registry.list().filter((entry) => entry.mode !== 'static');
|
|
59
63
|
if (previews.length === 0) return;
|
|
60
64
|
sweeping = true;
|
|
@@ -70,6 +74,9 @@ export function createPreviewHealthProbe({
|
|
|
70
74
|
timer = setIntervalImpl(() => { sweep().catch(() => {}); }, intervalMs);
|
|
71
75
|
// An idle relay should not be held awake by a probe that has nothing to do.
|
|
72
76
|
if (typeof timer?.unref === 'function') timer.unref();
|
|
77
|
+
// One pass right away: port previews restored at boot start without a
|
|
78
|
+
// health state, and their cards should not wait a full interval for one.
|
|
79
|
+
sweep().catch(() => {});
|
|
73
80
|
}
|
|
74
81
|
|
|
75
82
|
function stop() {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import http from 'http';
|
|
4
4
|
|
|
5
|
-
import { parsePreviewPath } from './preview-registry-service.mjs';
|
|
5
|
+
import { parsePreviewPath, previewLogId } from './preview-registry-service.mjs';
|
|
6
6
|
import { serveStaticPreview } from './preview-static-handler.mjs';
|
|
7
7
|
|
|
8
8
|
// The preview listener. A bare http.Server on its own loopback port whose
|
|
@@ -270,12 +270,16 @@ export function createPreviewProxyServer({
|
|
|
270
270
|
socket.once('error', forget);
|
|
271
271
|
}
|
|
272
272
|
|
|
273
|
-
|
|
273
|
+
// The token is the capability and the request URL carries it, so the line
|
|
274
|
+
// names the preview by a hash and logs only the upstream path, without query.
|
|
275
|
+
function logHit(entry, req, upstreamPath) {
|
|
274
276
|
const now = Date.now();
|
|
275
277
|
const last = lastHitLogAt.get(entry.token) || 0;
|
|
276
278
|
if (now - last < 60_000) return;
|
|
277
279
|
lastHitLogAt.set(entry.token, now);
|
|
278
|
-
|
|
280
|
+
const target = entry.mode === 'static' ? 'static' : `:${entry.targetPort}`;
|
|
281
|
+
const pathOnly = String(upstreamPath || '/').split('?')[0];
|
|
282
|
+
logger?.log?.(`[preview] ${previewLogId(entry.token)} → ${target} ${req.method} ${pathOnly} from ${clientIp(req)}`);
|
|
279
283
|
}
|
|
280
284
|
|
|
281
285
|
function resolveRequest(req) {
|
|
@@ -309,7 +313,7 @@ export function createPreviewProxyServer({
|
|
|
309
313
|
|
|
310
314
|
const { entry, upstreamPath } = resolved;
|
|
311
315
|
registry.recordHit(entry.token);
|
|
312
|
-
logHit(entry, req);
|
|
316
|
+
logHit(entry, req, upstreamPath);
|
|
313
317
|
|
|
314
318
|
// Static previews are answered in-process — no upstream, no forwarding.
|
|
315
319
|
if (entry.mode === 'static') {
|