@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
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
// The window of messages the page showed for each recently viewed
|
|
2
|
+
// conversation, with the payload it came in, in memory for this page load
|
|
3
|
+
// only. Switching back to one of them draws it at once from here, where the
|
|
4
|
+
// reader left it, older pages included; the fresh answer the switch asks for
|
|
5
|
+
// is merged into it when it arrives. The window follows the page: pages of
|
|
6
|
+
// older or newer history and the socket's live rows are added to it, and it
|
|
7
|
+
// is cut down to the retention cap when the reader leaves the conversation
|
|
8
|
+
// (conversation-window-retention.mjs).
|
|
9
|
+
// The live parts of a payload (a turn in flight, its state, tasks, draft,
|
|
10
|
+
// usage pause) are left out of the copy a read hands out: they are out of
|
|
11
|
+
// date the moment the payload is stored, and the fresh answer and the socket
|
|
12
|
+
// set them.
|
|
13
|
+
|
|
14
|
+
import { mergeTailPage, trimMessageWindow } from './transcript-reading-anchor.mjs';
|
|
15
|
+
|
|
16
|
+
/** Conversations kept; the least recently used goes first. */
|
|
17
|
+
export const CONVERSATION_PAYLOAD_CACHE_LIMIT = 8;
|
|
18
|
+
|
|
19
|
+
// Fields of a conversation payload that describe the moment it was read, not
|
|
20
|
+
// the transcript. A cached copy without them cannot end a turn, start the
|
|
21
|
+
// thinking bubble, or bring back a draft or a task list that has changed.
|
|
22
|
+
const LIVE_PAYLOAD_FIELDS = [
|
|
23
|
+
'inFlight',
|
|
24
|
+
'activeTurn',
|
|
25
|
+
'sessionState',
|
|
26
|
+
'backgroundTasks',
|
|
27
|
+
'endedBackgroundTasks',
|
|
28
|
+
'usageLimitPause',
|
|
29
|
+
'draftText',
|
|
30
|
+
'draftAttachments',
|
|
31
|
+
'draftUpdatedAt',
|
|
32
|
+
'draftUpdatedByClientId',
|
|
33
|
+
'previews',
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
// What the payload said about its own page; a read describes the kept
|
|
37
|
+
// window instead.
|
|
38
|
+
const PAGE_FIELDS = ['pageInfo', 'hasMoreHistory', 'historyCursor', 'historyNewestMessageId'];
|
|
39
|
+
|
|
40
|
+
function normalizeId(conversationId) {
|
|
41
|
+
return String(conversationId || '').trim();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function messageIdOf(message) {
|
|
45
|
+
return String(message?.id || '').trim();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function clonePayload(value) {
|
|
49
|
+
try {
|
|
50
|
+
return structuredClone(value);
|
|
51
|
+
} catch {
|
|
52
|
+
// A payload is plain JSON; this is only reached by an odd value in it.
|
|
53
|
+
return JSON.parse(JSON.stringify(value));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** True when the payload is a window with newer messages left unloaded. */
|
|
58
|
+
export function payloadHasMoreNewer(response) {
|
|
59
|
+
if (!response || typeof response !== 'object') return false;
|
|
60
|
+
if (typeof response.hasMoreNewer === 'boolean') return response.hasMoreNewer;
|
|
61
|
+
const pageInfo = response.pageInfo && typeof response.pageInfo === 'object' ? response.pageInfo : null;
|
|
62
|
+
return !!pageInfo?.hasMoreNewer;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** True when the payload has older messages left unloaded; read as renderMessages reads it. */
|
|
66
|
+
export function payloadHasMoreOlder(response) {
|
|
67
|
+
if (!response || typeof response !== 'object') return false;
|
|
68
|
+
if (typeof response.hasMoreOlder === 'boolean') return response.hasMoreOlder;
|
|
69
|
+
if (typeof response.hasMoreHistory === 'boolean') return response.hasMoreHistory;
|
|
70
|
+
const pageInfo = response.pageInfo && typeof response.pageInfo === 'object' ? response.pageInfo : null;
|
|
71
|
+
return !!pageInfo?.hasMoreOlder || !!pageInfo?.hasMore;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isCacheable(response) {
|
|
75
|
+
return !!response && typeof response === 'object' && Array.isArray(response.messages);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function cursorOf(message, direction) {
|
|
79
|
+
if (!message) return null;
|
|
80
|
+
const id = messageIdOf(message);
|
|
81
|
+
const timestamp = String(message.timestamp || '').trim() || null;
|
|
82
|
+
return direction === 'older'
|
|
83
|
+
? { beforeMessageId: id, beforeTimestamp: timestamp }
|
|
84
|
+
: { afterMessageId: id, afterTimestamp: timestamp };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The pageInfo of a kept window, in the relay's shape, so the loaders page on from its edges. */
|
|
88
|
+
export function windowPageInfo(messages, { hasMoreOlder = false, hasMoreNewer = false } = {}) {
|
|
89
|
+
const list = Array.isArray(messages) ? messages : [];
|
|
90
|
+
const first = list[0] || null;
|
|
91
|
+
const last = list[list.length - 1] || null;
|
|
92
|
+
return {
|
|
93
|
+
hasMore: !!hasMoreOlder,
|
|
94
|
+
hasMoreOlder: !!hasMoreOlder,
|
|
95
|
+
hasMoreNewer: !!hasMoreNewer,
|
|
96
|
+
nextCursor: cursorOf(first, 'older'),
|
|
97
|
+
olderCursor: cursorOf(first, 'older'),
|
|
98
|
+
newerCursor: cursorOf(last, 'newer'),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function windowPayload(entry) {
|
|
103
|
+
const transcriptOnly = { ...entry.payload };
|
|
104
|
+
for (const field of LIVE_PAYLOAD_FIELDS) delete transcriptOnly[field];
|
|
105
|
+
for (const field of PAGE_FIELDS) delete transcriptOnly[field];
|
|
106
|
+
const flags = { hasMoreOlder: entry.hasMoreOlder, hasMoreNewer: entry.hasMoreNewer };
|
|
107
|
+
return {
|
|
108
|
+
...transcriptOnly,
|
|
109
|
+
messages: entry.messages,
|
|
110
|
+
...flags,
|
|
111
|
+
pageInfo: windowPageInfo(entry.messages, flags),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The copy of a payload the cache hands out, without its live fields, or
|
|
117
|
+
* null when it must not be kept. The live fields are left out before the
|
|
118
|
+
* clone: a turn in flight can carry thousands of thought rows nobody reads.
|
|
119
|
+
*/
|
|
120
|
+
export function cacheablePayload(response) {
|
|
121
|
+
if (!isCacheable(response)) return null;
|
|
122
|
+
return clonePayload(windowPayload(entryFor(response)));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function entryFor(response) {
|
|
126
|
+
return {
|
|
127
|
+
payload: response,
|
|
128
|
+
messages: response.messages,
|
|
129
|
+
hasMoreOlder: payloadHasMoreOlder(response),
|
|
130
|
+
hasMoreNewer: payloadHasMoreNewer(response),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// The messages of `incoming` whose ids the window lacks, in their order.
|
|
135
|
+
function unseen(entry, incoming) {
|
|
136
|
+
const known = new Set(entry.messages.map(messageIdOf).filter(Boolean));
|
|
137
|
+
return (Array.isArray(incoming) ? incoming : []).filter((message) => {
|
|
138
|
+
const id = messageIdOf(message);
|
|
139
|
+
if (!message || !id || known.has(id)) return false;
|
|
140
|
+
known.add(id);
|
|
141
|
+
return true;
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* A reload of the end of the conversation on screen (the live poll, a view
|
|
147
|
+
* refresh) merged into the window the page holds, or null when the plain
|
|
148
|
+
* render of the answer is right. The relay caps a page at 100 messages: with
|
|
149
|
+
* more loaded, a render of the answer would cut the page and the kept window
|
|
150
|
+
* down to it. Merged only when both the window and the answer reach the end,
|
|
151
|
+
* the window holds more than the answer, and the two meet (mergeTailPage).
|
|
152
|
+
* `mergeFrom` is the window as it is on the page, `payload` the answer with
|
|
153
|
+
* the merged window as its messages and the window's edges.
|
|
154
|
+
* `windowPredatesPage`: no live row reached the window while the answer was
|
|
155
|
+
* on its way (see mergeTailPage), so a newer window row the answer lacks was
|
|
156
|
+
* deleted, not brought by the socket after the read.
|
|
157
|
+
*/
|
|
158
|
+
export function mergeReloadIntoWindow(kept, response, { windowPredatesPage = false } = {}) {
|
|
159
|
+
if (!kept || !Array.isArray(kept.messages) || !isCacheable(response)) return null;
|
|
160
|
+
if (kept.hasMoreNewer || payloadHasMoreNewer(response)) return null;
|
|
161
|
+
if (kept.messages.length <= response.messages.length) return null;
|
|
162
|
+
const merged = mergeTailPage(kept.messages, response.messages, {
|
|
163
|
+
pageHasMoreOlder: payloadHasMoreOlder(response),
|
|
164
|
+
windowPredatesPage,
|
|
165
|
+
});
|
|
166
|
+
if (!merged) return null;
|
|
167
|
+
const flags = { hasMoreOlder: !!kept.hasMoreOlder, hasMoreNewer: false };
|
|
168
|
+
return {
|
|
169
|
+
mergeFrom: kept.messages,
|
|
170
|
+
payload: {
|
|
171
|
+
...response,
|
|
172
|
+
messages: merged.messages,
|
|
173
|
+
...flags,
|
|
174
|
+
pageInfo: windowPageInfo(merged.messages, flags),
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function createConversationPayloadCache({ limit = CONVERSATION_PAYLOAD_CACHE_LIMIT } = {}) {
|
|
180
|
+
const maxEntries = Math.max(1, Math.trunc(Number(limit) || CONVERSATION_PAYLOAD_CACHE_LIMIT));
|
|
181
|
+
// A Map keeps insertion order: the first key is the least recently used.
|
|
182
|
+
const entries = new Map();
|
|
183
|
+
|
|
184
|
+
function forget(conversationId) {
|
|
185
|
+
const id = normalizeId(conversationId);
|
|
186
|
+
if (!id) return false;
|
|
187
|
+
return entries.delete(id);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Keeps the payload just applied for the conversation: its messages are the
|
|
192
|
+
* window the page shows. A payload that may not be kept drops the older one
|
|
193
|
+
* too: the page no longer shows it.
|
|
194
|
+
* Only a reference is kept: this runs on every apply, the live poll during
|
|
195
|
+
* a turn included, and a fresh payload from the relay is not changed once
|
|
196
|
+
* it is on the page. The copy is made on read, when a switch needs it; the
|
|
197
|
+
* window's own changes below make new arrays and never touch the payload.
|
|
198
|
+
*/
|
|
199
|
+
function remember(conversationId, response) {
|
|
200
|
+
const id = normalizeId(conversationId);
|
|
201
|
+
if (!id) return false;
|
|
202
|
+
entries.delete(id);
|
|
203
|
+
if (!isCacheable(response)) return false;
|
|
204
|
+
entries.set(id, entryFor(response));
|
|
205
|
+
while (entries.size > maxEntries) {
|
|
206
|
+
entries.delete(entries.keys().next().value);
|
|
207
|
+
}
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Changes to a window do not count as use: a conversation still open is
|
|
212
|
+
// the most recently used anyway, and one read last keeps its place.
|
|
213
|
+
function entryOf(conversationId) {
|
|
214
|
+
return entries.get(normalizeId(conversationId)) || null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** An older page was put above the window. */
|
|
218
|
+
function addOlder(conversationId, messages, { hasMoreOlder = false } = {}) {
|
|
219
|
+
const entry = entryOf(conversationId);
|
|
220
|
+
if (!entry) return false;
|
|
221
|
+
entry.messages = [...unseen(entry, messages), ...entry.messages];
|
|
222
|
+
entry.hasMoreOlder = !!hasMoreOlder;
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** A newer page was put below the window. */
|
|
227
|
+
function addNewer(conversationId, messages, { hasMoreNewer = false } = {}) {
|
|
228
|
+
const entry = entryOf(conversationId);
|
|
229
|
+
if (!entry) return false;
|
|
230
|
+
entry.messages = [...entry.messages, ...unseen(entry, messages)];
|
|
231
|
+
entry.hasMoreNewer = !!hasMoreNewer;
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* A live row from the socket: it replaces the message with its id, or joins
|
|
237
|
+
* the end of a window that is the end of the conversation. A window in the
|
|
238
|
+
* middle of the history leaves it to the newer pages.
|
|
239
|
+
*/
|
|
240
|
+
function upsertMessage(conversationId, message) {
|
|
241
|
+
const entry = entryOf(conversationId);
|
|
242
|
+
const messageId = messageIdOf(message);
|
|
243
|
+
if (!entry || !messageId) return false;
|
|
244
|
+
const index = entry.messages.findIndex((item) => messageIdOf(item) === messageId);
|
|
245
|
+
if (index >= 0) {
|
|
246
|
+
entry.messages = entry.messages.map((item, at) => (at === index ? message : item));
|
|
247
|
+
return true;
|
|
248
|
+
}
|
|
249
|
+
if (entry.hasMoreNewer) return false;
|
|
250
|
+
entry.messages = [...entry.messages, message];
|
|
251
|
+
return true;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** The window no longer meets the end of the conversation (more was written than one page holds). */
|
|
255
|
+
function detachFromTail(conversationId) {
|
|
256
|
+
const entry = entryOf(conversationId);
|
|
257
|
+
if (!entry) return false;
|
|
258
|
+
entry.hasMoreNewer = true;
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Cuts the window down to `cap` messages around the reader's place
|
|
264
|
+
* (trimMessageWindow). True when rows were dropped.
|
|
265
|
+
*/
|
|
266
|
+
function trim(conversationId, { cap = Infinity, anchorMessageId = '', atTail = false } = {}) {
|
|
267
|
+
const entry = entryOf(conversationId);
|
|
268
|
+
if (!entry) return false;
|
|
269
|
+
const next = trimMessageWindow(entry.messages, {
|
|
270
|
+
cap,
|
|
271
|
+
anchorMessageId,
|
|
272
|
+
atTail,
|
|
273
|
+
hasMoreOlder: entry.hasMoreOlder,
|
|
274
|
+
hasMoreNewer: entry.hasMoreNewer,
|
|
275
|
+
});
|
|
276
|
+
if (!next.trimmed) return false;
|
|
277
|
+
entry.messages = next.messages;
|
|
278
|
+
entry.hasMoreOlder = next.hasMoreOlder;
|
|
279
|
+
entry.hasMoreNewer = next.hasMoreNewer;
|
|
280
|
+
return true;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** `anchorOf(id)` gives the reader's place in each kept conversation. */
|
|
284
|
+
function trimAll(cap, anchorOf = () => null, { except = '' } = {}) {
|
|
285
|
+
const skip = normalizeId(except);
|
|
286
|
+
let trimmed = 0;
|
|
287
|
+
for (const id of [...entries.keys()]) {
|
|
288
|
+
if (id === skip) continue;
|
|
289
|
+
const anchor = anchorOf(id) || null;
|
|
290
|
+
if (trim(id, { cap, anchorMessageId: anchor?.messageId || '', atTail: anchor?.atTail === true })) trimmed += 1;
|
|
291
|
+
}
|
|
292
|
+
return trimmed;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* A copy of the kept window without its live fields (the caller may change
|
|
297
|
+
* it), or null. Its messages are the window, its pageInfo and flags
|
|
298
|
+
* describe the window's edges.
|
|
299
|
+
*/
|
|
300
|
+
function read(conversationId) {
|
|
301
|
+
const id = normalizeId(conversationId);
|
|
302
|
+
if (!id || !entries.has(id)) return null;
|
|
303
|
+
const entry = entries.get(id);
|
|
304
|
+
entries.delete(id);
|
|
305
|
+
entries.set(id, entry);
|
|
306
|
+
return clonePayload(windowPayload(entry));
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function has(conversationId) {
|
|
310
|
+
return entries.has(normalizeId(conversationId));
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* The window as it is, uncopied, without counting as use: for a reload of
|
|
315
|
+
* the conversation on screen, which reads it on every poll. The caller must
|
|
316
|
+
* not change it (the window's own changes make new arrays, so a held
|
|
317
|
+
* reference stays as it was read).
|
|
318
|
+
*/
|
|
319
|
+
function peekWindow(conversationId) {
|
|
320
|
+
const entry = entryOf(conversationId);
|
|
321
|
+
if (!entry) return null;
|
|
322
|
+
return { messages: entry.messages, hasMoreOlder: entry.hasMoreOlder, hasMoreNewer: entry.hasMoreNewer };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** How many messages the window holds (0 when none is kept). */
|
|
326
|
+
function windowSize(conversationId) {
|
|
327
|
+
return entryOf(conversationId)?.messages.length || 0;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function clear() {
|
|
331
|
+
entries.clear();
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return {
|
|
335
|
+
remember,
|
|
336
|
+
addOlder,
|
|
337
|
+
addNewer,
|
|
338
|
+
upsertMessage,
|
|
339
|
+
detachFromTail,
|
|
340
|
+
trim,
|
|
341
|
+
trimAll,
|
|
342
|
+
read,
|
|
343
|
+
peekWindow,
|
|
344
|
+
forget,
|
|
345
|
+
has,
|
|
346
|
+
windowSize,
|
|
347
|
+
clear,
|
|
348
|
+
keys: () => [...entries.keys()],
|
|
349
|
+
get size() {
|
|
350
|
+
return entries.size;
|
|
351
|
+
},
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// The page's one cache: the conversation views write and read it, the socket
|
|
356
|
+
// handlers drop entries the relay has changed.
|
|
357
|
+
export const conversationPayloadCache = createConversationPayloadCache();
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// A conversation record's turn fields (`activeTurn`, `sessionState`,
|
|
2
|
+
// `backgroundTaskCount`) kept in step with the live events, for every row
|
|
3
|
+
// and not only the open one. The list fetch sets them; between fetches the
|
|
4
|
+
// `message_status` and `background_tasks` events are the only word on what
|
|
5
|
+
// a session is doing, and the sidebar's title colour, a group head's pill and its
|
|
6
|
+
// fold read these fields. DOM-free.
|
|
7
|
+
|
|
8
|
+
const TERMINAL_STATUSES = new Set(['done', 'failed', 'dropped', 'cancelled']);
|
|
9
|
+
const QUEUED_STATUSES = new Set(['pending', 'parked']);
|
|
10
|
+
|
|
11
|
+
function runningTaskCount(tasks) {
|
|
12
|
+
return (Array.isArray(tasks) ? tasks : []).filter((task) => {
|
|
13
|
+
const status = String(task?.status || 'running').trim().toLowerCase();
|
|
14
|
+
return !status || status === 'running';
|
|
15
|
+
}).length;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function settleState(conversation) {
|
|
19
|
+
conversation.sessionState = Number(conversation.backgroundTaskCount || 0) > 0 ? 'background_only' : 'idle';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A `message_status` for the conversation. Processing: a turn runs. Pending
|
|
24
|
+
* or parked: a turn is queued (the relay counts it as active). A terminal
|
|
25
|
+
* status: the turn is over; background work, if any was reported, carries
|
|
26
|
+
* the state. Another message still queued is learnt from its own status or
|
|
27
|
+
* the next list fetch. Returns true when a field changed.
|
|
28
|
+
*/
|
|
29
|
+
export function applyTurnStatusToConversation(conversation, status) {
|
|
30
|
+
if (!conversation || typeof conversation !== 'object') return false;
|
|
31
|
+
const normalized = String(status || '').trim().toLowerCase();
|
|
32
|
+
const before = `${conversation.activeTurn}|${conversation.sessionState}`;
|
|
33
|
+
if (normalized === 'processing') {
|
|
34
|
+
conversation.activeTurn = true;
|
|
35
|
+
conversation.sessionState = 'running_turn';
|
|
36
|
+
} else if (QUEUED_STATUSES.has(normalized)) {
|
|
37
|
+
conversation.activeTurn = true;
|
|
38
|
+
if (conversation.sessionState !== 'running_turn') settleState(conversation);
|
|
39
|
+
} else if (TERMINAL_STATUSES.has(normalized)) {
|
|
40
|
+
conversation.activeTurn = false;
|
|
41
|
+
settleState(conversation);
|
|
42
|
+
} else {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
return before !== `${conversation.activeTurn}|${conversation.sessionState}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* What a fetched payload says (a list row, or the single conversation): the
|
|
50
|
+
* relay's own view of the session is the truth whenever one arrives; the
|
|
51
|
+
* events only move it forward until the next. `sessionState` names the
|
|
52
|
+
* state, `sessionTasks` (list) or `backgroundTasks` (single) the live set;
|
|
53
|
+
* a `background_only` state without a set still counts as one task.
|
|
54
|
+
* Returns true when a field changed.
|
|
55
|
+
*/
|
|
56
|
+
export function applyFetchedTurnStateToConversation(conversation, payload) {
|
|
57
|
+
if (!conversation || typeof conversation !== 'object' || !payload || typeof payload !== 'object') return false;
|
|
58
|
+
const before = `${conversation.activeTurn}|${conversation.sessionState}|${conversation.backgroundTaskCount}`;
|
|
59
|
+
if (typeof payload.activeTurn === 'boolean') conversation.activeTurn = payload.activeTurn;
|
|
60
|
+
const state = String(payload.sessionState || '').trim().toLowerCase();
|
|
61
|
+
if (state) {
|
|
62
|
+
conversation.sessionState = state;
|
|
63
|
+
const tasks = Array.isArray(payload.sessionTasks) ? payload.sessionTasks
|
|
64
|
+
: (Array.isArray(payload.backgroundTasks) ? payload.backgroundTasks : null);
|
|
65
|
+
conversation.backgroundTaskCount = tasks ? runningTaskCount(tasks) : (state === 'background_only' ? 1 : 0);
|
|
66
|
+
} else if (Array.isArray(payload.backgroundTasks)) {
|
|
67
|
+
conversation.backgroundTaskCount = runningTaskCount(payload.backgroundTasks);
|
|
68
|
+
if (conversation.sessionState !== 'running_turn' && conversation.activeTurn !== true) settleState(conversation);
|
|
69
|
+
}
|
|
70
|
+
return before !== `${conversation.activeTurn}|${conversation.sessionState}|${conversation.backgroundTaskCount}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A `background_tasks` set for the conversation (replace semantics). Returns true when a field changed. */
|
|
74
|
+
export function applyBackgroundTasksToConversation(conversation, tasks) {
|
|
75
|
+
if (!conversation || typeof conversation !== 'object') return false;
|
|
76
|
+
const before = `${conversation.backgroundTaskCount}|${conversation.sessionState}`;
|
|
77
|
+
conversation.backgroundTaskCount = runningTaskCount(tasks);
|
|
78
|
+
if (conversation.sessionState !== 'running_turn' && conversation.activeTurn !== true) settleState(conversation);
|
|
79
|
+
else if (conversation.sessionState !== 'running_turn') settleState(conversation);
|
|
80
|
+
return before !== `${conversation.backgroundTaskCount}|${conversation.sessionState}`;
|
|
81
|
+
}
|