@oar-sh/oar 0.9.1 → 0.9.3
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 +0 -3
- package/.github/extensions/web-relay/polling/heartbeat.mjs +2 -55
- package/.github/extensions/web-relay/polling/polling-loop.mjs +0 -1
- package/.github/extensions/web-relay/runtime/api-client.mjs +2 -34
- package/.github/extensions/web-relay/runtime/config-loader.mjs +2 -104
- package/.github/extensions/web-relay/runtime/send-and-wait-errors.mjs +2 -170
- package/.github/extensions/web-relay/runtime/session-io.mjs +1 -71
- package/.github/extensions/web-relay/runtime/worker-websocket-link.mjs +2 -325
- package/.github/extensions/web-relay/skills/prompt-context.mjs +2 -160
- package/CHANGELOG.md +269 -0
- package/README.md +407 -424
- package/bin/oar.js +142 -11
- package/package.json +8 -4
- package/server/README.md +59 -22
- package/server/claude-worker/claude-sdk-adapter.mjs +7 -2
- package/server/claude-worker/claude-session-process.mjs +1013 -45
- package/server/claude-worker/claude-session-worker.mjs +54 -9
- package/server/claude-worker/claude-turn-publisher.mjs +110 -19
- package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
- package/server/copilot-sdk-runtime.mjs +12 -3
- package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
- package/server/copilot-worker/copilot-attachments.mjs +35 -2
- package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
- package/server/copilot-worker/copilot-model-switch.mjs +425 -0
- package/server/copilot-worker/copilot-prompt-context.mjs +20 -8
- package/server/copilot-worker/copilot-question-bridge.mjs +129 -16
- package/server/copilot-worker/copilot-sdk-adapter.mjs +59 -11
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +136 -12
- package/server/copilot-worker/copilot-sdk-session-process.mjs +2753 -527
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +94 -25
- package/server/cursor-worker/cursor-session-worker.mjs +5 -5
- package/server/cursor-worker/cursor-turn-runner.mjs +50 -4
- package/server/db-schema.mjs +61 -1
- package/server/features.mjs +155 -41
- package/server/grok-worker/grok-session-worker.mjs +5 -5
- package/server/grok-worker/grok-turn-runner.mjs +45 -4
- package/server/migrations/0004-steer-settle-markers.mjs +119 -0
- package/server/public/app/annotate-core.mjs +181 -0
- package/server/public/app/annotate-editor.js +617 -0
- package/server/public/app/api-client.js +86 -5
- package/server/public/app/ask-user-view.js +102 -13
- package/server/public/app/attachments-view.js +172 -3
- package/server/public/app/background-task-timeout-options.mjs +9 -0
- package/server/public/app/background-tasks-view.mjs +34 -19
- package/server/public/app/bootstrap.js +153 -69
- package/server/public/app/composer-control-state.mjs +52 -34
- package/server/public/app/context-tier-options.mjs +24 -4
- 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-preferences.mjs +47 -10
- package/server/public/app/conversation-provider-indicator.mjs +8 -0
- package/server/public/app/conversation-view.js +1032 -218
- package/server/public/app/emoji-view.js +2 -0
- package/server/public/app/font-scaling.js +4 -37
- package/server/public/app/journal-view.js +190 -24
- package/server/public/app/model-selector-options.mjs +185 -1
- package/server/public/app/new-conversation-model-choice.mjs +29 -9
- package/server/public/app/pwa-install.js +64 -136
- package/server/public/app/question-multi-select.mjs +52 -0
- package/server/public/app/reasoning-effort-labels.mjs +13 -3
- package/server/public/app/relay-prompt-sanitizer.mjs +26 -3
- package/server/public/app/router.js +120 -11
- package/server/public/app/settings-modal.js +225 -20
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +47 -4
- package/server/public/app/steered-turn-merge.mjs +100 -0
- package/server/public/app/store.js +159 -16
- package/server/public/app/sync-outbox.mjs +16 -8
- package/server/public/app/tool-activity-emoji.mjs +36 -0
- package/server/public/app/transcript-viewport-anchor.mjs +210 -0
- package/server/public/app/update-ui.js +194 -0
- package/server/public/index.html +483 -24
- package/server/public/sw.js +7 -0
- package/server/relay-tools.md +2 -0
- package/server/relay.mjs +23 -21
- package/server/repositories/message-repository.mjs +78 -21
- package/server/repositories/question-repository.mjs +19 -1
- package/server/repositories/session-repository.mjs +7 -31
- package/server/routes/ask-user-routes.mjs +10 -0
- package/server/routes/messages-routes.mjs +689 -159
- package/server/routes/sessions-routes.mjs +574 -80
- package/server/server-runtime.mjs +393 -925
- package/server/services/claude-auth-service.mjs +7 -1
- package/server/services/cli-install-service.mjs +8 -27
- package/server/services/cli-process-runner.mjs +30 -0
- package/server/services/copilot-model-discovery-service.mjs +202 -0
- package/server/services/data-dir-resolution.mjs +47 -0
- package/server/services/grok-auth-service.mjs +9 -1
- package/server/services/live-turn-picker.mjs +47 -0
- package/server/services/model-variant-catalog-service.mjs +1183 -0
- package/server/services/oar-cli-helpers.mjs +6 -2
- package/server/services/relay-prompt-sanitizer.mjs +30 -3
- package/server/services/relay-question-context.mjs +46 -0
- package/server/services/sdk-session-import-service.mjs +144 -16
- package/server/services/sdk-session-sync-service.mjs +84 -27
- package/server/services/session-worker-registry-service.mjs +116 -3
- package/server/services/session-worker-supervisor-service.mjs +7 -1
- package/server/services/session-worker-websocket-service.mjs +119 -24
- package/server/services/status-event-service.mjs +8 -2
- package/server/services/steer-resend.mjs +45 -0
- package/server/services/update-check-service.mjs +220 -0
- package/server/services/update-install-service.mjs +279 -0
- package/server/services/windows-autostart-service.mjs +42 -6
- package/server/services/windows-boot-autostart-service.mjs +487 -0
- package/shared/ask-user-bridge.mjs +37 -5
- package/shared/background-task-timeout.mjs +12 -4
- package/shared/claude-model-catalog.mjs +49 -0
- package/shared/context-window-fallbacks.mjs +1 -0
- package/shared/control-poller.mjs +4 -1
- package/shared/copilot-model-order.mjs +182 -0
- package/shared/media-embed-instructions.mjs +35 -0
- package/shared/model-descriptors.mjs +181 -30
- package/shared/model-id.mjs +6 -0
- package/shared/pwa-app-name.mjs +44 -0
- package/shared/question-multi-select.mjs +16 -0
- package/shared/relay-stop-failure.mjs +15 -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/update-semver.mjs +50 -0
- package/shared/worker-crash-guard.mjs +40 -14
- package/shared/worker-runtime/api-client.mjs +34 -0
- package/shared/worker-runtime/config-loader.mjs +104 -0
- package/shared/worker-runtime/heartbeat.mjs +109 -0
- package/shared/worker-runtime/prompt-context.mjs +160 -0
- package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
- package/shared/worker-runtime/send-and-wait-errors.mjs +170 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +436 -0
- package/server/claude-worker/claude-session-test-harness.mjs +0 -236
- package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
- package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -329
- 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.sh +0 -62
- package/server/services/fixtures/cli-install-stub.sh +0 -75
- package/server/services/fixtures/grok-stub.sh +0 -86
|
@@ -19,7 +19,8 @@ export function buildDefaultConfig({ token = generateAuthToken(), port = 3333, l
|
|
|
19
19
|
pollIntervalMs: 3000,
|
|
20
20
|
processingTimeoutMs: 600000,
|
|
21
21
|
conversationSessionMode: 'isolated',
|
|
22
|
-
updateCheck:
|
|
22
|
+
// No updateCheck key: automatic update checking is opt-in from the web
|
|
23
|
+
// UI (app_settings), never a config default — zero telemetry out of the box.
|
|
23
24
|
};
|
|
24
25
|
}
|
|
25
26
|
|
|
@@ -45,13 +46,16 @@ export function relayUrl({ config, lanAddress = null } = {}) {
|
|
|
45
46
|
* so a `gh copilot` session is not tied to the unit's lifetime.
|
|
46
47
|
*/
|
|
47
48
|
export function buildSystemdUnit({ nodeBin, packageRoot, configPath, dataDir, logDir }) {
|
|
49
|
+
// systemd units are Linux-only, so the path inside the unit is always
|
|
50
|
+
// posix-joined — host-platform path.join would write backslashes when this
|
|
51
|
+
// template is exercised on Windows (tests; the CLI never writes it there).
|
|
48
52
|
return [
|
|
49
53
|
'[Unit]',
|
|
50
54
|
'Description=OAR — Open Agent Relay',
|
|
51
55
|
'After=network-online.target',
|
|
52
56
|
'',
|
|
53
57
|
'[Service]',
|
|
54
|
-
`ExecStart=${nodeBin} ${path.join(packageRoot, 'server', 'server.js')}`,
|
|
58
|
+
`ExecStart=${nodeBin} ${path.posix.join(packageRoot, 'server', 'server.js')}`,
|
|
55
59
|
`Environment=COPILOT_WEB_RELAY_CONFIG=${configPath}`,
|
|
56
60
|
`Environment=COPILOT_WEB_RELAY_DATA_DIR=${dataDir}`,
|
|
57
61
|
`Environment=COPILOT_WEB_RELAY_LOG_DIR=${logDir}`,
|
|
@@ -4,6 +4,8 @@ import fs from 'fs';
|
|
|
4
4
|
import path from 'path';
|
|
5
5
|
import { fileURLToPath } from 'url';
|
|
6
6
|
|
|
7
|
+
import { renderMediaEmbedInstructionBlock } from '../../shared/media-embed-instructions.mjs';
|
|
8
|
+
|
|
7
9
|
const DEFAULT_RELAY_TOOL_GUIDANCE = [
|
|
8
10
|
'# Relay Tool Guidance',
|
|
9
11
|
'For any user-facing question or clarification, use the ask_user tool so the web relay can render question cards and buttons. Never ask questions in plain assistant text.',
|
|
@@ -125,13 +127,38 @@ function stripAttachmentPromptArtifacts(text) {
|
|
|
125
127
|
.trim();
|
|
126
128
|
}
|
|
127
129
|
|
|
130
|
+
// The once-per-worker media-embed guidance rides ahead of the mode marker on
|
|
131
|
+
// the Cursor/Grok prompt paths, so it is dropped before the prefix-anchored
|
|
132
|
+
// marker patterns run. Machine-injected verbatim, never user-authored.
|
|
133
|
+
const MEDIA_EMBED_BLOCK_PATTERN = new RegExp(
|
|
134
|
+
whitespaceFlexiblePattern(renderMediaEmbedInstructionBlock()),
|
|
135
|
+
'gi',
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
// A steered message's hidden note (shared/steer-note.mjs) leads the prompt the
|
|
139
|
+
// runtime stored, right after any mode marker. Matched by its stable opening
|
|
140
|
+
// words, because the browser copy of this module cannot import shared/.
|
|
141
|
+
const STEER_NOTE_PATTERN = /^\s*\[Sent while you were still working on my previous message\.[^\]\n]*\]\s*/;
|
|
142
|
+
|
|
143
|
+
// The Claude worker's slash-command guard (shared/slash-command-guard.mjs): a
|
|
144
|
+
// zero-width space in front of a "/x" it delivers as text. trim() keeps it, so
|
|
145
|
+
// it is matched on its own, and only where it guards a "/".
|
|
146
|
+
const SLASH_COMMAND_GUARD_PATTERN = /^\u200B(?=\s*\/)/;
|
|
147
|
+
|
|
148
|
+
function stripLeadingRelayNotes(text) {
|
|
149
|
+
const stripped = String(text || '').replace(STEER_NOTE_PATTERN, '').replace(SLASH_COMMAND_GUARD_PATTERN, '');
|
|
150
|
+
return stripped.trim() ? stripped.trim() : String(text || '').trim();
|
|
151
|
+
}
|
|
152
|
+
|
|
128
153
|
export function stripRelayPromptContext(text, relayMode = '', attachments = []) {
|
|
129
|
-
const value = stripAttachmentPromptArtifacts(text)
|
|
154
|
+
const value = stripAttachmentPromptArtifacts(text)
|
|
155
|
+
.replace(MEDIA_EMBED_BLOCK_PATTERN, '')
|
|
156
|
+
.trim();
|
|
130
157
|
if (!value) return '';
|
|
131
158
|
const patterns = buildPromptPrefixPatterns(relayMode);
|
|
132
159
|
for (const pattern of patterns) {
|
|
133
160
|
const stripped = value.replace(pattern, '').trim();
|
|
134
|
-
if (stripped && stripped !== value) return stripped;
|
|
161
|
+
if (stripped && stripped !== value) return stripLeadingRelayNotes(stripped);
|
|
135
162
|
}
|
|
136
|
-
return value;
|
|
163
|
+
return stripLeadingRelayNotes(value);
|
|
137
164
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// The `context` block a relay question card carries: who asked, why, for which
|
|
2
|
+
// queue row, and the few presentation hints the card renders from. Workers
|
|
3
|
+
// send it on POST /api/relay-question; it is stored inside the question's
|
|
4
|
+
// request envelope and served back to the client. Anything not listed here is
|
|
5
|
+
// dropped, so a new hint the client reads must be added here too (the
|
|
6
|
+
// multi-select flag was once silently stripped this way).
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {unknown} rawContext
|
|
10
|
+
* @param {{ normalizeRelayMode?: (mode: string) => string | null, defaultRelayMode?: string }} [options]
|
|
11
|
+
*/
|
|
12
|
+
export function sanitizeRelayQuestionContext(rawContext, {
|
|
13
|
+
normalizeRelayMode = (mode) => String(mode || '').trim().toLowerCase() || null,
|
|
14
|
+
defaultRelayMode = 'agent',
|
|
15
|
+
} = {}) {
|
|
16
|
+
if (!rawContext || typeof rawContext !== 'object' || Array.isArray(rawContext)) return null;
|
|
17
|
+
const context = {};
|
|
18
|
+
if (typeof rawContext.source === 'string' && rawContext.source.trim()) {
|
|
19
|
+
context.source = rawContext.source.trim().slice(0, 64);
|
|
20
|
+
}
|
|
21
|
+
if (typeof rawContext.rationale === 'string' && rawContext.rationale.trim()) {
|
|
22
|
+
context.rationale = rawContext.rationale.trim().slice(0, 240);
|
|
23
|
+
}
|
|
24
|
+
if (typeof rawContext.queueMessageId === 'string' && rawContext.queueMessageId.trim()) {
|
|
25
|
+
context.queueMessageId = rawContext.queueMessageId.trim();
|
|
26
|
+
}
|
|
27
|
+
if (typeof rawContext.conversationId === 'string' && rawContext.conversationId.trim()) {
|
|
28
|
+
context.conversationId = rawContext.conversationId.trim();
|
|
29
|
+
}
|
|
30
|
+
if (typeof rawContext.relayMode === 'string' && rawContext.relayMode.trim()) {
|
|
31
|
+
context.relayMode = normalizeRelayMode(rawContext.relayMode) || defaultRelayMode;
|
|
32
|
+
}
|
|
33
|
+
// The card renders checkmarks instead of one-shot buttons when several
|
|
34
|
+
// answers are allowed (Claude's AskUserQuestion `multiSelect`, or the
|
|
35
|
+
// Copilot worker's wording heuristic). Only an explicit `true` is kept.
|
|
36
|
+
if (rawContext.multiSelect === true) context.multiSelect = true;
|
|
37
|
+
// Providers whose ask tool cannot always say so (Copilot) mark their choice
|
|
38
|
+
// cards with this: the card then offers a "Select several" switch, so the
|
|
39
|
+
// user can pick several answers even when the model did not flag it.
|
|
40
|
+
if (rawContext.allowMultiSelect === true) context.allowMultiSelect = true;
|
|
41
|
+
// Claude's short question label ("Workers", "Visibility").
|
|
42
|
+
if (typeof rawContext.header === 'string' && rawContext.header.trim()) {
|
|
43
|
+
context.header = rawContext.header.trim().slice(0, 120);
|
|
44
|
+
}
|
|
45
|
+
return Object.keys(context).length ? context : null;
|
|
46
|
+
}
|
|
@@ -71,17 +71,69 @@ export function createSdkSessionImportService({
|
|
|
71
71
|
parseSessionEventsToMessages,
|
|
72
72
|
replaceRetrievableHistory,
|
|
73
73
|
ensureRuntimeSessionBinding,
|
|
74
|
+
// Live evidence that the relay runs (or ran) a worker for this session in
|
|
75
|
+
// the current process — queue rows alone miss a worker between turns.
|
|
76
|
+
hasRelayExecutionSignal = () => false,
|
|
74
77
|
logger = console,
|
|
75
78
|
} = {}) {
|
|
76
79
|
if (!db || !stmts || typeof createClient !== 'function') throw new Error('SDK session importer requires database, statements, and a client factory');
|
|
77
|
-
let
|
|
80
|
+
let runtimeCreation = null;
|
|
78
81
|
let activeRun = null;
|
|
82
|
+
let closing = false;
|
|
79
83
|
const countConversationMessages = db.prepare(`
|
|
80
84
|
SELECT COUNT(*) AS count
|
|
81
85
|
FROM messages
|
|
82
86
|
WHERE conversation_id = ?
|
|
83
87
|
`);
|
|
84
88
|
|
|
89
|
+
// Ownership ledger (audit #19). The importer creates a runtime binding for
|
|
90
|
+
// every conversation it imports, so "a binding exists" cannot double as "the
|
|
91
|
+
// relay executes this conversation" — that reading froze every imported
|
|
92
|
+
// session after its first import. The ledger's origin column records who the
|
|
93
|
+
// binding belongs to; relay evidence flips it durably below.
|
|
94
|
+
const getImportOwnership = db.prepare(`
|
|
95
|
+
SELECT status, origin
|
|
96
|
+
FROM sdk_session_imports
|
|
97
|
+
WHERE sdk_session_id = ?
|
|
98
|
+
`);
|
|
99
|
+
// Any queue row referencing the session — including done/failed ones that
|
|
100
|
+
// have not been pruned yet — proves the relay queued a turn for it.
|
|
101
|
+
const getQueueEvidence = db.prepare(`
|
|
102
|
+
SELECT 1 AS present
|
|
103
|
+
FROM queue
|
|
104
|
+
WHERE conversation_id = ? OR owner_sdk_session_id = ?
|
|
105
|
+
LIMIT 1
|
|
106
|
+
`);
|
|
107
|
+
// Durable one-way flip: once the relay owns a session, pruned queue rows or
|
|
108
|
+
// a restart must not hand it back to the importer.
|
|
109
|
+
const markImportRelayOwned = db.prepare(`
|
|
110
|
+
UPDATE sdk_session_imports
|
|
111
|
+
SET origin = 'relay', updated_at = ?
|
|
112
|
+
WHERE sdk_session_id = ? AND (origin IS NULL OR origin != 'relay')
|
|
113
|
+
`);
|
|
114
|
+
const markImportOriginImported = db.prepare(`
|
|
115
|
+
UPDATE sdk_session_imports
|
|
116
|
+
SET origin = 'imported'
|
|
117
|
+
WHERE sdk_session_id = ? AND (origin IS NULL OR origin = 'imported')
|
|
118
|
+
`);
|
|
119
|
+
|
|
120
|
+
function relayOwnsSession(sdkSessionId) {
|
|
121
|
+
const ledger = getImportOwnership.get(sdkSessionId) || null;
|
|
122
|
+
if (ledger?.origin === 'relay') return true;
|
|
123
|
+
const executed = !!getQueueEvidence.get(sdkSessionId, sdkSessionId)
|
|
124
|
+
|| hasRelayExecutionSignal(sdkSessionId) === true;
|
|
125
|
+
if (executed) {
|
|
126
|
+
markImportRelayOwned.run(new Date().toISOString(), sdkSessionId);
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
// origin = 'imported' is only ever written by a completed import, so it is
|
|
130
|
+
// the one proof the binding belongs to the importer (and it survives a
|
|
131
|
+
// later failed refresh attempt). Anything else — no ledger row, no origin —
|
|
132
|
+
// means the relay created the binding (conversation bootstrap,
|
|
133
|
+
// session-sync) and owns the history.
|
|
134
|
+
return ledger?.origin !== 'imported';
|
|
135
|
+
}
|
|
136
|
+
|
|
85
137
|
const upsertConversation = db.prepare(`
|
|
86
138
|
INSERT INTO conversations (id, title, sdk_session_id, configured_workspace_root_path, runtime_workspace_root_path, created_at, updated_at)
|
|
87
139
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
@@ -94,8 +146,21 @@ export function createSdkSessionImportService({
|
|
|
94
146
|
`);
|
|
95
147
|
|
|
96
148
|
async function getRuntime() {
|
|
97
|
-
|
|
98
|
-
|
|
149
|
+
// Refusing after dispose() is what stops a mid-shutdown import from
|
|
150
|
+
// resurrecting a fresh SDK client the server would never tear down.
|
|
151
|
+
if (closing) throw new Error('SDK session importer is shutting down');
|
|
152
|
+
// Callers share the start in flight: parallel deletes, or a delete during
|
|
153
|
+
// the startup import, each started a runtime of their own, and only the
|
|
154
|
+
// last one was ever disposed. A start that fails is dropped, so the next
|
|
155
|
+
// call tries again.
|
|
156
|
+
if (!runtimeCreation) {
|
|
157
|
+
const creation = Promise.resolve().then(() => createClient());
|
|
158
|
+
runtimeCreation = creation;
|
|
159
|
+
creation.catch(() => {
|
|
160
|
+
if (runtimeCreation === creation) runtimeCreation = null;
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
return runtimeCreation;
|
|
99
164
|
}
|
|
100
165
|
|
|
101
166
|
function isTombstoned(sdkSessionId) {
|
|
@@ -136,6 +201,16 @@ export function createSdkSessionImportService({
|
|
|
136
201
|
const workspaceRoot = text(metadata.workspaceRootPath || metadata.workspace_root_path || metadata.cwd) || null;
|
|
137
202
|
const title = sessionTitle(session, messages);
|
|
138
203
|
db.transaction(() => {
|
|
204
|
+
// The relay may have queued a turn while this import was reading SDK
|
|
205
|
+
// events; overwriting now would replace relay history with the raw
|
|
206
|
+
// transcript. Re-checked inside the transaction so the decision and the
|
|
207
|
+
// write are one unit. (The durable origin flip happens in the caller —
|
|
208
|
+
// an UPDATE here would roll back with the throw.)
|
|
209
|
+
if (getQueueEvidence.get(sdkSessionId, sdkSessionId) || hasRelayExecutionSignal(sdkSessionId) === true) {
|
|
210
|
+
const error = new Error('Relay queued a turn during import');
|
|
211
|
+
error.code = 'relay-owned';
|
|
212
|
+
throw error;
|
|
213
|
+
}
|
|
139
214
|
upsertConversation.run(sdkSessionId, title, sdkSessionId, workspaceRoot, workspaceRoot, createdAt, updatedAt);
|
|
140
215
|
ensureRuntimeSessionBinding(sdkSessionId, null, updatedAt, sdkSessionId);
|
|
141
216
|
replaceRetrievableHistory(sdkSessionId, messages);
|
|
@@ -147,12 +222,15 @@ export function createSdkSessionImportService({
|
|
|
147
222
|
now,
|
|
148
223
|
sdkSessionId,
|
|
149
224
|
);
|
|
225
|
+
// The binding above belongs to the import, not to relay execution.
|
|
226
|
+
markImportOriginImported.run(sdkSessionId);
|
|
150
227
|
})();
|
|
151
228
|
}
|
|
152
229
|
|
|
153
230
|
async function importSession(session, { force = false } = {}) {
|
|
154
231
|
const sdkSessionId = sessionIdOf(session);
|
|
155
232
|
if (!sdkSessionId) return { status: 'skipped', category: 'unchanged', reason: 'missing-session-id' };
|
|
233
|
+
if (closing) return { sdkSessionId, status: 'skipped', category: 'unchanged', reason: 'importer-closing' };
|
|
156
234
|
if (isTombstoned(sdkSessionId)) return { sdkSessionId, status: 'skipped', category: 'tombstoned', reason: 'tombstoned' };
|
|
157
235
|
// Sessions the relay created to execute an existing conversation's turns
|
|
158
236
|
// are vehicles, not conversations: their history already lives in the
|
|
@@ -165,15 +243,16 @@ export function createSdkSessionImportService({
|
|
|
165
243
|
// The SDK-engine workers (Copilot SDK, Claude, Cursor, Grok) run a
|
|
166
244
|
// conversation's turns in a CLI session that shares the conversation's OWN
|
|
167
245
|
// id — the exact equality the guard above reads as "external". A runtime
|
|
168
|
-
// binding for that conversation
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
// the
|
|
246
|
+
// binding for that conversation used to be read as relay ownership
|
|
247
|
+
// outright, but the importer itself creates a binding on every completed
|
|
248
|
+
// import — that reading made the first import the last (audit #19). The
|
|
249
|
+
// binding still matters (importing over relay history reproduces the
|
|
250
|
+
// burn-in incident 2026-08-31: "[Relay mode: autopilot] …" surfaced as
|
|
251
|
+
// user bubbles and titles after a restart), so ownership now comes from
|
|
252
|
+
// the ledger: only a binding the importer did NOT account for, or actual
|
|
253
|
+
// relay activity (a queued turn, a live worker), blocks the import.
|
|
175
254
|
const runtimeSession = stmts.getRuntimeSessionByConversation?.get?.(sdkSessionId) || null;
|
|
176
|
-
if (runtimeSession) {
|
|
255
|
+
if (runtimeSession && relayOwnsSession(sdkSessionId)) {
|
|
177
256
|
return { sdkSessionId, status: 'skipped', category: 'relay-owned', reason: 'relay-execution-session' };
|
|
178
257
|
}
|
|
179
258
|
// Same protection when a live conversation already claims this session id
|
|
@@ -202,11 +281,20 @@ export function createSdkSessionImportService({
|
|
|
202
281
|
persistCompletedImport({ sdkSessionId, session, messages });
|
|
203
282
|
return { sdkSessionId, status: 'completed', category: claimed.category, messageCount: messages.length };
|
|
204
283
|
} catch (error) {
|
|
205
|
-
|
|
284
|
+
const now = new Date().toISOString();
|
|
285
|
+
if (error?.code === 'relay-owned') {
|
|
286
|
+
// The relay claimed the session mid-import. Flip ownership durably and
|
|
287
|
+
// release the claim so the ledger row is not stuck in 'processing'.
|
|
288
|
+
markImportRelayOwned.run(now, sdkSessionId);
|
|
289
|
+
stmts.failSdkSessionImport.run(now, boundedError(error), sdkSessionId);
|
|
290
|
+
return { sdkSessionId, status: 'skipped', category: 'relay-owned', reason: 'relay-claimed-during-import' };
|
|
291
|
+
}
|
|
292
|
+
stmts.failSdkSessionImport.run(now, boundedError(error), sdkSessionId);
|
|
206
293
|
return { sdkSessionId, status: 'failed', category: 'failed', error: boundedError(error) };
|
|
207
294
|
} finally {
|
|
208
|
-
|
|
209
|
-
|
|
295
|
+
// SDK 1.0.13's CopilotSession cleanup API; the previous optional
|
|
296
|
+
// stop()/dispose() calls matched nothing and leaked every resume.
|
|
297
|
+
try { await resumed?.disconnect?.(); } catch {}
|
|
210
298
|
}
|
|
211
299
|
}
|
|
212
300
|
|
|
@@ -229,6 +317,9 @@ export function createSdkSessionImportService({
|
|
|
229
317
|
const sessions = await normalizeEvents(await client.client.listSessions());
|
|
230
318
|
summary.listed = sessions.length;
|
|
231
319
|
for (const session of sessions) {
|
|
320
|
+
// A shutdown mid-sweep must stop cleanly between sessions instead of
|
|
321
|
+
// racing dispose() for the runtime.
|
|
322
|
+
if (closing) break;
|
|
232
323
|
const result = await importSession(session);
|
|
233
324
|
summary[result.category] = Number(summary[result.category] || 0) + 1;
|
|
234
325
|
}
|
|
@@ -248,6 +339,34 @@ export function createSdkSessionImportService({
|
|
|
248
339
|
return activeRun;
|
|
249
340
|
}
|
|
250
341
|
|
|
342
|
+
/**
|
|
343
|
+
* Remove a Copilot session from the runtime's own store, for a conversation
|
|
344
|
+
* deleted in the relay. Goes through the SDK rather than the session-state
|
|
345
|
+
* folder so the runtime's bookkeeping stays consistent. Throws when the
|
|
346
|
+
* runtime cannot do it; the caller decides what that means.
|
|
347
|
+
*/
|
|
348
|
+
async function deleteSession(sdkSessionId) {
|
|
349
|
+
const sid = text(sdkSessionId);
|
|
350
|
+
if (!sid) throw new Error('Missing session id');
|
|
351
|
+
const client = await getRuntime();
|
|
352
|
+
if (typeof client?.client?.deleteSession !== 'function') {
|
|
353
|
+
throw new Error('deleteSession() is unavailable in this Copilot runtime');
|
|
354
|
+
}
|
|
355
|
+
await client.client.deleteSession(sid);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Whether the relay only imported this session and never ran it: the ledger
|
|
360
|
+
* says the importer brought it in, and no queued turn or relay worker says
|
|
361
|
+
* the relay has run it since. Continuing an imported conversation does not
|
|
362
|
+
* touch the ledger, so that evidence is checked here too, and it flips the
|
|
363
|
+
* ledger to 'relay' for good, as it does on import.
|
|
364
|
+
*/
|
|
365
|
+
function isImportedOnly(sdkSessionId) {
|
|
366
|
+
const sid = text(sdkSessionId);
|
|
367
|
+
return !!sid && !relayOwnsSession(sid);
|
|
368
|
+
}
|
|
369
|
+
|
|
251
370
|
async function refreshConversation(conversation) {
|
|
252
371
|
const sdkSessionId = text(conversation?.sdk_session_id || conversation?.sdkSessionId || conversation?.id);
|
|
253
372
|
if (!sdkSessionId || isTombstoned(sdkSessionId)) {
|
|
@@ -262,9 +381,18 @@ export function createSdkSessionImportService({
|
|
|
262
381
|
runStartupImport,
|
|
263
382
|
importSession,
|
|
264
383
|
refreshConversation,
|
|
384
|
+
deleteSession,
|
|
385
|
+
isImportedOnly,
|
|
265
386
|
async dispose() {
|
|
266
|
-
|
|
267
|
-
runtime
|
|
387
|
+
// Flag first: getRuntime() must refuse before the client goes away, or a
|
|
388
|
+
// concurrent import finds no runtime and starts a replacement while the
|
|
389
|
+
// server is exiting.
|
|
390
|
+
closing = true;
|
|
391
|
+
try { await activeRun; } catch {}
|
|
392
|
+
// A start still in flight is waited for, so its runtime is disposed too.
|
|
393
|
+
const creation = runtimeCreation;
|
|
394
|
+
runtimeCreation = null;
|
|
395
|
+
const current = await creation?.catch(() => null);
|
|
268
396
|
await current?.dispose?.();
|
|
269
397
|
},
|
|
270
398
|
};
|
|
@@ -65,14 +65,29 @@ export function createSdkSessionSyncService(db) {
|
|
|
65
65
|
WHERE id = ?
|
|
66
66
|
`);
|
|
67
67
|
|
|
68
|
+
// Rekeying a placeholder must carry every live queue row with it: a
|
|
69
|
+
// processing/parked row left under the placeholder id would be orphaned the
|
|
70
|
+
// moment the real session id takes over (audit #22). attempt_id is
|
|
71
|
+
// deliberately untouched — owner migration must not invalidate the Phase 1
|
|
72
|
+
// attempt fence on an in-flight row.
|
|
68
73
|
const migrateQueueOwnerSessionId = db.prepare(`
|
|
69
74
|
UPDATE queue
|
|
70
75
|
SET owner_sdk_session_id = ?
|
|
71
|
-
WHERE status
|
|
76
|
+
WHERE status IN ('pending', 'processing', 'parked')
|
|
72
77
|
AND owner_sdk_session_id = ?
|
|
73
78
|
AND conversation_id = ?
|
|
74
79
|
`);
|
|
75
80
|
|
|
81
|
+
// Once a session syncs, the relay drives it: its ledger row (if it was ever
|
|
82
|
+
// imported) flips to relay ownership so the startup import sweep can never
|
|
83
|
+
// again overwrite relay history with the raw CLI transcript. One-way by
|
|
84
|
+
// design; rows that never existed are a no-op.
|
|
85
|
+
const markImportRelayOwned = db.prepare(`
|
|
86
|
+
UPDATE sdk_session_imports
|
|
87
|
+
SET origin = 'relay', updated_at = ?
|
|
88
|
+
WHERE sdk_session_id = ? AND (origin IS NULL OR origin != 'relay')
|
|
89
|
+
`);
|
|
90
|
+
|
|
76
91
|
const insertRuntimeSession = db.prepare(runtimeSessionsSupportProviders
|
|
77
92
|
? `
|
|
78
93
|
INSERT INTO runtime_sessions (
|
|
@@ -85,24 +100,27 @@ export function createSdkSessionSyncService(db) {
|
|
|
85
100
|
) VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?)
|
|
86
101
|
`);
|
|
87
102
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
103
|
+
// Every conflict check, no writes. syncSessionTx runs it immediately before
|
|
104
|
+
// mutating; the session-sync route additionally runs it (via validateBinding,
|
|
105
|
+
// with requireConversation off — workspace learning may legitimately create
|
|
106
|
+
// the conversation afterwards) BEFORE workspace learning is allowed to touch
|
|
107
|
+
// any table, so a doomed request is vetoed while everything is pristine
|
|
108
|
+
// (audit #21).
|
|
109
|
+
function assertNoBindingConflicts(sdkSessionId, conversationId, { requireConversation = true } = {}) {
|
|
93
110
|
if (!sdkSessionId || !conversationId) {
|
|
94
111
|
throw makeError('Missing sdk_session_id or conversation_id', 400);
|
|
95
112
|
}
|
|
96
113
|
|
|
97
|
-
const conversation = getConversation.get(conversationId);
|
|
98
|
-
|
|
114
|
+
const conversation = getConversation.get(conversationId) || null;
|
|
115
|
+
const conversationMissing = !conversation || String(conversation.status || '').trim() === 'deleted';
|
|
116
|
+
if (conversationMissing && requireConversation) {
|
|
99
117
|
throw makeError('Conversation not found', 404);
|
|
100
118
|
}
|
|
101
119
|
|
|
102
|
-
const existingConversationSdkSessionId = normalizeId(conversation.sdk_session_id);
|
|
103
|
-
const placeholderConversationBinding = existingConversationSdkSessionId
|
|
120
|
+
const existingConversationSdkSessionId = conversationMissing ? '' : normalizeId(conversation.sdk_session_id);
|
|
121
|
+
const placeholderConversationBinding = !!(existingConversationSdkSessionId
|
|
104
122
|
&& existingConversationSdkSessionId === conversationId
|
|
105
|
-
&& existingConversationSdkSessionId !== sdkSessionId;
|
|
123
|
+
&& existingConversationSdkSessionId !== sdkSessionId);
|
|
106
124
|
if (existingConversationSdkSessionId && existingConversationSdkSessionId !== sdkSessionId && !placeholderConversationBinding) {
|
|
107
125
|
throw makeError(
|
|
108
126
|
`Conversation ${conversationId} is already bound to SDK session ${existingConversationSdkSessionId}`,
|
|
@@ -146,14 +164,6 @@ export function createSdkSessionSyncService(db) {
|
|
|
146
164
|
);
|
|
147
165
|
}
|
|
148
166
|
|
|
149
|
-
updateConversationSdkSession.run(sdkSessionId, nowIso, conversationId);
|
|
150
|
-
if (placeholderConversationBinding) {
|
|
151
|
-
migrateQueueOwnerSessionId.run(sdkSessionId, conversationId, conversationId);
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
let runtimeSessionId = null;
|
|
155
|
-
let createdRuntimeSession = false;
|
|
156
|
-
|
|
157
167
|
if (runtimeSessionByConversation) {
|
|
158
168
|
const currentSdkSessionId = normalizeId(runtimeSessionByConversation.sdk_session_id);
|
|
159
169
|
if (currentSdkSessionId && currentSdkSessionId !== sdkSessionId && currentSdkSessionId !== conversationId) {
|
|
@@ -162,18 +172,47 @@ export function createSdkSessionSyncService(db) {
|
|
|
162
172
|
409,
|
|
163
173
|
);
|
|
164
174
|
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
placeholderConversationBinding,
|
|
179
|
+
runtimeSessionByConversation,
|
|
180
|
+
runtimeSessionBySdkSessionId,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ORDERING CONTRACT (audit #22): the queue dequeue path reads
|
|
185
|
+
// owner_sdk_session_id to route pending work, so the placeholder→real rekey
|
|
186
|
+
// below must be visible before the next dequeue considers this
|
|
187
|
+
// conversation's rows. Both run on the same better-sqlite3 connection and
|
|
188
|
+
// this whole rekey is one synchronous transaction, so a dequeue can never
|
|
189
|
+
// observe a half-rekeyed state; callers must keep the in-memory registry
|
|
190
|
+
// rekey immediately after this transaction commits (see the session-sync
|
|
191
|
+
// route) rather than deferring it past other queue work.
|
|
192
|
+
const syncSessionTx = db.transaction((sdkSessionIdRaw, conversationIdRaw) => {
|
|
193
|
+
const sdkSessionId = normalizeId(sdkSessionIdRaw);
|
|
194
|
+
const conversationId = normalizeId(conversationIdRaw);
|
|
195
|
+
const nowIso = new Date().toISOString();
|
|
196
|
+
|
|
197
|
+
const {
|
|
198
|
+
placeholderConversationBinding,
|
|
199
|
+
runtimeSessionByConversation,
|
|
200
|
+
runtimeSessionBySdkSessionId,
|
|
201
|
+
} = assertNoBindingConflicts(sdkSessionId, conversationId, { requireConversation: true });
|
|
202
|
+
|
|
203
|
+
updateConversationSdkSession.run(sdkSessionId, nowIso, conversationId);
|
|
204
|
+
let migratedQueueRows = 0;
|
|
205
|
+
if (placeholderConversationBinding) {
|
|
206
|
+
migratedQueueRows = Number(migrateQueueOwnerSessionId.run(sdkSessionId, conversationId, conversationId).changes || 0);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
let runtimeSessionId = null;
|
|
210
|
+
let createdRuntimeSession = false;
|
|
165
211
|
|
|
212
|
+
if (runtimeSessionByConversation) {
|
|
166
213
|
updateRuntimeSessionSdkSession.run(conversationId, sdkSessionId, nowIso, runtimeSessionByConversation.id);
|
|
167
214
|
runtimeSessionId = runtimeSessionByConversation.id;
|
|
168
215
|
} else if (runtimeSessionBySdkSessionId) {
|
|
169
|
-
const currentConversationId = normalizeId(runtimeSessionBySdkSessionId.conversation_id);
|
|
170
|
-
if (currentConversationId && currentConversationId !== conversationId) {
|
|
171
|
-
throw makeError(
|
|
172
|
-
`SDK session ${sdkSessionId} is already bound to conversation ${currentConversationId}`,
|
|
173
|
-
409,
|
|
174
|
-
);
|
|
175
|
-
}
|
|
176
|
-
|
|
177
216
|
updateRuntimeSessionSdkSession.run(conversationId, sdkSessionId, nowIso, runtimeSessionBySdkSessionId.id);
|
|
178
217
|
runtimeSessionId = runtimeSessionBySdkSessionId.id;
|
|
179
218
|
} else {
|
|
@@ -191,11 +230,23 @@ export function createSdkSessionSyncService(db) {
|
|
|
191
230
|
);
|
|
192
231
|
}
|
|
193
232
|
|
|
233
|
+
markImportRelayOwned.run(nowIso, sdkSessionId);
|
|
234
|
+
if (conversationId !== sdkSessionId) {
|
|
235
|
+
// A conversation originally imported under its own session id keeps a
|
|
236
|
+
// ledger row under that id; rebinding to a new CLI session means the
|
|
237
|
+
// relay owns that history now too.
|
|
238
|
+
markImportRelayOwned.run(nowIso, conversationId);
|
|
239
|
+
}
|
|
240
|
+
|
|
194
241
|
return {
|
|
195
242
|
conversationId,
|
|
196
243
|
sdkSessionId,
|
|
197
244
|
runtimeSessionId,
|
|
198
245
|
createdRuntimeSession,
|
|
246
|
+
migratedQueueRows,
|
|
247
|
+
// Non-null when this sync rekeyed a placeholder binding: the caller must
|
|
248
|
+
// rekey any in-memory worker-registry entry from this id to sdkSessionId.
|
|
249
|
+
placeholderSdkSessionId: placeholderConversationBinding ? conversationId : null,
|
|
199
250
|
};
|
|
200
251
|
});
|
|
201
252
|
|
|
@@ -203,5 +254,11 @@ export function createSdkSessionSyncService(db) {
|
|
|
203
254
|
syncSession({ sdk_session_id, conversation_id }) {
|
|
204
255
|
return syncSessionTx(sdk_session_id, conversation_id);
|
|
205
256
|
},
|
|
257
|
+
// Read-only conflict probe for the session-sync route's validate-first
|
|
258
|
+
// ordering. Missing conversations pass — workspace learning creates them.
|
|
259
|
+
validateBinding({ sdk_session_id, conversation_id }) {
|
|
260
|
+
assertNoBindingConflicts(normalizeId(sdk_session_id), normalizeId(conversation_id), { requireConversation: false });
|
|
261
|
+
return true;
|
|
262
|
+
},
|
|
206
263
|
};
|
|
207
264
|
}
|