@oar-sh/oar 0.9.2 → 0.9.4
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/.github/extensions/web-relay/extension.mjs +6 -1
- package/.github/extensions/web-relay/skills/tool-activity.mjs +7 -0
- package/CHANGELOG.md +336 -0
- package/README.md +455 -423
- package/bin/oar.js +25 -10
- package/package.json +11 -5
- package/server/README.md +44 -4
- package/server/claude-worker/claude-remote-relay-tool.mjs +99 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +26 -4
- package/server/claude-worker/claude-session-process.mjs +1073 -69
- package/server/claude-worker/claude-session-worker.mjs +45 -4
- package/server/claude-worker/claude-turn-publisher.mjs +81 -20
- package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +7 -0
- package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
- package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
- package/server/copilot-worker/copilot-question-bridge.mjs +34 -12
- package/server/copilot-worker/copilot-remote-relay-tool.mjs +59 -0
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +113 -14
- package/server/copilot-worker/copilot-sdk-session-process.mjs +2349 -498
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +82 -20
- package/server/cursor-worker/cursor-remote-relay-tool.mjs +65 -0
- package/server/cursor-worker/cursor-turn-runner.mjs +45 -2
- package/server/cursor-worker/sdk-message-normalizer.mjs +25 -0
- package/server/db-schema.mjs +28 -0
- package/server/grok-worker/acp-client.mjs +92 -6
- package/server/grok-worker/grok-turn-runner.mjs +59 -8
- package/server/grok-worker/sdk-message-normalizer.mjs +13 -0
- package/server/mcp/oar-mcp-server.mjs +473 -0
- package/server/migrations/0004-steer-settle-markers.mjs +119 -0
- package/server/migrations/0005-remote-relays.mjs +90 -0
- package/server/public/app/action-confirmations.js +141 -13
- package/server/public/app/api-client.js +125 -2
- package/server/public/app/ask-user-view.js +102 -13
- package/server/public/app/attachments-view.js +37 -2
- package/server/public/app/background-task-timeout-options.mjs +9 -0
- package/server/public/app/background-tasks-view.mjs +33 -18
- package/server/public/app/bootstrap.js +55 -14
- package/server/public/app/composer-control-state.mjs +52 -34
- package/server/public/app/conversation-deep-link.mjs +42 -0
- package/server/public/app/conversation-draft-sync.mjs +187 -0
- package/server/public/app/conversation-list-filter.mjs +86 -0
- package/server/public/app/conversation-provider-indicator.mjs +8 -0
- package/server/public/app/conversation-view.js +1016 -200
- package/server/public/app/emoji-view.js +2 -0
- package/server/public/app/font-scaling.js +4 -37
- package/server/public/app/host-suspend-ui.js +272 -0
- package/server/public/app/journal-view.js +149 -6
- package/server/public/app/mention-autocomplete.mjs +298 -0
- package/server/public/app/model-selector-options.mjs +1 -1
- package/server/public/app/push-settings.js +1 -0
- package/server/public/app/pwa-install.js +8 -3
- package/server/public/app/question-multi-select.mjs +52 -0
- package/server/public/app/relay-prompt-sanitizer.mjs +61 -3
- package/server/public/app/remote-relay-origin-view.mjs +76 -0
- package/server/public/app/remote-relay-shared.mjs +85 -0
- package/server/public/app/remote-relays-settings.js +618 -0
- package/server/public/app/remote-relays-store.mjs +126 -0
- package/server/public/app/settings-modal.js +12 -8
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +37 -1
- package/server/public/app/steered-turn-merge.mjs +100 -0
- package/server/public/app/store.js +126 -15
- package/server/public/app/sync-outbox.mjs +16 -8
- package/server/public/app/thinking-follow.js +260 -0
- package/server/public/app/tool-activity-emoji.mjs +40 -0
- package/server/public/app/transcript-viewport-anchor.mjs +210 -0
- package/server/public/index.html +463 -17
- package/server/public/sw.js +7 -0
- package/server/relay-tools.md +7 -1
- package/server/repositories/message-repository.mjs +68 -18
- package/server/repositories/question-repository.mjs +18 -0
- package/server/repositories/remote-relay-repository.mjs +75 -0
- package/server/repositories/session-repository.mjs +13 -32
- package/server/routes/ask-user-routes.mjs +42 -2
- package/server/routes/messages-routes.mjs +572 -56
- package/server/routes/remote-relay-routes.mjs +133 -0
- package/server/routes/sessions-routes.mjs +378 -84
- package/server/server-runtime.mjs +303 -25
- package/server/services/github-ci-activity-service.mjs +199 -0
- package/server/services/host-suspend-command.mjs +38 -0
- package/server/services/host-suspend-service.mjs +254 -0
- package/server/services/live-turn-picker.mjs +47 -0
- package/server/services/push-dispatch-service.mjs +37 -4
- package/server/services/relay-prompt-sanitizer.mjs +17 -2
- package/server/services/relay-question-context.mjs +46 -0
- package/server/services/remote-relay-caller-context.mjs +341 -0
- package/server/services/remote-relay-client.mjs +332 -0
- package/server/services/remote-relay-dispatcher.mjs +1500 -0
- package/server/services/remote-relay-inbound.mjs +187 -0
- package/server/services/remote-relay-pairing.mjs +189 -0
- package/server/services/remote-relay-registry-service.mjs +581 -0
- package/server/services/sdk-session-import-service.mjs +49 -7
- package/server/services/session-worker-launch-service.mjs +131 -12
- package/server/services/session-worker-registry-service.mjs +75 -0
- package/server/services/session-worker-supervisor-service.mjs +7 -1
- package/server/services/session-worker-websocket-service.mjs +23 -1
- package/server/services/steer-resend.mjs +45 -0
- package/shared/background-task-timeout.mjs +12 -4
- package/shared/control-poller.mjs +4 -1
- package/shared/question-multi-select.mjs +16 -0
- package/shared/relay-stop-failure.mjs +15 -0
- package/shared/remote-relay-contract.mjs +539 -0
- package/shared/remote-relay-mentions.mjs +129 -0
- package/shared/remote-relay-tool-core.mjs +314 -0
- package/shared/slash-command-guard.mjs +76 -0
- package/shared/steer-note.mjs +66 -0
- package/shared/steer-settle-failure.mjs +89 -0
- package/shared/steer-settle-markers.mjs +18 -0
- package/shared/worker-crash-guard.mjs +40 -25
- package/shared/worker-runtime/api-client.mjs +112 -22
- package/shared/worker-runtime/heartbeat.mjs +55 -1
- package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +119 -8
- package/server/claude-worker/claude-session-test-harness.mjs +0 -250
- package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
- package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -382
- package/server/copilot-worker/fixtures/abort-turn.json +0 -588
- package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
- package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
- package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
- package/server/copilot-worker/fixtures/happy-turn.json +0 -566
- package/server/copilot-worker/fixtures/quota-turn.json +0 -647
- package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
- package/server/copilot-worker/fixtures/resume-turn.json +0 -700
- package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
- package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
- package/server/routes/messages-routes-test-harness.mjs +0 -111
- package/server/services/fixtures/claude-auth-stub.cmd +0 -1
- package/server/services/fixtures/claude-auth-stub.mjs +0 -76
- package/server/services/fixtures/claude-auth-stub.sh +0 -4
- package/server/services/fixtures/cli-install-stub.cmd +0 -1
- package/server/services/fixtures/cli-install-stub.mjs +0 -98
- package/server/services/fixtures/cli-install-stub.sh +0 -4
- package/server/services/fixtures/grok-stub.cmd +0 -1
- package/server/services/fixtures/grok-stub.mjs +0 -89
- package/server/services/fixtures/grok-stub.sh +0 -4
- package/shared/fixtures/copilot-catalog-raw-2026-09-07.json +0 -2363
package/server/public/sw.js
CHANGED
|
@@ -205,6 +205,9 @@ const SYNC_DB_VERSION = 1;
|
|
|
205
205
|
const SYNC_STORE_NAME = 'outbox';
|
|
206
206
|
const SYNC_TAG = 'copilot-outbox';
|
|
207
207
|
const SYNC_MAX_REPLAY_ATTEMPTS = 10;
|
|
208
|
+
// Mirrors DRAFT_FLUSH_MAX_AGE_MS in app/conversation-draft-sync.mjs: a draft
|
|
209
|
+
// flush replayed later than this would overwrite whatever the draft became.
|
|
210
|
+
const SYNC_DRAFT_FLUSH_MAX_AGE_MS = 5 * 60 * 1000;
|
|
208
211
|
|
|
209
212
|
function openSyncDb() {
|
|
210
213
|
return new Promise((resolve, reject) => {
|
|
@@ -271,6 +274,10 @@ async function replaySyncOutbox() {
|
|
|
271
274
|
try {
|
|
272
275
|
const entries = await readSyncEntries(db);
|
|
273
276
|
for (const { key, value } of entries) {
|
|
277
|
+
if (value?.kind === 'draft-flush' && !(Date.now() - Number(value.createdAt || 0) <= SYNC_DRAFT_FLUSH_MAX_AGE_MS)) {
|
|
278
|
+
await deleteSyncEntry(db, key);
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
274
281
|
let response = null;
|
|
275
282
|
try {
|
|
276
283
|
response = await fetch(scopeUrl(String(value.path || '').replace(/^\//, '')), {
|
package/server/relay-tools.md
CHANGED
|
@@ -12,6 +12,8 @@ When using ask_user, ALWAYS include a `choices` array with 2-6 answer options so
|
|
|
12
12
|
At runtime, inject the question and choices from a random item in your quiz pool.
|
|
13
13
|
Only omit choices when the question genuinely requires freeform text input (e.g., "What is your name?").
|
|
14
14
|
|
|
15
|
+
When the user may pick several of the choices (they are not mutually exclusive), set `"multi_select": true` if your ask_user tool offers that field; otherwise begin the question with "Select all that apply:". Either way, expect an answer that lists several choices separated by ", ".
|
|
16
|
+
|
|
15
17
|
In autopilot, still call ask_user when user input is truly blocking, because the relay bridge can surface the question even when the direct SDK question hook is bypassed.
|
|
16
18
|
|
|
17
19
|
For relay restarts in extension-managed mode, require explicit user permission first, then use the authenticated localhost API `POST /api/relay/shutdown`. Do not restart by killing processes or using respawn scripts.
|
|
@@ -24,4 +26,8 @@ Use `restart: true` in the request body when the user wants a real relay restart
|
|
|
24
26
|
|
|
25
27
|
Publish a local web server or a static directory on a public preview URL so the user can open it from any device, or list/close existing previews. Use this whenever the user wants to see, try, or share something with a web UI — never ask them to open localhost or forward a port. For a dev server: call this FIRST with {action:"create", port} to get the basePath, then start the server configured to serve under that basePath (Vite --base, Next basePath, Express mount prefix); the proxy forwards X-Forwarded-Prefix but does not rewrite bodies, so root-absolute asset paths will not work. For plain files or a build output: pass {action:"create", dir} instead and the relay serves the directory itself — no dev server needed. Always tell the user the returned URL and that the link is public: anyone who has it can reach the app without logging in. Previews never expire on their own; close them with {action:"close"} when the user is done.
|
|
26
28
|
|
|
27
|
-
|
|
29
|
+
If you have a `preview` tool, use it. Otherwise use the authenticated localhost API (same auth as `POST /api/relay/shutdown`): `POST /api/previews` with `{ "conversationId": "<conv>", "port": 5173, "label": "web app" }` (or `"dir": "./dist"` instead of the port) publishes, `GET /api/previews` lists, `DELETE /api/previews/:token` closes the link without touching the dev server behind it. A 503 with `details` means the preview lane is disabled or misconfigured — surface those details, they are the operator's fix. See `docs/preview-servers.md`.
|
|
30
|
+
|
|
31
|
+
## Remote relays
|
|
32
|
+
|
|
33
|
+
If you have a `remote_relay` tool, this relay is paired with other OAR relays and the tool works on them: list and read their sessions, prompt a session, start a new one and wait for its reply. Use it only when the user asks for work on another relay or mentions one, and start with `{"action":"list_relays"}`. A relay stays locked until the user mentions it in this conversation (`@name` or its plain name); a locked relay refuses everything except `list_relays`, so ask the user to mention it rather than retrying. Write actions may first wait for the user's approval. If a result lists `pendingQuestions`, the remote agent is waiting for an answer: ask the user (unless they told you to decide), then pass it on with `answer_question`. Always tell the user which relay and session you worked on.
|
|
@@ -7,6 +7,9 @@ export function createMessageRepository(db) {
|
|
|
7
7
|
const shareVisibility = createShareVisibilityStatements(db);
|
|
8
8
|
const queueHasImageOperationId = db.prepare(`PRAGMA table_info(queue)`).all()
|
|
9
9
|
.some((column) => column.name === 'image_operation_id');
|
|
10
|
+
const messageColumns = new Set(db.prepare(`PRAGMA table_info(messages)`).all().map((column) => column.name));
|
|
11
|
+
const messagesHaveSourceId = messageColumns.has('source_message_id');
|
|
12
|
+
const messagesHaveResendOf = messageColumns.has('resend_of_message_id');
|
|
10
13
|
const insertQueueSql = queueHasImageOperationId
|
|
11
14
|
? `INSERT INTO queue (id, conversation_id, runtime_session_id, is_new_conversation, model, model_variant_id, reasoning_effort, context_tier, relay_mode, text, attachments, status, timestamp, retry_count, next_attempt_at, owner_sdk_session_id, owner_assigned_at, owner_lease_expires_at, owner_last_claimed_at, image_operation_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, 0, NULL, ?, ?, ?, ?, ?)`
|
|
12
15
|
: `INSERT INTO queue (id, conversation_id, runtime_session_id, is_new_conversation, model, model_variant_id, reasoning_effort, context_tier, relay_mode, text, attachments, status, timestamp, retry_count, next_attempt_at, owner_sdk_session_id, owner_assigned_at, owner_lease_expires_at, owner_last_claimed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, 0, NULL, ?, ?, ?, ?)`;
|
|
@@ -17,7 +20,7 @@ export function createMessageRepository(db) {
|
|
|
17
20
|
getMessageByConversation: db.prepare(`SELECT * FROM messages WHERE id = ? AND conversation_id = ? LIMIT 1`),
|
|
18
21
|
setMessageShareVisibility: shareVisibility.setMessageShareVisibility,
|
|
19
22
|
getLatestConversationModel: db.prepare(`SELECT model FROM messages WHERE conversation_id = ? AND model IS NOT NULL AND model != '' ORDER BY timestamp DESC LIMIT 1`),
|
|
20
|
-
getRecentMessagesDesc: db.prepare(`SELECT role, text, timestamp FROM messages WHERE conversation_id = ? ORDER BY timestamp DESC LIMIT ?`),
|
|
23
|
+
getRecentMessagesDesc: db.prepare(`SELECT id, role, text, timestamp FROM messages WHERE conversation_id = ? ORDER BY timestamp DESC LIMIT ?`),
|
|
21
24
|
insertMsg: db.prepare(`INSERT INTO messages (id, conversation_id, role, text, model, mode, attachments, timestamp, model_requested, model_actual, model_origin) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`),
|
|
22
25
|
// Provenance is derived from the authenticated responder identity at
|
|
23
26
|
// /api/response time, never from the response payload.
|
|
@@ -117,10 +120,28 @@ export function createMessageRepository(db) {
|
|
|
117
120
|
// response resolves to the row its operation id already created.
|
|
118
121
|
findQByContinuationOp: db.prepare(`SELECT * FROM queue WHERE continuation_op_id = ?`),
|
|
119
122
|
setMessageKind: db.prepare(`UPDATE messages SET kind = ? WHERE id = ?`),
|
|
123
|
+
// The durable answer→prompt link (the queue's copy is pruned), so a
|
|
124
|
+
// reload still anchors the reply under the message it answers.
|
|
125
|
+
setMessageSourceId: messagesHaveSourceId
|
|
126
|
+
? db.prepare(`UPDATE messages SET source_message_id = ? WHERE id = ?`)
|
|
127
|
+
: null,
|
|
128
|
+
setMessageResendOf: messagesHaveResendOf
|
|
129
|
+
? db.prepare(`UPDATE messages SET resend_of_message_id = ? WHERE id = ?`)
|
|
130
|
+
: null,
|
|
120
131
|
// Quiet teardown for a continuation whose worker died: there is no
|
|
121
132
|
// user to answer, so it fails without the terminal-failure ceremony.
|
|
122
133
|
dropStaleContinuation: db.prepare(`UPDATE queue SET status = 'failed', processing_at = NULL, next_attempt_at = NULL, owner_lease_expires_at = NULL WHERE id = ? AND status = 'processing' AND kind = 'continuation'`),
|
|
123
|
-
|
|
134
|
+
// Claim ordering (all findPending* variants and the owner-transfer
|
|
135
|
+
// scan): strict send order, rowid breaking same-millisecond ties.
|
|
136
|
+
// Neither retry_count nor next_attempt_at may rank rows: dead-worker
|
|
137
|
+
// recovery and /api/requeue bump retry_count, so ranking by it ran a
|
|
138
|
+
// recovered OLDER message after newer ones in the same conversation,
|
|
139
|
+
// and ranking expired-backoff rows behind fresh arrivals starved them
|
|
140
|
+
// for as long as new messages kept coming (live 2026-09-19: a
|
|
141
|
+
// steer-recovered row waited 5 minutes while newer messages jumped
|
|
142
|
+
// it). Head-of-line protection is the WHERE clause's next_attempt_at
|
|
143
|
+
// backoff plus MAX_REQUEUE_RETRIES, which fails a row for good.
|
|
144
|
+
findPending: db.prepare(`SELECT * FROM queue WHERE status = 'pending' AND COALESCE(kind, '') != 'continuation' AND (next_attempt_at IS NULL OR next_attempt_at <= ?) ORDER BY timestamp ASC, rowid ASC LIMIT 1`),
|
|
124
145
|
// Global fallback for requesters without a session identity (the legacy
|
|
125
146
|
// Copilot relay CLI). Conversations bound to a session-worker provider
|
|
126
147
|
// (claude/cursor/grok) are excluded outright: handing one of their turns
|
|
@@ -152,10 +173,8 @@ export function createMessageRepository(db) {
|
|
|
152
173
|
'github'
|
|
153
174
|
)) NOT IN (${sessionWorkerProviderSqlList()})
|
|
154
175
|
ORDER BY
|
|
155
|
-
q.
|
|
156
|
-
|
|
157
|
-
COALESCE(q.next_attempt_at, q.timestamp) ASC,
|
|
158
|
-
q.timestamp ASC
|
|
176
|
+
q.timestamp ASC,
|
|
177
|
+
q.rowid ASC
|
|
159
178
|
LIMIT 1
|
|
160
179
|
`),
|
|
161
180
|
findPendingForWorker: db.prepare(`
|
|
@@ -198,10 +217,8 @@ export function createMessageRepository(db) {
|
|
|
198
217
|
) = ? THEN 0
|
|
199
218
|
ELSE 1
|
|
200
219
|
END ASC,
|
|
201
|
-
q.
|
|
202
|
-
|
|
203
|
-
COALESCE(q.next_attempt_at, q.timestamp) ASC,
|
|
204
|
-
q.timestamp ASC
|
|
220
|
+
q.timestamp ASC,
|
|
221
|
+
q.rowid ASC
|
|
205
222
|
LIMIT 1
|
|
206
223
|
`),
|
|
207
224
|
findPendingForSessionAffinity: db.prepare(`
|
|
@@ -220,10 +237,8 @@ export function createMessageRepository(db) {
|
|
|
220
237
|
NULLIF(c.sdk_session_id, '')
|
|
221
238
|
) = ?
|
|
222
239
|
ORDER BY
|
|
223
|
-
q.
|
|
224
|
-
|
|
225
|
-
COALESCE(q.next_attempt_at, q.timestamp) ASC,
|
|
226
|
-
q.timestamp ASC
|
|
240
|
+
q.timestamp ASC,
|
|
241
|
+
q.rowid ASC
|
|
227
242
|
LIMIT 1
|
|
228
243
|
`),
|
|
229
244
|
countQueueWorkForSessionAffinity: db.prepare(`
|
|
@@ -269,6 +284,13 @@ export function createMessageRepository(db) {
|
|
|
269
284
|
LIMIT ?
|
|
270
285
|
`),
|
|
271
286
|
countStatus: db.prepare(`SELECT status, COUNT(*) as cnt FROM queue WHERE status IN ('pending','processing','parked') GROUP BY status`),
|
|
287
|
+
// Deferred host suspend: which conversations still hold live turns.
|
|
288
|
+
listActiveQueueCountsByConversation: db.prepare(`
|
|
289
|
+
SELECT q.conversation_id, COUNT(*) AS cnt, MAX(c.title) AS title
|
|
290
|
+
FROM queue q LEFT JOIN conversations c ON c.id = q.conversation_id
|
|
291
|
+
WHERE q.status IN ('pending','processing','parked')
|
|
292
|
+
GROUP BY q.conversation_id
|
|
293
|
+
`),
|
|
272
294
|
countRuntimeSessions: db.prepare(`SELECT COUNT(*) AS cnt FROM runtime_sessions WHERE status = 'active'`),
|
|
273
295
|
setProcessing: db.prepare(`UPDATE queue SET status = 'processing', processing_at = ?, attempt_id = ? WHERE id = ?`),
|
|
274
296
|
setProcessingWithWorkerLease: db.prepare(`
|
|
@@ -301,7 +323,7 @@ export function createMessageRepository(db) {
|
|
|
301
323
|
// Recovery clears only the lease, never the owner: a recovered row must
|
|
302
324
|
// stay routed to its provider worker so the primer respawns that worker
|
|
303
325
|
// instead of the row becoming claimable by the global relay poll.
|
|
304
|
-
recoverStale: db.prepare(`UPDATE queue SET status = 'pending', processing_at = NULL, attempt_id = NULL, next_attempt_at = ?, owner_lease_expires_at = NULL, owner_last_claimed_at = NULL WHERE status = 'processing' AND COALESCE(kind, '') != 'continuation' AND processing_at < ?`),
|
|
326
|
+
recoverStale: db.prepare(`UPDATE queue SET status = 'pending', processing_at = NULL, attempt_id = NULL, next_attempt_at = ?, owner_lease_expires_at = NULL, owner_last_claimed_at = NULL WHERE status = 'processing' AND COALESCE(kind, '') != 'continuation' AND consumed_at IS NULL AND processing_at < ?`),
|
|
305
327
|
// Staleness is inactivity, not elapsed turn time: owner_last_claimed_at is
|
|
306
328
|
// refreshed by every worker heartbeat for the message it is working on, so a
|
|
307
329
|
// long-but-alive turn keeps moving the cutoff. processing_at is only the
|
|
@@ -311,7 +333,7 @@ export function createMessageRepository(db) {
|
|
|
311
333
|
// A turn blocked on an unanswered AskUserQuestion is never stale — it is
|
|
312
334
|
// waiting on the human, and relay_questions carries its own expiry.
|
|
313
335
|
listRecoverableProcessing: db.prepare(`
|
|
314
|
-
SELECT id, conversation_id, kind, relay_mode, model, retry_count
|
|
336
|
+
SELECT id, conversation_id, kind, relay_mode, model, retry_count, consumed_at
|
|
315
337
|
FROM queue
|
|
316
338
|
WHERE status = 'processing'
|
|
317
339
|
AND COALESCE(kind, '') != 'continuation'
|
|
@@ -332,6 +354,7 @@ export function createMessageRepository(db) {
|
|
|
332
354
|
retry_count = retry_count + 1
|
|
333
355
|
WHERE status = 'processing'
|
|
334
356
|
AND COALESCE(kind, '') != 'continuation'
|
|
357
|
+
AND consumed_at IS NULL
|
|
335
358
|
AND (
|
|
336
359
|
COALESCE(owner_last_claimed_at, processing_at, timestamp) < @inactiveBefore
|
|
337
360
|
OR (@ceilingBefore IS NOT NULL AND COALESCE(processing_at, timestamp) < @ceilingBefore)
|
|
@@ -347,7 +370,7 @@ export function createMessageRepository(db) {
|
|
|
347
370
|
// worker can never collect its answer, so holding the row would leak
|
|
348
371
|
// both the row and the question.
|
|
349
372
|
listProcessingRowsForOwner: db.prepare(`
|
|
350
|
-
SELECT id, conversation_id, kind, retry_count
|
|
373
|
+
SELECT id, conversation_id, kind, retry_count, consumed_at, relay_mode, model
|
|
351
374
|
FROM queue
|
|
352
375
|
WHERE status = 'processing'
|
|
353
376
|
AND NULLIF(owner_sdk_session_id, '') = ?
|
|
@@ -361,7 +384,28 @@ export function createMessageRepository(db) {
|
|
|
361
384
|
owner_lease_expires_at = NULL,
|
|
362
385
|
owner_last_claimed_at = NULL,
|
|
363
386
|
retry_count = retry_count + 1
|
|
364
|
-
WHERE id = ? AND status = 'processing'
|
|
387
|
+
WHERE id = ? AND status = 'processing' AND consumed_at IS NULL
|
|
388
|
+
`),
|
|
389
|
+
// See the queue.consumed_at migration: at-most-once rows.
|
|
390
|
+
// Fenced like every other worker write: the row must be processing in
|
|
391
|
+
// this conversation, owned by the same worker session (NULL-safe, for
|
|
392
|
+
// routing-disabled installs), and — when the caller names one — still
|
|
393
|
+
// on the attempt it was pushed under.
|
|
394
|
+
markQueueConsumed: db.prepare(`
|
|
395
|
+
UPDATE queue
|
|
396
|
+
SET consumed_at = COALESCE(consumed_at, @at)
|
|
397
|
+
WHERE id = @id AND conversation_id = @conversationId AND status = 'processing'
|
|
398
|
+
AND NULLIF(owner_sdk_session_id, '') IS @owner
|
|
399
|
+
AND (@attemptId IS NULL OR attempt_id = @attemptId)
|
|
400
|
+
`),
|
|
401
|
+
// A consumed steer the CLI then replayed as a turn of its own is an
|
|
402
|
+
// ordinary in-flight row again.
|
|
403
|
+
clearQueueConsumed: db.prepare(`
|
|
404
|
+
UPDATE queue
|
|
405
|
+
SET consumed_at = NULL
|
|
406
|
+
WHERE id = @id AND conversation_id = @conversationId AND status = 'processing'
|
|
407
|
+
AND NULLIF(owner_sdk_session_id, '') IS @owner
|
|
408
|
+
AND (@attemptId IS NULL OR attempt_id = @attemptId)
|
|
365
409
|
`),
|
|
366
410
|
listProcessingOwnerSessionIds: db.prepare(`
|
|
367
411
|
SELECT DISTINCT NULLIF(owner_sdk_session_id, '') AS sdk_session_id
|
|
@@ -390,6 +434,12 @@ export function createMessageRepository(db) {
|
|
|
390
434
|
listConversationIdsWithActiveQueue: db.prepare(`SELECT DISTINCT conversation_id FROM queue WHERE status IN ('pending', 'processing', 'parked')`),
|
|
391
435
|
deleteQueueById: db.prepare(`DELETE FROM queue WHERE id = ?`),
|
|
392
436
|
getLatestProcessingQueueByConversation: db.prepare(`SELECT id, relay_mode, timestamp, processing_at FROM queue WHERE conversation_id = ? AND status = 'processing' ORDER BY COALESCE(processing_at, timestamp) DESC LIMIT 1`),
|
|
437
|
+
// All processing rows, oldest first. Mid-turn steering means a
|
|
438
|
+
// conversation can have more than one processing row at once (the live
|
|
439
|
+
// turn plus a steered message folded into it), so the single-row query
|
|
440
|
+
// above is ambiguous — callers that need "the live turn" resolve it
|
|
441
|
+
// from this list by which row is actually producing output.
|
|
442
|
+
listProcessingQueueByConversation: db.prepare(`SELECT id, relay_mode, timestamp, processing_at FROM queue WHERE conversation_id = ? AND status = 'processing' ORDER BY COALESCE(processing_at, timestamp) ASC`),
|
|
393
443
|
parkPendingQueueForRestart: db.prepare(`
|
|
394
444
|
UPDATE queue
|
|
395
445
|
SET
|
|
@@ -25,6 +25,21 @@ export function createQuestionRepository(db) {
|
|
|
25
25
|
linkActivityToResponse: db.prepare(`UPDATE relay_activity SET response_message_id = ? WHERE queue_message_id = ? AND response_message_id IS NULL`),
|
|
26
26
|
listActivityByResponse: db.prepare(`SELECT text, subagent_run_id, metadata_json FROM relay_activity WHERE response_message_id = ? ORDER BY id ASC`),
|
|
27
27
|
listActivityByQueueMessage: db.prepare(`SELECT text, subagent_run_id, metadata_json FROM relay_activity WHERE queue_message_id = ? ORDER BY id ASC`),
|
|
28
|
+
// When a queue row last produced output — its newest stream snapshot
|
|
29
|
+
// (rows are rewritten in place and re-stamped per update) or activity
|
|
30
|
+
// line. The live-turn picker compares rows by this, never by the
|
|
31
|
+
// per-row stream seq. `stream_done` is 1 once the row's main-thread
|
|
32
|
+
// snapshot is final: a row settling after a handoff re-stamps its
|
|
33
|
+
// snapshot with done=1, which must not read as live output.
|
|
34
|
+
getLastOutputAtByQueueMessage: db.prepare(`
|
|
35
|
+
SELECT
|
|
36
|
+
(SELECT MAX(at) FROM (
|
|
37
|
+
SELECT MAX(created_at) AS at FROM relay_stream_events WHERE queue_message_id = @id
|
|
38
|
+
UNION ALL
|
|
39
|
+
SELECT MAX(created_at) AS at FROM relay_activity WHERE queue_message_id = @id
|
|
40
|
+
)) AS last_output_at,
|
|
41
|
+
(SELECT MAX(done) FROM relay_stream_events WHERE queue_message_id = @id AND subagent_run_id IS NULL) AS stream_done
|
|
42
|
+
`),
|
|
28
43
|
deleteConvActivity: db.prepare(`DELETE FROM relay_activity WHERE conversation_id = ?`),
|
|
29
44
|
|
|
30
45
|
// relay stream events — every update carries the full text-so-far
|
|
@@ -97,6 +112,9 @@ export function createQuestionRepository(db) {
|
|
|
97
112
|
FROM subagent_runs
|
|
98
113
|
WHERE queue_message_id = ? AND status = 'running'
|
|
99
114
|
`),
|
|
115
|
+
countRunningSubagentRuns: db.prepare(`
|
|
116
|
+
SELECT COUNT(*) AS cnt FROM subagent_runs WHERE status = 'running'
|
|
117
|
+
`),
|
|
100
118
|
closeRunningSubagentRunsByQueueMessage: db.prepare(`
|
|
101
119
|
UPDATE subagent_runs
|
|
102
120
|
SET status = ?, updated_at = ?, completed_at = COALESCE(completed_at, ?)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import { normalizeRemoteRelayOrigin } from '../../shared/remote-relay-contract.mjs';
|
|
4
|
+
|
|
5
|
+
// Storage for remote relays that is not app settings: which remote relays a
|
|
6
|
+
// conversation has unlocked (the user mentioned them), and the provenance of
|
|
7
|
+
// messages and conversations another relay's agent created. Tables and
|
|
8
|
+
// columns come from migrations/0005-remote-relays.mjs.
|
|
9
|
+
|
|
10
|
+
function parseOrigin(json) {
|
|
11
|
+
if (!json) return null;
|
|
12
|
+
try {
|
|
13
|
+
return normalizeRemoteRelayOrigin(JSON.parse(json));
|
|
14
|
+
} catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function createRemoteRelayRepository(db) {
|
|
20
|
+
const hasUnlock = db.prepare(`SELECT 1 FROM conversation_remote_relay_unlocks WHERE conversation_id = ? AND remote_relay_id = ?`);
|
|
21
|
+
const listUnlocks = db.prepare(`SELECT remote_relay_id, message_id, created_at FROM conversation_remote_relay_unlocks WHERE conversation_id = ? ORDER BY created_at ASC`);
|
|
22
|
+
const insertUnlock = db.prepare(`INSERT OR IGNORE INTO conversation_remote_relay_unlocks (conversation_id, remote_relay_id, message_id, created_at) VALUES (?, ?, ?, ?)`);
|
|
23
|
+
const deleteUnlocksForRelay = db.prepare(`DELETE FROM conversation_remote_relay_unlocks WHERE remote_relay_id = ?`);
|
|
24
|
+
const deleteUnlocksForConversation = db.prepare(`DELETE FROM conversation_remote_relay_unlocks WHERE conversation_id = ?`);
|
|
25
|
+
const setMessageOrigin = db.prepare(`UPDATE messages SET origin_json = ? WHERE id = ?`);
|
|
26
|
+
const getMessageOrigin = db.prepare(`SELECT origin_json FROM messages WHERE id = ?`);
|
|
27
|
+
const setConversationOrigin = db.prepare(`UPDATE conversations SET origin_json = ? WHERE id = ?`);
|
|
28
|
+
const getConversationOrigin = db.prepare(`SELECT origin_json FROM conversations WHERE id = ?`);
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
hasUnlock(conversationId, remoteRelayId) {
|
|
32
|
+
return !!hasUnlock.get(String(conversationId || ''), String(remoteRelayId || ''));
|
|
33
|
+
},
|
|
34
|
+
/** `[{ remoteRelayId, messageId, createdAt }]`, oldest first. */
|
|
35
|
+
listUnlocks(conversationId) {
|
|
36
|
+
return listUnlocks.all(String(conversationId || '')).map((row) => ({
|
|
37
|
+
remoteRelayId: row.remote_relay_id,
|
|
38
|
+
messageId: row.message_id || null,
|
|
39
|
+
createdAt: row.created_at,
|
|
40
|
+
}));
|
|
41
|
+
},
|
|
42
|
+
/** True when this call created the unlock (false when it already existed). */
|
|
43
|
+
recordUnlock(conversationId, remoteRelayId, messageId = null, nowIso = new Date().toISOString()) {
|
|
44
|
+
const conversation = String(conversationId || '').trim();
|
|
45
|
+
const relay = String(remoteRelayId || '').trim();
|
|
46
|
+
if (!conversation || !relay) return false;
|
|
47
|
+
return insertUnlock.run(conversation, relay, messageId ? String(messageId) : null, nowIso).changes > 0;
|
|
48
|
+
},
|
|
49
|
+
forgetRelay(remoteRelayId) {
|
|
50
|
+
return deleteUnlocksForRelay.run(String(remoteRelayId || '')).changes;
|
|
51
|
+
},
|
|
52
|
+
forgetConversation(conversationId) {
|
|
53
|
+
return deleteUnlocksForConversation.run(String(conversationId || '')).changes;
|
|
54
|
+
},
|
|
55
|
+
setMessageOrigin(messageId, origin) {
|
|
56
|
+
const normalized = normalizeRemoteRelayOrigin(origin);
|
|
57
|
+
return setMessageOrigin.run(normalized ? JSON.stringify(normalized) : null, String(messageId || '')).changes > 0;
|
|
58
|
+
},
|
|
59
|
+
getMessageOrigin(messageId) {
|
|
60
|
+
return parseOrigin(getMessageOrigin.get(String(messageId || ''))?.origin_json);
|
|
61
|
+
},
|
|
62
|
+
setConversationOrigin(conversationId, origin) {
|
|
63
|
+
const normalized = normalizeRemoteRelayOrigin(origin);
|
|
64
|
+
return setConversationOrigin.run(normalized ? JSON.stringify(normalized) : null, String(conversationId || '')).changes > 0;
|
|
65
|
+
},
|
|
66
|
+
getConversationOrigin(conversationId) {
|
|
67
|
+
return parseOrigin(getConversationOrigin.get(String(conversationId || ''))?.origin_json);
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Parses an `origin_json` column value read by other queries. */
|
|
73
|
+
export function parseRemoteRelayOriginJson(json) {
|
|
74
|
+
return parseOrigin(json);
|
|
75
|
+
}
|
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
import { createShareVisibilityStatements } from './share-visibility-statements.mjs';
|
|
4
|
-
|
|
5
3
|
export function createSessionRepository(db) {
|
|
6
|
-
const shareVisibility = createShareVisibilityStatements(db);
|
|
7
4
|
const runtimeSessionColumns = new Set(
|
|
8
5
|
db.prepare(`PRAGMA table_info(runtime_sessions)`).all().map((column) => String(column?.name || '').trim()),
|
|
9
6
|
);
|
|
@@ -12,6 +9,11 @@ export function createSessionRepository(db) {
|
|
|
12
9
|
const runtimeProviderSelect = runtimeSessionsSupportProviders
|
|
13
10
|
? 'rs.model AS runtime_model, rs.provider_type AS runtime_provider_type, rs.provider_model AS runtime_provider_model'
|
|
14
11
|
: 'rs.model AS runtime_model, NULL AS runtime_provider_type, NULL AS runtime_provider_model';
|
|
12
|
+
// Provenance of conversations another relay's agent created (migration
|
|
13
|
+
// 0005); NULL on a schema that predates it.
|
|
14
|
+
const conversationsHaveOrigin = db.prepare(`PRAGMA table_info(conversations)`).all()
|
|
15
|
+
.some((column) => String(column?.name || '').trim() === 'origin_json');
|
|
16
|
+
const conversationOriginSelect = conversationsHaveOrigin ? 'c.origin_json' : 'NULL AS origin_json';
|
|
15
17
|
return {
|
|
16
18
|
// conversations
|
|
17
19
|
getConv: db.prepare(`SELECT * FROM conversations WHERE id = ? AND status != 'deleted'`),
|
|
@@ -19,7 +21,7 @@ export function createSessionRepository(db) {
|
|
|
19
21
|
getConvBySdkSessionId: db.prepare(`SELECT * FROM conversations WHERE sdk_session_id = ? AND status != 'deleted' ORDER BY updated_at DESC LIMIT 1`),
|
|
20
22
|
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'`),
|
|
21
23
|
runtimeSessionsSupportProviders,
|
|
22
|
-
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, 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 = 0) 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`),
|
|
24
|
+
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}, 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 = 0) 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`),
|
|
23
25
|
insertConv: db.prepare(`INSERT OR IGNORE INTO conversations (id, title, created_at, updated_at) VALUES (?, ?, ?, ?)`),
|
|
24
26
|
updateConvTime: db.prepare(`UPDATE conversations SET updated_at = ? WHERE id = ?`),
|
|
25
27
|
updateConvTitle: db.prepare(`UPDATE conversations SET title = ?, title_source = 'manual' WHERE id = ?`),
|
|
@@ -36,36 +38,15 @@ export function createSessionRepository(db) {
|
|
|
36
38
|
archiveConv: db.prepare(`UPDATE conversations SET archived = 1, updated_at = ? WHERE id = ?`),
|
|
37
39
|
deleteConv: db.prepare(`DELETE FROM conversations WHERE id = ?`),
|
|
38
40
|
|
|
39
|
-
//
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
// Queue and message statements live in message-repository.mjs — the
|
|
42
|
+
// single source of truth. Copies used to live here too; the runtime's
|
|
43
|
+
// {...sessionRepo, ...messageRepo} spread shadowed all of them, and the
|
|
44
|
+
// dead copies silently fossilized (this one's findPending predated the
|
|
45
|
+
// continuation-row exclusion — claiming through it would have replayed
|
|
46
|
+
// a background continuation as a user prompt). The two counts below are
|
|
47
|
+
// the only message/queue statements this repository actually owns.
|
|
44
48
|
getConversationMessageCount: db.prepare(`SELECT COUNT(*) AS count FROM messages WHERE conversation_id = ?`),
|
|
45
49
|
getConversationActiveQueueCount: db.prepare(`SELECT COUNT(*) AS count FROM queue WHERE conversation_id = ? AND status IN ('pending', 'processing', 'parked')`),
|
|
46
|
-
getLatestConversationModel: db.prepare(`SELECT model FROM messages WHERE conversation_id = ? AND model IS NOT NULL AND model != '' ORDER BY timestamp DESC LIMIT 1`),
|
|
47
|
-
getRecentMessagesDesc: db.prepare(`SELECT role, text, timestamp FROM messages WHERE conversation_id = ? ORDER BY timestamp DESC LIMIT ?`),
|
|
48
|
-
insertMsg: db.prepare(`INSERT INTO messages (id, conversation_id, role, text, model, mode, attachments, timestamp, model_requested, model_actual, model_origin) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`),
|
|
49
|
-
|
|
50
|
-
// queue
|
|
51
|
-
insertQ: db.prepare(`INSERT INTO queue (id, conversation_id, runtime_session_id, is_new_conversation, model, model_variant_id, reasoning_effort, relay_mode, text, attachments, status, timestamp, retry_count, next_attempt_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, 0, NULL)`),
|
|
52
|
-
findPending: db.prepare(`SELECT * FROM queue WHERE status = 'pending' AND (next_attempt_at IS NULL OR next_attempt_at <= ?) ORDER BY retry_count ASC, CASE WHEN next_attempt_at IS NULL THEN 0 ELSE 1 END ASC, COALESCE(next_attempt_at, timestamp) ASC, timestamp ASC LIMIT 1`),
|
|
53
|
-
countStatus: db.prepare(`SELECT status, COUNT(*) as cnt FROM queue WHERE status IN ('pending','processing','parked') GROUP BY status`),
|
|
54
|
-
countRuntimeSessions: db.prepare(`SELECT COUNT(*) AS cnt FROM runtime_sessions WHERE status = 'active'`),
|
|
55
|
-
setProcessing: db.prepare(`UPDATE queue SET status = 'processing', processing_at = ? WHERE id = ?`),
|
|
56
|
-
setQueueRuntimeSession: db.prepare(`UPDATE queue SET runtime_session_id = ? WHERE id = ?`),
|
|
57
|
-
setQueueResponseMessageId: db.prepare(`UPDATE queue SET response_message_id = ? WHERE id = ?`),
|
|
58
|
-
setDone: db.prepare(`UPDATE queue SET status = 'done', response = ?, processing_at = NULL, next_attempt_at = NULL WHERE id = ? AND status IN ('processing', 'pending')`),
|
|
59
|
-
setFailed: db.prepare(`UPDATE queue SET status = 'failed', response = ?, processing_at = NULL, next_attempt_at = NULL WHERE id = ?`),
|
|
60
|
-
deleteConvQ: db.prepare(`DELETE FROM queue WHERE conversation_id = ?`),
|
|
61
|
-
findQById: db.prepare(`SELECT * FROM queue WHERE id = ?`),
|
|
62
|
-
pruneQueue: db.prepare(`DELETE FROM queue WHERE status = 'done' AND id NOT IN (SELECT id FROM queue WHERE status = 'done' ORDER BY timestamp DESC LIMIT 200)`),
|
|
63
|
-
recoverStale: db.prepare(`UPDATE queue SET status = 'pending', processing_at = NULL, next_attempt_at = ? WHERE status = 'processing' AND processing_at < ?`),
|
|
64
|
-
listRecoverableProcessing: db.prepare(`SELECT id, conversation_id FROM queue WHERE status = 'processing' AND processing_at < ?`),
|
|
65
|
-
recoverProcessingBefore: db.prepare(`UPDATE queue SET status = 'pending', processing_at = NULL, next_attempt_at = ? WHERE status = 'processing' AND processing_at < ?`),
|
|
66
|
-
listQueueForPauseDrop: db.prepare(`SELECT id, conversation_id FROM queue WHERE status IN ('pending', 'processing', 'parked')`),
|
|
67
|
-
deleteQueueById: db.prepare(`DELETE FROM queue WHERE id = ?`),
|
|
68
|
-
getLatestProcessingQueueByConversation: db.prepare(`SELECT id, relay_mode, timestamp, processing_at FROM queue WHERE conversation_id = ? AND status = 'processing' ORDER BY COALESCE(processing_at, timestamp) DESC LIMIT 1`),
|
|
69
50
|
|
|
70
51
|
// runtime sessions
|
|
71
52
|
getRuntimeSessionByConversation: db.prepare(`SELECT * FROM runtime_sessions WHERE conversation_id = ?`),
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
import { createAskUserRoutingService } from '../services/ask-user-routing-service.mjs';
|
|
4
|
+
import { admitRemoteRelayRequest, readRemoteRelayRequest } from '../services/remote-relay-inbound.mjs';
|
|
5
|
+
import { isRemoteRelayApprovalQuestion } from '../../shared/remote-relay-contract.mjs';
|
|
4
6
|
import {
|
|
5
7
|
extractRequestedSchema,
|
|
6
8
|
normalizeSchema,
|
|
@@ -33,6 +35,21 @@ function firstKnownValue(source, keyPaths = []) {
|
|
|
33
35
|
return null;
|
|
34
36
|
}
|
|
35
37
|
|
|
38
|
+
/**
|
|
39
|
+
* A stored question row that is this relay's Allow / Deny card for one of its
|
|
40
|
+
* own agent's calls to another relay (remote-relay-caller-context.mjs). The
|
|
41
|
+
* context lives in the row's request envelope.
|
|
42
|
+
*/
|
|
43
|
+
function isRemoteRelayApprovalRow(row) {
|
|
44
|
+
let envelope = null;
|
|
45
|
+
try {
|
|
46
|
+
envelope = JSON.parse(String(row?.request || ''));
|
|
47
|
+
} catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
return isRemoteRelayApprovalQuestion(envelope);
|
|
51
|
+
}
|
|
52
|
+
|
|
36
53
|
function extractContinuationOwnership(rawRequest) {
|
|
37
54
|
const parsed = rawRequest && typeof rawRequest === 'object' ? rawRequest : null;
|
|
38
55
|
if (!parsed) {
|
|
@@ -99,6 +116,9 @@ export function registerAskUserRoutes(app, deps) {
|
|
|
99
116
|
DEFAULT_RELAY_MODE,
|
|
100
117
|
sessionWorkerRegistry,
|
|
101
118
|
pushDispatchService,
|
|
119
|
+
// Remote relays: another relay's agent answering a question is refused
|
|
120
|
+
// while the inbound switch is off. Absent in older wiring (no-op).
|
|
121
|
+
remoteRelayInbound = null,
|
|
102
122
|
} = deps;
|
|
103
123
|
|
|
104
124
|
const continuationRoutingEnabled = runtimeState?.featureFlags?.SESSION_WORKER_CONTINUATION_ROUTING_ENABLED === true;
|
|
@@ -108,16 +128,26 @@ export function registerAskUserRoutes(app, deps) {
|
|
|
108
128
|
sessionWorkerRegistry,
|
|
109
129
|
});
|
|
110
130
|
|
|
131
|
+
// A request from another relay's agent, by the test the inbound switch
|
|
132
|
+
// applies (X-OAR-Remote-Origin or a body origin). Made even without the
|
|
133
|
+
// remote-relay deps: an approval card is only ever this relay's user's.
|
|
134
|
+
function fromRemoteRelay(req) {
|
|
135
|
+
return readRemoteRelayRequest(req, remoteRelayInbound || {}).remote;
|
|
136
|
+
}
|
|
137
|
+
|
|
111
138
|
app.get('/api/relay-questions', auth, (req, res) => {
|
|
112
139
|
const conversationId = req.query.conversationId ? String(req.query.conversationId) : null;
|
|
113
140
|
const status = String(req.query.status || 'pending').trim() || 'pending';
|
|
114
|
-
|
|
141
|
+
let rows = stmts.listQuestions.all(status, conversationId, conversationId);
|
|
142
|
+
if (fromRemoteRelay(req)) rows = rows.filter((row) => !isRemoteRelayApprovalRow(row));
|
|
115
143
|
res.json({ questions: rows.map(formatQuestionRow) });
|
|
116
144
|
});
|
|
117
145
|
|
|
118
146
|
app.get('/api/relay-question/:id', auth, (req, res) => {
|
|
119
147
|
const row = stmts.getQuestion.get(req.params.id);
|
|
120
|
-
if (!row
|
|
148
|
+
if (!row || (isRemoteRelayApprovalRow(row) && fromRemoteRelay(req))) {
|
|
149
|
+
return res.status(404).json({ error: 'Not found' });
|
|
150
|
+
}
|
|
121
151
|
res.json({ question: formatQuestionRow(row) });
|
|
122
152
|
});
|
|
123
153
|
|
|
@@ -220,6 +250,7 @@ export function registerAskUserRoutes(app, deps) {
|
|
|
220
250
|
});
|
|
221
251
|
|
|
222
252
|
app.post('/api/relay-question/:id/answer', auth, (req, res) => {
|
|
253
|
+
if (!admitRemoteRelayRequest(req, res, remoteRelayInbound)) return;
|
|
223
254
|
const id = String(req.params.id || '').trim();
|
|
224
255
|
const { answer, structuredAnswer, sdk_session_id, continuation_id, continuation_question_id } = req.body;
|
|
225
256
|
const sdkSessionId = normalizeId(sdk_session_id);
|
|
@@ -228,6 +259,12 @@ export function registerAskUserRoutes(app, deps) {
|
|
|
228
259
|
|
|
229
260
|
const row = stmts.getQuestion.get(id);
|
|
230
261
|
if (!row) return res.status(404).json({ error: 'Not found' });
|
|
262
|
+
// Allow / Deny on this relay's own call to another relay is its user's
|
|
263
|
+
// decision; the relay that call went to must never make it.
|
|
264
|
+
if (isRemoteRelayApprovalRow(row) && fromRemoteRelay(req)) {
|
|
265
|
+
console.warn(`[${ts()}] QUESTION reject id=${id.slice(0, 8)} reason=remote-relay-approval-from-remote`);
|
|
266
|
+
return res.status(403).json({ error: 'Only this relay\'s user can answer a remote relay approval' });
|
|
267
|
+
}
|
|
231
268
|
if (row.status !== 'pending') return res.status(409).json({ error: `Question already ${row.status}` });
|
|
232
269
|
|
|
233
270
|
const schema = parseQuestionRequest(row.request_schema);
|
|
@@ -287,6 +324,9 @@ export function registerAskUserRoutes(app, deps) {
|
|
|
287
324
|
app.post('/api/relay-question/:id/timeout', auth, (req, res) => {
|
|
288
325
|
const row = stmts.getQuestion.get(req.params.id);
|
|
289
326
|
if (!row) return res.status(404).json({ error: 'Not found' });
|
|
327
|
+
if (isRemoteRelayApprovalRow(row) && fromRemoteRelay(req)) {
|
|
328
|
+
return res.status(403).json({ error: 'Only this relay\'s user can answer a remote relay approval' });
|
|
329
|
+
}
|
|
290
330
|
if (row.status !== 'pending') return res.json({ ok: true, question: formatQuestionRow(row) });
|
|
291
331
|
|
|
292
332
|
const result = stmts.timeoutQuestion.run(row.id);
|