@oar-sh/oar 0.9.0
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/README.md +190 -0
- package/.github/extensions/web-relay/extension.mjs +972 -0
- package/.github/extensions/web-relay/model-api/model-switching.mjs +338 -0
- package/.github/extensions/web-relay/polling/heartbeat.mjs +55 -0
- package/.github/extensions/web-relay/polling/polling-loop.mjs +1105 -0
- package/.github/extensions/web-relay/runtime/api-client.mjs +34 -0
- package/.github/extensions/web-relay/runtime/banner-state.mjs +126 -0
- package/.github/extensions/web-relay/runtime/config-loader.mjs +104 -0
- package/.github/extensions/web-relay/runtime/debug-log.mjs +27 -0
- package/.github/extensions/web-relay/runtime/send-and-wait-errors.mjs +170 -0
- package/.github/extensions/web-relay/runtime/session-binding.mjs +78 -0
- package/.github/extensions/web-relay/runtime/session-io.mjs +349 -0
- package/.github/extensions/web-relay/runtime/session-join-retry.mjs +44 -0
- package/.github/extensions/web-relay/runtime/session-registry.mjs +29 -0
- package/.github/extensions/web-relay/runtime/session-runtime-manager.mjs +156 -0
- package/.github/extensions/web-relay/runtime/session-sync-bridge.mjs +74 -0
- package/.github/extensions/web-relay/runtime/worker-websocket-link.mjs +325 -0
- package/.github/extensions/web-relay/runtime/workspace-root.mjs +55 -0
- package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +386 -0
- package/.github/extensions/web-relay/skills/prompt-context.mjs +160 -0
- package/.github/extensions/web-relay/skills/question-bridge.mjs +141 -0
- package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +463 -0
- package/.github/extensions/web-relay/skills/reasoning-stream.mjs +285 -0
- package/.github/extensions/web-relay/skills/subagent-lifecycle.mjs +345 -0
- package/.github/extensions/web-relay/skills/tool-activity.mjs +243 -0
- package/.github/extensions/web-relay/utils/process-utils.mjs +25 -0
- package/.github/extensions/web-relay/utils/token-generation.mjs +58 -0
- package/CHANGELOG.md +25 -0
- package/LICENSE +21 -0
- package/README.md +717 -0
- package/bin/bat/oar.bat +46 -0
- package/bin/oar.js +665 -0
- package/package.json +68 -0
- package/server/README.md +1379 -0
- package/server/claude-worker/claude-attachments.mjs +134 -0
- package/server/claude-worker/claude-preview-tool.mjs +57 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +420 -0
- package/server/claude-worker/claude-session-process.mjs +2391 -0
- package/server/claude-worker/claude-session-test-harness.mjs +236 -0
- package/server/claude-worker/claude-session-worker.mjs +186 -0
- package/server/claude-worker/claude-transcript-relocator.mjs +189 -0
- package/server/claude-worker/claude-turn-publisher.mjs +291 -0
- package/server/claude-worker/fixtures/workflow-run-record.json +1 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +629 -0
- package/server/claude-worker/workflow-progress-digest.mjs +269 -0
- package/server/copilot-sdk-runtime.mjs +126 -0
- package/server/copilot-worker/copilot-attachments.mjs +97 -0
- package/server/copilot-worker/copilot-byok-provider.mjs +121 -0
- package/server/copilot-worker/copilot-continuation-signals.mjs +349 -0
- package/server/copilot-worker/copilot-plan-board.mjs +110 -0
- package/server/copilot-worker/copilot-prompt-context.mjs +86 -0
- package/server/copilot-worker/copilot-question-bridge.mjs +216 -0
- package/server/copilot-worker/copilot-sdk-adapter.mjs +494 -0
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +800 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +1729 -0
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +167 -0
- package/server/copilot-worker/copilot-sdk-test-harness.mjs +329 -0
- package/server/copilot-worker/fixtures/abort-turn.json +588 -0
- package/server/copilot-worker/fixtures/ask-user-turn.json +1154 -0
- package/server/copilot-worker/fixtures/background-timer-continuation.json +445 -0
- package/server/copilot-worker/fixtures/background-timer-turn.json +497 -0
- package/server/copilot-worker/fixtures/happy-turn.json +566 -0
- package/server/copilot-worker/fixtures/quota-turn.json +647 -0
- package/server/copilot-worker/fixtures/reasoning-turn.json +625 -0
- package/server/copilot-worker/fixtures/resume-turn.json +700 -0
- package/server/copilot-worker/fixtures/subagent-turn.json +1378 -0
- package/server/copilot-worker/fixtures/tool-permission-turn.json +1293 -0
- package/server/cursor-worker/cursor-ask-user-tool.mjs +83 -0
- package/server/cursor-worker/cursor-attachments.mjs +88 -0
- package/server/cursor-worker/cursor-context-usage.mjs +85 -0
- package/server/cursor-worker/cursor-preview-tool.mjs +44 -0
- package/server/cursor-worker/cursor-sdk-adapter.mjs +554 -0
- package/server/cursor-worker/cursor-session-worker.mjs +145 -0
- package/server/cursor-worker/cursor-subagent-roster.mjs +81 -0
- package/server/cursor-worker/cursor-turn-runner.mjs +717 -0
- package/server/cursor-worker/sdk-message-normalizer.mjs +485 -0
- package/server/db-schema.mjs +932 -0
- package/server/features.mjs +106 -0
- package/server/grok-worker/acp-client.mjs +434 -0
- package/server/grok-worker/acp-host-services.mjs +277 -0
- package/server/grok-worker/grok-context-usage.mjs +77 -0
- package/server/grok-worker/grok-sdk-adapter.mjs +334 -0
- package/server/grok-worker/grok-session-worker.mjs +145 -0
- package/server/grok-worker/grok-turn-runner.mjs +523 -0
- package/server/grok-worker/sdk-message-normalizer.mjs +311 -0
- package/server/migrations/0001-add-structured-answer.mjs +80 -0
- package/server/migrations/0002-recent-workspace-roots-path-key.mjs +121 -0
- package/server/migrations/0003-push-subscriptions.mjs +87 -0
- package/server/public/app/action-confirmations.js +267 -0
- package/server/public/app/activity-replay-state.mjs +149 -0
- package/server/public/app/api-client.js +1082 -0
- package/server/public/app/ask-user-view.js +545 -0
- package/server/public/app/attachments-view.js +2112 -0
- package/server/public/app/auto-compact-window-options.mjs +59 -0
- package/server/public/app/background-tasks-view.mjs +494 -0
- package/server/public/app/bootstrap.js +4347 -0
- package/server/public/app/claude-auth-ui.js +394 -0
- package/server/public/app/claude-thinking-options.mjs +38 -0
- package/server/public/app/cli-install-ui.js +455 -0
- package/server/public/app/code-copy.mjs +85 -0
- package/server/public/app/composer-attachment-cache.mjs +151 -0
- package/server/public/app/composer-control-state.mjs +107 -0
- package/server/public/app/composer-paste.mjs +132 -0
- package/server/public/app/context-tier-options.mjs +68 -0
- package/server/public/app/context-usage-view.mjs +338 -0
- package/server/public/app/conversation-draft-timestamp-utils.mjs +11 -0
- package/server/public/app/conversation-preferences.mjs +91 -0
- package/server/public/app/conversation-provider-indicator.mjs +88 -0
- package/server/public/app/conversation-view.js +2898 -0
- package/server/public/app/cwd-menu-placement.mjs +85 -0
- package/server/public/app/cwd-picker.js +605 -0
- package/server/public/app/emoji-view.js +101 -0
- package/server/public/app/external-link-policy.mjs +80 -0
- package/server/public/app/file-preview-navigation.mjs +51 -0
- package/server/public/app/font-scaling.js +262 -0
- package/server/public/app/git-changes-view.js +339 -0
- package/server/public/app/git-diff-model.mjs +109 -0
- package/server/public/app/grok-auth-ui.js +387 -0
- package/server/public/app/image-downscale.mjs +174 -0
- package/server/public/app/in-flight-snapshot.mjs +40 -0
- package/server/public/app/infinite-loader.js +164 -0
- package/server/public/app/journal-view.js +1141 -0
- package/server/public/app/known-cwd-options.mjs +41 -0
- package/server/public/app/launch-cwd-path.mjs +23 -0
- package/server/public/app/live-message-dedupe.mjs +57 -0
- package/server/public/app/message-search-view.js +242 -0
- package/server/public/app/model-selector-options.mjs +31 -0
- package/server/public/app/model-vision-support.mjs +56 -0
- package/server/public/app/new-conversation-model-choice.mjs +67 -0
- package/server/public/app/plan-usage-view.mjs +307 -0
- package/server/public/app/preview-cards.mjs +273 -0
- package/server/public/app/preview-command.mjs +103 -0
- package/server/public/app/push-settings.js +370 -0
- package/server/public/app/pwa-install.js +456 -0
- package/server/public/app/question-schema-view.mjs +71 -0
- package/server/public/app/reasoning-effort-labels.mjs +42 -0
- package/server/public/app/relay-board-view.js +117 -0
- package/server/public/app/relay-dot-state.mjs +69 -0
- package/server/public/app/relay-error-ctas.mjs +52 -0
- package/server/public/app/relay-prompt-sanitizer.mjs +114 -0
- package/server/public/app/relay-thoughts.mjs +98 -0
- package/server/public/app/relay-watchdog-policy.mjs +29 -0
- package/server/public/app/repo-browser-preferences.mjs +79 -0
- package/server/public/app/repo-browser-tree-state.mjs +97 -0
- package/server/public/app/router.js +657 -0
- package/server/public/app/selection-guard.mjs +93 -0
- package/server/public/app/settings-modal.js +1484 -0
- package/server/public/app/settings-tabs.js +129 -0
- package/server/public/app/slash-autocomplete.mjs +188 -0
- package/server/public/app/slash-commands.mjs +183 -0
- package/server/public/app/socket-handlers.js +703 -0
- package/server/public/app/status-store.mjs +242 -0
- package/server/public/app/status-view.mjs +315 -0
- package/server/public/app/store.js +1454 -0
- package/server/public/app/stream-state.mjs +73 -0
- package/server/public/app/streaming-dom-patch.mjs +23 -0
- package/server/public/app/sync-outbox.mjs +208 -0
- package/server/public/app/tap-actions.js +62 -0
- package/server/public/app/thread-order.mjs +54 -0
- package/server/public/app/tmux-inspector-view.js +422 -0
- package/server/public/app/transcript-separators.mjs +282 -0
- package/server/public/app-icon-192.png +0 -0
- package/server/public/app-icon-512.png +0 -0
- package/server/public/app-icon.svg +4 -0
- package/server/public/favicon.ico +0 -0
- package/server/public/index.html +5743 -0
- package/server/public/manifest.webmanifest +32 -0
- package/server/public/sw.js +307 -0
- package/server/relay-exit-codes.mjs +4 -0
- package/server/relay-self-restart.mjs +170 -0
- package/server/relay-tools.md +27 -0
- package/server/relay.mjs +1026 -0
- package/server/repositories/image-conversation-repository.mjs +317 -0
- package/server/repositories/message-repository.mjs +434 -0
- package/server/repositories/question-repository.mjs +161 -0
- package/server/repositories/session-repository.mjs +320 -0
- package/server/repositories/share-visibility-statements.mjs +24 -0
- package/server/routes/ask-user-routes.mjs +292 -0
- package/server/routes/cache-routes.mjs +44 -0
- package/server/routes/git-routes.mjs +92 -0
- package/server/routes/messages-routes-test-harness.mjs +111 -0
- package/server/routes/messages-routes.mjs +7107 -0
- package/server/routes/preview-routes.mjs +108 -0
- package/server/routes/push-routes.mjs +59 -0
- package/server/routes/relay-board-routes.mjs +268 -0
- package/server/routes/sessions-routes.mjs +6150 -0
- package/server/server-runtime.mjs +7463 -0
- package/server/server.js +24 -0
- package/server/services/ask-user-routing-service.mjs +117 -0
- package/server/services/cache-rebuild-service.mjs +236 -0
- package/server/services/claude-auth-service.mjs +578 -0
- package/server/services/claude-context-usage.mjs +240 -0
- package/server/services/claude-session-root-service.mjs +228 -0
- package/server/services/cli-install-service.mjs +1152 -0
- package/server/services/cli-process-runner.mjs +181 -0
- package/server/services/cloudflared-tunnel-service.mjs +358 -0
- package/server/services/context-snapshot-service.mjs +499 -0
- package/server/services/context-usage-view.mjs +160 -0
- package/server/services/conversation-preferences-service.mjs +208 -0
- package/server/services/cursor-dashboard-usage.mjs +217 -0
- package/server/services/cursor-session-root-service.mjs +62 -0
- package/server/services/delete-archive-service.mjs +154 -0
- package/server/services/drives-path-helpers.mjs +66 -0
- package/server/services/fixtures/claude-auth-stub.sh +62 -0
- package/server/services/fixtures/cli-install-stub.sh +75 -0
- package/server/services/fixtures/grok-stub.sh +86 -0
- package/server/services/generated-image-cleanup-service.mjs +114 -0
- package/server/services/git-changes-service.mjs +195 -0
- package/server/services/github-billing-usage.mjs +133 -0
- package/server/services/grok-auth-service.mjs +593 -0
- package/server/services/grok-billing-usage.mjs +120 -0
- package/server/services/image-context-service.mjs +106 -0
- package/server/services/image-operation-service.mjs +135 -0
- package/server/services/keyed-mutex-service.mjs +82 -0
- package/server/services/mime-sniffer.mjs +118 -0
- package/server/services/oar-cli-helpers.mjs +109 -0
- package/server/services/oar-state-migration-service.mjs +243 -0
- package/server/services/plan-usage-claude.mjs +387 -0
- package/server/services/plan-usage-contract.mjs +271 -0
- package/server/services/plan-usage-copilot.mjs +421 -0
- package/server/services/plan-usage-cursor.mjs +566 -0
- package/server/services/plan-usage-grok.mjs +436 -0
- package/server/services/plan-usage-service.mjs +487 -0
- package/server/services/preview-health-probe.mjs +82 -0
- package/server/services/preview-proxy-server.mjs +498 -0
- package/server/services/preview-registry-service.mjs +457 -0
- package/server/services/preview-static-handler.mjs +237 -0
- package/server/services/provider-model-selection.mjs +91 -0
- package/server/services/provider-reasoning-effort.mjs +94 -0
- package/server/services/push-active-device-service.mjs +57 -0
- package/server/services/push-dispatch-service.mjs +459 -0
- package/server/services/rate-limit-service.mjs +63 -0
- package/server/services/relay-bridge-owner-service.mjs +84 -0
- package/server/services/relay-cli-launcher-service.mjs +209 -0
- package/server/services/relay-prompt-sanitizer.mjs +137 -0
- package/server/services/relay-queue-gate-service.mjs +38 -0
- package/server/services/relay-restart-orchestrator-service.mjs +935 -0
- package/server/services/relay-singleton-guard.mjs +124 -0
- package/server/services/safe-served-content.mjs +57 -0
- package/server/services/sdk-session-import-service.mjs +271 -0
- package/server/services/sdk-session-sync-service.mjs +207 -0
- package/server/services/session-history-refresh-service.mjs +497 -0
- package/server/services/session-transcript-service.mjs +419 -0
- package/server/services/session-worker-launch-service.mjs +786 -0
- package/server/services/session-worker-process-service.mjs +319 -0
- package/server/services/session-worker-registry-service.mjs +194 -0
- package/server/services/session-worker-stop-service.mjs +141 -0
- package/server/services/session-worker-supervisor-service.mjs +898 -0
- package/server/services/session-worker-websocket-service.mjs +483 -0
- package/server/services/ssh-tunnel-manager-service.mjs +423 -0
- package/server/services/status-event-service.mjs +158 -0
- package/server/services/tmux-inspector-access-policy.mjs +77 -0
- package/server/services/tmux-inspector-socket-service.mjs +191 -0
- package/server/services/tmux-inspector-stream-service.mjs +552 -0
- package/server/services/tunnel-worker-path-guard.mjs +107 -0
- package/server/services/upload-sweep.mjs +60 -0
- package/server/services/usage-snapshot-helpers.mjs +209 -0
- package/server/services/windows-autostart-service.mjs +115 -0
- package/server/services/workspace-root-defaults-service.mjs +34 -0
- package/server/services/workspace-root-path-policy.mjs +196 -0
- package/server/services/workspace-root-relaunch-service.mjs +132 -0
- package/server/services/workspace-symlink-guard.mjs +54 -0
- package/server/tools/capture-proxy.mjs +173 -0
- package/server/tty-console-bootstrap.mjs +75 -0
- package/server/uploads/README.md +36 -0
- package/server/uploads/image_input.md +540 -0
- package/server/windows-terminal-launcher.mjs +29 -0
- package/server/workspace-root.mjs +87 -0
- package/shared/ask-user-bridge.mjs +131 -0
- package/shared/auto-compact-window.mjs +116 -0
- package/shared/background-task-timeout.mjs +52 -0
- package/shared/claude-thinking.mjs +101 -0
- package/shared/context-window-fallbacks.mjs +148 -0
- package/shared/control-poller.mjs +115 -0
- package/shared/empty-turn-completion.mjs +20 -0
- package/shared/model-catalog-freshness.mjs +11 -0
- package/shared/model-descriptors.mjs +118 -0
- package/shared/model-id.mjs +147 -0
- package/shared/model-refresh.mjs +24 -0
- package/shared/openai-reasoning.mjs +13 -0
- package/shared/plan-lines.mjs +13 -0
- package/shared/preview-instructions.mjs +77 -0
- package/shared/preview-tool-core.mjs +191 -0
- package/shared/provider-routing.mjs +29 -0
- package/shared/question-schema.mjs +315 -0
- package/shared/question-timeout.mjs +18 -0
- package/shared/stream-emit-gating.mjs +28 -0
- package/shared/subagent-run-id.mjs +17 -0
- package/shared/thought-cap.mjs +13 -0
- package/shared/tool-activity.mjs +98 -0
- package/shared/turn-ceiling.mjs +53 -0
- package/shared/worker-bootstrap.mjs +44 -0
- package/shared/worker-crash-guard.mjs +60 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import crypto from "crypto";
|
|
4
|
+
|
|
5
|
+
function hashToken(value) {
|
|
6
|
+
const token = String(value || "").trim();
|
|
7
|
+
if (!token) return "";
|
|
8
|
+
return crypto.createHash("sha256").update(token).digest("hex");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function safeReadLock(lockPath) {
|
|
12
|
+
try {
|
|
13
|
+
const raw = fs.readFileSync(lockPath, "utf8");
|
|
14
|
+
const parsed = JSON.parse(raw);
|
|
15
|
+
// Prefer the hashed field; fall back to hashing a legacy plaintext `token`
|
|
16
|
+
// so a lock written by an older build still matches (and is rewritten 0600).
|
|
17
|
+
const tokenHash = typeof parsed?.tokenHash === "string" && parsed.tokenHash
|
|
18
|
+
? parsed.tokenHash
|
|
19
|
+
: hashToken(parsed?.token);
|
|
20
|
+
return {
|
|
21
|
+
pid: Number.parseInt(String(parsed?.pid ?? ""), 10),
|
|
22
|
+
tokenHash,
|
|
23
|
+
startedAt: typeof parsed?.startedAt === "string" ? parsed.startedAt : null,
|
|
24
|
+
};
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function createRelaySingletonGuard({
|
|
31
|
+
lockPath,
|
|
32
|
+
pid,
|
|
33
|
+
token,
|
|
34
|
+
now = () => new Date().toISOString(),
|
|
35
|
+
isProcessAlive,
|
|
36
|
+
logger = console,
|
|
37
|
+
}) {
|
|
38
|
+
const lockFilePath = path.resolve(String(lockPath || ""));
|
|
39
|
+
const currentPid = Number.parseInt(String(pid || process.pid), 10);
|
|
40
|
+
const currentTokenHash = hashToken(token);
|
|
41
|
+
const processAlive = typeof isProcessAlive === "function"
|
|
42
|
+
? isProcessAlive
|
|
43
|
+
: (candidatePid) => {
|
|
44
|
+
if (!Number.isInteger(candidatePid) || candidatePid <= 0) return false;
|
|
45
|
+
try {
|
|
46
|
+
process.kill(candidatePid, 0);
|
|
47
|
+
return true;
|
|
48
|
+
} catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
function writeLock() {
|
|
54
|
+
fs.mkdirSync(path.dirname(lockFilePath), { recursive: true });
|
|
55
|
+
const payload = {
|
|
56
|
+
pid: currentPid,
|
|
57
|
+
// Store only a hash of the auth token: the guard needs equality for the
|
|
58
|
+
// self-ownership check, never the token itself. Keeps the master
|
|
59
|
+
// credential out of a file that has historically been world-readable.
|
|
60
|
+
tokenHash: currentTokenHash,
|
|
61
|
+
startedAt: now(),
|
|
62
|
+
};
|
|
63
|
+
const fd = fs.openSync(lockFilePath, "wx", 0o600);
|
|
64
|
+
try {
|
|
65
|
+
fs.writeFileSync(fd, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
|
66
|
+
} finally {
|
|
67
|
+
try { fs.closeSync(fd); } catch {}
|
|
68
|
+
}
|
|
69
|
+
return payload;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function acquire() {
|
|
73
|
+
try {
|
|
74
|
+
return writeLock();
|
|
75
|
+
} catch (error) {
|
|
76
|
+
if (error?.code !== "EEXIST") {
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const existingLock = safeReadLock(lockFilePath);
|
|
82
|
+
const existingPid = Number.isInteger(existingLock?.pid) ? existingLock.pid : null;
|
|
83
|
+
const existingTokenHash = typeof existingLock?.tokenHash === "string" ? existingLock.tokenHash : "";
|
|
84
|
+
const existingStartedAt = existingLock?.startedAt || "unknown";
|
|
85
|
+
|
|
86
|
+
if (existingPid && processAlive(existingPid)) {
|
|
87
|
+
if (existingTokenHash && existingTokenHash === currentTokenHash) {
|
|
88
|
+
throw new Error(`Relay already running (pid=${existingPid}, startedAt=${existingStartedAt}).`);
|
|
89
|
+
}
|
|
90
|
+
throw new Error(
|
|
91
|
+
`Relay appears to be owned by another live process (pid=${existingPid}, startedAt=${existingStartedAt}).`
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
fs.unlinkSync(lockFilePath);
|
|
97
|
+
logger?.warn?.(`[server] Recovered stale singleton lock: ${lockFilePath}`);
|
|
98
|
+
} catch (unlinkError) {
|
|
99
|
+
if (unlinkError?.code !== "ENOENT") throw unlinkError;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return writeLock();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function release() {
|
|
106
|
+
const existingLock = safeReadLock(lockFilePath);
|
|
107
|
+
if (!existingLock) return false;
|
|
108
|
+
if (existingLock.pid !== currentPid) return false;
|
|
109
|
+
if (String(existingLock.tokenHash || "") !== currentTokenHash) return false;
|
|
110
|
+
try {
|
|
111
|
+
fs.unlinkSync(lockFilePath);
|
|
112
|
+
return true;
|
|
113
|
+
} catch (error) {
|
|
114
|
+
if (error?.code === "ENOENT") return true;
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
lockFilePath,
|
|
121
|
+
acquire,
|
|
122
|
+
release,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Safe headers for streaming stored bytes (workspace files, uploads, shared
|
|
2
|
+
// attachments) back to the browser.
|
|
3
|
+
//
|
|
4
|
+
// The threat: a semi-trusted producer (an AI worker writing a workspace file, or
|
|
5
|
+
// an uploaded attachment) can supply content whose MIME type the browser will
|
|
6
|
+
// execute as markup/script on THIS origin — `text/html`, `image/svg+xml`, XML
|
|
7
|
+
// with embedded script. Served inline, that is stored XSS against the app (and,
|
|
8
|
+
// via the unauthenticated /api/shared/* routes, against share viewers). We
|
|
9
|
+
// neutralize such types to `text/plain` and always send `nosniff`, and we attach
|
|
10
|
+
// a locked-down CSP + `sandbox` so anything that still reaches the browser as a
|
|
11
|
+
// document can neither run script nor navigate.
|
|
12
|
+
|
|
13
|
+
// Types the browser renders as active documents on this origin.
|
|
14
|
+
const ACTIVE_MARKUP_PATTERN = /^text\/html\b|svg|xhtml|(^|\/)xml\b|\+xml\b/i;
|
|
15
|
+
|
|
16
|
+
// Types safe to preview inline with their real Content-Type.
|
|
17
|
+
const INLINE_SAFE_PREFIXES = ['image/', 'video/', 'audio/'];
|
|
18
|
+
const INLINE_SAFE_EXACT = new Set(['application/pdf']);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Resolve the Content-Type to send for stored bytes, neutralizing types the
|
|
22
|
+
* browser would execute. Returns the (possibly coerced) type plus whether it is
|
|
23
|
+
* safe to display inline.
|
|
24
|
+
*/
|
|
25
|
+
export function safeServedContentType(mimeType) {
|
|
26
|
+
const type = String(mimeType || '').trim().toLowerCase().split(';')[0].slice(0, 127)
|
|
27
|
+
|| 'application/octet-stream';
|
|
28
|
+
if (ACTIVE_MARKUP_PATTERN.test(type)) {
|
|
29
|
+
// Render markup/script content as its own source text instead of executing it.
|
|
30
|
+
return { contentType: 'text/plain; charset=utf-8', inlineSafe: false, neutralized: true };
|
|
31
|
+
}
|
|
32
|
+
const inlineSafe = INLINE_SAFE_PREFIXES.some((prefix) => type.startsWith(prefix))
|
|
33
|
+
|| INLINE_SAFE_EXACT.has(type);
|
|
34
|
+
return { contentType: type, inlineSafe, neutralized: false };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Apply the safe Content-Type plus the hardening headers to a response that is
|
|
39
|
+
* about to stream stored bytes. Returns the resolved descriptor so callers can
|
|
40
|
+
* choose an inline vs. attachment disposition.
|
|
41
|
+
*/
|
|
42
|
+
export function applySafeServedContentHeaders(res, mimeType, { fileName = '' } = {}) {
|
|
43
|
+
const resolved = safeServedContentType(mimeType);
|
|
44
|
+
res.setHeader('Content-Type', resolved.contentType);
|
|
45
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
46
|
+
// Belt-and-braces: a served file is never part of the app UI, so forbid it
|
|
47
|
+
// from loading anything or being framed, and sandbox it so it cannot run
|
|
48
|
+
// script or navigate even if a browser would otherwise treat it as active.
|
|
49
|
+
res.setHeader('Content-Security-Policy', "default-src 'none'; sandbox; frame-ancestors 'none'");
|
|
50
|
+
const safeName = String(fileName || '').replace(/[\r\n"\\]/g, '').slice(0, 255);
|
|
51
|
+
const disposition = resolved.inlineSafe ? 'inline' : 'attachment';
|
|
52
|
+
res.setHeader(
|
|
53
|
+
'Content-Disposition',
|
|
54
|
+
safeName ? `${disposition}; filename="${safeName}"` : disposition,
|
|
55
|
+
);
|
|
56
|
+
return resolved;
|
|
57
|
+
}
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
function text(value) {
|
|
4
|
+
return String(value || '').trim();
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function iso(value, fallback = null) {
|
|
8
|
+
const parsed = value instanceof Date ? value.getTime() : Date.parse(String(value || ''));
|
|
9
|
+
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : fallback;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function sessionIdOf(session) {
|
|
13
|
+
return text(session?.sessionId || session?.id || session?.session_id);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function sessionMetadata(session) {
|
|
17
|
+
return session?.metadata || session || {};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function sourceTimestamps(session) {
|
|
21
|
+
const metadata = sessionMetadata(session);
|
|
22
|
+
return {
|
|
23
|
+
startedAt: iso(
|
|
24
|
+
metadata.startTime
|
|
25
|
+
|| metadata.start_time
|
|
26
|
+
|| metadata.createdAt
|
|
27
|
+
|| metadata.created_at
|
|
28
|
+
|| session?.startTime
|
|
29
|
+
|| session?.createdAt,
|
|
30
|
+
),
|
|
31
|
+
modifiedAt: iso(
|
|
32
|
+
metadata.modifiedTime
|
|
33
|
+
|| metadata.modified_time
|
|
34
|
+
|| metadata.updatedAt
|
|
35
|
+
|| metadata.updated_at
|
|
36
|
+
|| session?.modifiedTime
|
|
37
|
+
|| session?.updatedAt,
|
|
38
|
+
),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function sessionTitle(session, messages) {
|
|
43
|
+
const metadata = session?.metadata || session || {};
|
|
44
|
+
const summary = text(metadata.summary || metadata.title || metadata.name);
|
|
45
|
+
if (summary) return summary.slice(0, 240);
|
|
46
|
+
const firstUser = (Array.isArray(messages) ? messages : []).find((message) => message?.role === 'user');
|
|
47
|
+
return text(firstUser?.text).replace(/\s+/g, ' ').slice(0, 240) || 'Session';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function normalizeEvents(value) {
|
|
51
|
+
if (Array.isArray(value)) return value;
|
|
52
|
+
if (Array.isArray(value?.sessions)) return value.sessions;
|
|
53
|
+
if (Array.isArray(value?.events)) return value.events;
|
|
54
|
+
if (value?.[Symbol.asyncIterator]) {
|
|
55
|
+
const events = [];
|
|
56
|
+
for await (const event of value) events.push(event);
|
|
57
|
+
return events;
|
|
58
|
+
}
|
|
59
|
+
if (value?.[Symbol.iterator]) return [...value];
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function boundedError(error) {
|
|
64
|
+
return text(error?.message || error).slice(0, 1000) || 'Unknown SDK session import failure';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function createSdkSessionImportService({
|
|
68
|
+
db,
|
|
69
|
+
stmts,
|
|
70
|
+
createClient,
|
|
71
|
+
parseSessionEventsToMessages,
|
|
72
|
+
replaceRetrievableHistory,
|
|
73
|
+
ensureRuntimeSessionBinding,
|
|
74
|
+
logger = console,
|
|
75
|
+
} = {}) {
|
|
76
|
+
if (!db || !stmts || typeof createClient !== 'function') throw new Error('SDK session importer requires database, statements, and a client factory');
|
|
77
|
+
let runtime = null;
|
|
78
|
+
let activeRun = null;
|
|
79
|
+
const countConversationMessages = db.prepare(`
|
|
80
|
+
SELECT COUNT(*) AS count
|
|
81
|
+
FROM messages
|
|
82
|
+
WHERE conversation_id = ?
|
|
83
|
+
`);
|
|
84
|
+
|
|
85
|
+
const upsertConversation = db.prepare(`
|
|
86
|
+
INSERT INTO conversations (id, title, sdk_session_id, configured_workspace_root_path, runtime_workspace_root_path, created_at, updated_at)
|
|
87
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
88
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
89
|
+
sdk_session_id = excluded.sdk_session_id,
|
|
90
|
+
title = CASE WHEN conversations.title_source = 'manual' THEN conversations.title ELSE excluded.title END,
|
|
91
|
+
configured_workspace_root_path = COALESCE(conversations.configured_workspace_root_path, excluded.configured_workspace_root_path),
|
|
92
|
+
runtime_workspace_root_path = COALESCE(conversations.runtime_workspace_root_path, excluded.runtime_workspace_root_path),
|
|
93
|
+
updated_at = excluded.updated_at
|
|
94
|
+
`);
|
|
95
|
+
|
|
96
|
+
async function getRuntime() {
|
|
97
|
+
if (!runtime) runtime = await createClient();
|
|
98
|
+
return runtime;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function isTombstoned(sdkSessionId) {
|
|
102
|
+
return !!stmts.getDeletedSdkSession.get(sdkSessionId);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function claim(sdkSessionId, session, { force = false } = {}) {
|
|
106
|
+
const now = new Date().toISOString();
|
|
107
|
+
return db.transaction(() => {
|
|
108
|
+
const existing = stmts.getSdkSessionImport.get(sdkSessionId);
|
|
109
|
+
const { modifiedAt } = sourceTimestamps(session);
|
|
110
|
+
const hasNewerSource = modifiedAt
|
|
111
|
+
&& (!existing?.source_modified_at || modifiedAt > existing.source_modified_at);
|
|
112
|
+
if (!force && existing?.status === 'completed' && !hasNewerSource) {
|
|
113
|
+
return { claimed: null, category: 'unchanged' };
|
|
114
|
+
}
|
|
115
|
+
stmts.upsertSdkSessionImport.run(sdkSessionId, existing?.conversation_id || sdkSessionId, now);
|
|
116
|
+
const claimed = stmts.claimSdkSessionImport.run(
|
|
117
|
+
now,
|
|
118
|
+
now,
|
|
119
|
+
sdkSessionId,
|
|
120
|
+
force || hasNewerSource ? 1 : 0,
|
|
121
|
+
);
|
|
122
|
+
if (claimed.changes === 0) return { claimed: null, category: 'unchanged' };
|
|
123
|
+
return {
|
|
124
|
+
claimed: stmts.getSdkSessionImport.get(sdkSessionId),
|
|
125
|
+
category: existing?.status === 'completed' ? 'changed' : 'new',
|
|
126
|
+
};
|
|
127
|
+
})();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function persistCompletedImport({ sdkSessionId, session, messages }) {
|
|
131
|
+
const now = new Date().toISOString();
|
|
132
|
+
const metadata = sessionMetadata(session);
|
|
133
|
+
const { startedAt: sourceStartedAt, modifiedAt: sourceModifiedAt } = sourceTimestamps(session);
|
|
134
|
+
const updatedAt = sourceModifiedAt || now;
|
|
135
|
+
const createdAt = sourceStartedAt || updatedAt;
|
|
136
|
+
const workspaceRoot = text(metadata.workspaceRootPath || metadata.workspace_root_path || metadata.cwd) || null;
|
|
137
|
+
const title = sessionTitle(session, messages);
|
|
138
|
+
db.transaction(() => {
|
|
139
|
+
upsertConversation.run(sdkSessionId, title, sdkSessionId, workspaceRoot, workspaceRoot, createdAt, updatedAt);
|
|
140
|
+
ensureRuntimeSessionBinding(sdkSessionId, null, updatedAt, sdkSessionId);
|
|
141
|
+
replaceRetrievableHistory(sdkSessionId, messages);
|
|
142
|
+
stmts.completeSdkSessionImport.run(
|
|
143
|
+
sdkSessionId,
|
|
144
|
+
now,
|
|
145
|
+
sourceStartedAt,
|
|
146
|
+
sourceModifiedAt,
|
|
147
|
+
now,
|
|
148
|
+
sdkSessionId,
|
|
149
|
+
);
|
|
150
|
+
})();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function importSession(session, { force = false } = {}) {
|
|
154
|
+
const sdkSessionId = sessionIdOf(session);
|
|
155
|
+
if (!sdkSessionId) return { status: 'skipped', category: 'unchanged', reason: 'missing-session-id' };
|
|
156
|
+
if (isTombstoned(sdkSessionId)) return { sdkSessionId, status: 'skipped', category: 'tombstoned', reason: 'tombstoned' };
|
|
157
|
+
// Sessions the relay created to execute an existing conversation's turns
|
|
158
|
+
// are vehicles, not conversations: their history already lives in the
|
|
159
|
+
// conversation they ran for. Importing them creates duplicate "shadow"
|
|
160
|
+
// conversations in the list.
|
|
161
|
+
const relayLink = stmts.getRelaySessionLink?.get?.(sdkSessionId) || null;
|
|
162
|
+
if (relayLink && text(relayLink.conversation_id) && text(relayLink.conversation_id) !== sdkSessionId) {
|
|
163
|
+
return { sdkSessionId, status: 'skipped', category: 'relay-owned', reason: 'relay-execution-session' };
|
|
164
|
+
}
|
|
165
|
+
// The SDK-engine workers (Copilot SDK, Claude, Cursor, Grok) run a
|
|
166
|
+
// conversation's turns in a CLI session that shares the conversation's OWN
|
|
167
|
+
// id — the exact equality the guard above reads as "external". A runtime
|
|
168
|
+
// binding for that conversation means the relay executes it and already
|
|
169
|
+
// owns its history: importing would overwrite relay messages with the raw
|
|
170
|
+
// runtime transcript, instruction preambles included (burn-in incident
|
|
171
|
+
// 2026-08-31: "[Relay mode: autopilot] …" surfaced as user bubbles and
|
|
172
|
+
// conversation titles after a restart). This also protects an imported
|
|
173
|
+
// conversation the user later CONTINUED in the relay — from that moment
|
|
174
|
+
// the relay's history is authoritative, not the CLI transcript.
|
|
175
|
+
const runtimeSession = stmts.getRuntimeSessionByConversation?.get?.(sdkSessionId) || null;
|
|
176
|
+
if (runtimeSession) {
|
|
177
|
+
return { sdkSessionId, status: 'skipped', category: 'relay-owned', reason: 'relay-execution-session' };
|
|
178
|
+
}
|
|
179
|
+
// Same protection when a live conversation already claims this session id
|
|
180
|
+
// as its transcript binding under a different conversation id.
|
|
181
|
+
const owningConversation = stmts.getConvBySdkSessionId?.get?.(sdkSessionId) || null;
|
|
182
|
+
if (owningConversation && text(owningConversation.id) && text(owningConversation.id) !== sdkSessionId) {
|
|
183
|
+
return { sdkSessionId, status: 'skipped', category: 'bound-elsewhere', reason: 'session-bound-to-existing-conversation' };
|
|
184
|
+
}
|
|
185
|
+
const claimed = claim(sdkSessionId, session, { force });
|
|
186
|
+
if (!claimed.claimed) return { sdkSessionId, status: 'skipped', category: claimed.category, reason: 'unchanged-or-active' };
|
|
187
|
+
|
|
188
|
+
let resumed = null;
|
|
189
|
+
try {
|
|
190
|
+
const client = await getRuntime();
|
|
191
|
+
resumed = await client.client.resumeSession(sdkSessionId, {
|
|
192
|
+
suppressResumeEvent: true,
|
|
193
|
+
availableTools: [],
|
|
194
|
+
});
|
|
195
|
+
const events = await normalizeEvents(await resumed.getEvents());
|
|
196
|
+
const messages = parseSessionEventsToMessages?.(events);
|
|
197
|
+
if (!Array.isArray(messages)) throw new Error('SDK session import returned an invalid history snapshot');
|
|
198
|
+
const existingMessageCount = Number(countConversationMessages.get(sdkSessionId)?.count || 0);
|
|
199
|
+
if (messages.length === 0 && existingMessageCount > 0) {
|
|
200
|
+
throw new Error('SDK session import returned an empty history snapshot for an existing conversation');
|
|
201
|
+
}
|
|
202
|
+
persistCompletedImport({ sdkSessionId, session, messages });
|
|
203
|
+
return { sdkSessionId, status: 'completed', category: claimed.category, messageCount: messages.length };
|
|
204
|
+
} catch (error) {
|
|
205
|
+
stmts.failSdkSessionImport.run(new Date().toISOString(), boundedError(error), sdkSessionId);
|
|
206
|
+
return { sdkSessionId, status: 'failed', category: 'failed', error: boundedError(error) };
|
|
207
|
+
} finally {
|
|
208
|
+
try { await resumed?.stop?.(); } catch {}
|
|
209
|
+
try { await resumed?.dispose?.(); } catch {}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function runStartupImport() {
|
|
214
|
+
if (activeRun) return activeRun;
|
|
215
|
+
activeRun = (async () => {
|
|
216
|
+
const summary = {
|
|
217
|
+
listed: 0,
|
|
218
|
+
new: 0,
|
|
219
|
+
changed: 0,
|
|
220
|
+
unchanged: 0,
|
|
221
|
+
failed: 0,
|
|
222
|
+
tombstoned: 0,
|
|
223
|
+
'relay-owned': 0,
|
|
224
|
+
'bound-elsewhere': 0,
|
|
225
|
+
};
|
|
226
|
+
try {
|
|
227
|
+
stmts.resetInterruptedSdkSessionImports.run(new Date().toISOString());
|
|
228
|
+
const client = await getRuntime();
|
|
229
|
+
const sessions = await normalizeEvents(await client.client.listSessions());
|
|
230
|
+
summary.listed = sessions.length;
|
|
231
|
+
for (const session of sessions) {
|
|
232
|
+
const result = await importSession(session);
|
|
233
|
+
summary[result.category] = Number(summary[result.category] || 0) + 1;
|
|
234
|
+
}
|
|
235
|
+
} catch (error) {
|
|
236
|
+
summary.failed += 1;
|
|
237
|
+
summary.error = boundedError(error);
|
|
238
|
+
} finally {
|
|
239
|
+
activeRun = null;
|
|
240
|
+
}
|
|
241
|
+
logger.info?.(
|
|
242
|
+
`[sdk-session-import] listed=${summary.listed} new=${summary.new} changed=${summary.changed}`
|
|
243
|
+
+ ` unchanged=${summary.unchanged} failed=${summary.failed} tombstoned=${summary.tombstoned}`
|
|
244
|
+
+ ` relay-owned=${summary['relay-owned']} bound-elsewhere=${summary['bound-elsewhere']}`,
|
|
245
|
+
);
|
|
246
|
+
return summary;
|
|
247
|
+
})();
|
|
248
|
+
return activeRun;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function refreshConversation(conversation) {
|
|
252
|
+
const sdkSessionId = text(conversation?.sdk_session_id || conversation?.sdkSessionId || conversation?.id);
|
|
253
|
+
if (!sdkSessionId || isTombstoned(sdkSessionId)) {
|
|
254
|
+
const error = new Error('Conversation not found');
|
|
255
|
+
error.statusCode = 404;
|
|
256
|
+
throw error;
|
|
257
|
+
}
|
|
258
|
+
return importSession({ sessionId: sdkSessionId, metadata: conversation }, { force: true });
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
runStartupImport,
|
|
263
|
+
importSession,
|
|
264
|
+
refreshConversation,
|
|
265
|
+
async dispose() {
|
|
266
|
+
const current = runtime;
|
|
267
|
+
runtime = null;
|
|
268
|
+
await current?.dispose?.();
|
|
269
|
+
},
|
|
270
|
+
};
|
|
271
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
4
|
+
|
|
5
|
+
function normalizeId(value) {
|
|
6
|
+
return String(value || '').trim();
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function makeError(message, statusCode) {
|
|
10
|
+
const error = new Error(message);
|
|
11
|
+
error.statusCode = statusCode;
|
|
12
|
+
return error;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function createSdkSessionSyncService(db) {
|
|
16
|
+
const runtimeSessionColumns = new Set(
|
|
17
|
+
db.prepare(`PRAGMA table_info(runtime_sessions)`).all().map((column) => String(column?.name || '').trim()),
|
|
18
|
+
);
|
|
19
|
+
const runtimeSessionsSupportProviders = runtimeSessionColumns.has('provider_type')
|
|
20
|
+
&& runtimeSessionColumns.has('provider_model');
|
|
21
|
+
const providerSelect = runtimeSessionsSupportProviders
|
|
22
|
+
? 'provider_type, provider_model'
|
|
23
|
+
: `'github' AS provider_type, NULL AS provider_model`;
|
|
24
|
+
const getConversation = db.prepare(`
|
|
25
|
+
SELECT id, sdk_session_id, status
|
|
26
|
+
FROM conversations
|
|
27
|
+
WHERE id = ?
|
|
28
|
+
LIMIT 1
|
|
29
|
+
`);
|
|
30
|
+
|
|
31
|
+
const getConversationBySdkSessionId = db.prepare(`
|
|
32
|
+
SELECT id, sdk_session_id, status
|
|
33
|
+
FROM conversations
|
|
34
|
+
WHERE sdk_session_id = ?
|
|
35
|
+
AND id != ?
|
|
36
|
+
LIMIT 1
|
|
37
|
+
`);
|
|
38
|
+
|
|
39
|
+
const getRuntimeSessionByConversation = db.prepare(`
|
|
40
|
+
SELECT id, conversation_id, sdk_session_id, status, strategy, runtime_key, model, ${providerSelect}
|
|
41
|
+
FROM runtime_sessions
|
|
42
|
+
WHERE conversation_id = ?
|
|
43
|
+
LIMIT 1
|
|
44
|
+
`);
|
|
45
|
+
|
|
46
|
+
const getRuntimeSessionBySdkSessionId = db.prepare(`
|
|
47
|
+
SELECT id, conversation_id, sdk_session_id, status, strategy, runtime_key, model, ${providerSelect}
|
|
48
|
+
FROM runtime_sessions
|
|
49
|
+
WHERE sdk_session_id = ?
|
|
50
|
+
LIMIT 1
|
|
51
|
+
`);
|
|
52
|
+
|
|
53
|
+
const updateConversationSdkSession = db.prepare(`
|
|
54
|
+
UPDATE conversations
|
|
55
|
+
SET sdk_session_id = ?, updated_at = ?
|
|
56
|
+
WHERE id = ?
|
|
57
|
+
`);
|
|
58
|
+
|
|
59
|
+
const updateRuntimeSessionSdkSession = db.prepare(`
|
|
60
|
+
UPDATE runtime_sessions
|
|
61
|
+
SET conversation_id = ?,
|
|
62
|
+
sdk_session_id = ?,
|
|
63
|
+
last_used_at = ?,
|
|
64
|
+
status = COALESCE(status, 'active')
|
|
65
|
+
WHERE id = ?
|
|
66
|
+
`);
|
|
67
|
+
|
|
68
|
+
const migrateQueueOwnerSessionId = db.prepare(`
|
|
69
|
+
UPDATE queue
|
|
70
|
+
SET owner_sdk_session_id = ?
|
|
71
|
+
WHERE status = 'pending'
|
|
72
|
+
AND owner_sdk_session_id = ?
|
|
73
|
+
AND conversation_id = ?
|
|
74
|
+
`);
|
|
75
|
+
|
|
76
|
+
const insertRuntimeSession = db.prepare(runtimeSessionsSupportProviders
|
|
77
|
+
? `
|
|
78
|
+
INSERT INTO runtime_sessions (
|
|
79
|
+
id, conversation_id, strategy, runtime_key, model, status, created_at, last_used_at, sdk_session_id, provider_type, provider_model
|
|
80
|
+
) VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?, 'github', NULL)
|
|
81
|
+
`
|
|
82
|
+
: `
|
|
83
|
+
INSERT INTO runtime_sessions (
|
|
84
|
+
id, conversation_id, strategy, runtime_key, model, status, created_at, last_used_at, sdk_session_id
|
|
85
|
+
) VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?)
|
|
86
|
+
`);
|
|
87
|
+
|
|
88
|
+
const syncSessionTx = db.transaction((sdkSessionIdRaw, conversationIdRaw) => {
|
|
89
|
+
const sdkSessionId = normalizeId(sdkSessionIdRaw);
|
|
90
|
+
const conversationId = normalizeId(conversationIdRaw);
|
|
91
|
+
const nowIso = new Date().toISOString();
|
|
92
|
+
|
|
93
|
+
if (!sdkSessionId || !conversationId) {
|
|
94
|
+
throw makeError('Missing sdk_session_id or conversation_id', 400);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const conversation = getConversation.get(conversationId);
|
|
98
|
+
if (!conversation || String(conversation.status || '').trim() === 'deleted') {
|
|
99
|
+
throw makeError('Conversation not found', 404);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const existingConversationSdkSessionId = normalizeId(conversation.sdk_session_id);
|
|
103
|
+
const placeholderConversationBinding = existingConversationSdkSessionId
|
|
104
|
+
&& existingConversationSdkSessionId === conversationId
|
|
105
|
+
&& existingConversationSdkSessionId !== sdkSessionId;
|
|
106
|
+
if (existingConversationSdkSessionId && existingConversationSdkSessionId !== sdkSessionId && !placeholderConversationBinding) {
|
|
107
|
+
throw makeError(
|
|
108
|
+
`Conversation ${conversationId} is already bound to SDK session ${existingConversationSdkSessionId}`,
|
|
109
|
+
409,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const otherConversation = getConversationBySdkSessionId.get(sdkSessionId, conversationId);
|
|
114
|
+
if (otherConversation) {
|
|
115
|
+
throw makeError(
|
|
116
|
+
`SDK session ${sdkSessionId} is already bound to conversation ${otherConversation.id}`,
|
|
117
|
+
409,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const runtimeSessionByConversation = getRuntimeSessionByConversation.get(conversationId) || null;
|
|
122
|
+
const runtimeSessionBySdkSessionId = getRuntimeSessionBySdkSessionId.get(sdkSessionId) || null;
|
|
123
|
+
|
|
124
|
+
if (runtimeSessionByConversation && runtimeSessionBySdkSessionId) {
|
|
125
|
+
const sameRuntimeSession = String(runtimeSessionByConversation.id || '') === String(runtimeSessionBySdkSessionId.id || '');
|
|
126
|
+
const sameConversation = String(runtimeSessionBySdkSessionId.conversation_id || '') === conversationId;
|
|
127
|
+
const sameSdkSession = normalizeId(runtimeSessionByConversation.sdk_session_id) === sdkSessionId;
|
|
128
|
+
const placeholderRuntimeBinding = normalizeId(runtimeSessionByConversation.sdk_session_id) === conversationId
|
|
129
|
+
&& normalizeId(runtimeSessionByConversation.sdk_session_id) !== sdkSessionId;
|
|
130
|
+
if (!sameRuntimeSession && !sameConversation && !placeholderRuntimeBinding) {
|
|
131
|
+
throw makeError(
|
|
132
|
+
`SDK session ${sdkSessionId} is already bound to another runtime session`,
|
|
133
|
+
409,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
if (!sameSdkSession && normalizeId(runtimeSessionByConversation.sdk_session_id)) {
|
|
137
|
+
throw makeError(
|
|
138
|
+
`Runtime session ${runtimeSessionByConversation.id} is already bound to SDK session ${normalizeId(runtimeSessionByConversation.sdk_session_id)}`,
|
|
139
|
+
409,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
} else if (runtimeSessionBySdkSessionId && normalizeId(runtimeSessionBySdkSessionId.conversation_id) !== conversationId) {
|
|
143
|
+
throw makeError(
|
|
144
|
+
`SDK session ${sdkSessionId} is already bound to runtime session ${runtimeSessionBySdkSessionId.id}`,
|
|
145
|
+
409,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
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
|
+
if (runtimeSessionByConversation) {
|
|
158
|
+
const currentSdkSessionId = normalizeId(runtimeSessionByConversation.sdk_session_id);
|
|
159
|
+
if (currentSdkSessionId && currentSdkSessionId !== sdkSessionId && currentSdkSessionId !== conversationId) {
|
|
160
|
+
throw makeError(
|
|
161
|
+
`Runtime session ${runtimeSessionByConversation.id} is already bound to SDK session ${currentSdkSessionId}`,
|
|
162
|
+
409,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
updateRuntimeSessionSdkSession.run(conversationId, sdkSessionId, nowIso, runtimeSessionByConversation.id);
|
|
167
|
+
runtimeSessionId = runtimeSessionByConversation.id;
|
|
168
|
+
} 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
|
+
updateRuntimeSessionSdkSession.run(conversationId, sdkSessionId, nowIso, runtimeSessionBySdkSessionId.id);
|
|
178
|
+
runtimeSessionId = runtimeSessionBySdkSessionId.id;
|
|
179
|
+
} else {
|
|
180
|
+
runtimeSessionId = uuidv4();
|
|
181
|
+
createdRuntimeSession = true;
|
|
182
|
+
insertRuntimeSession.run(
|
|
183
|
+
runtimeSessionId,
|
|
184
|
+
conversationId,
|
|
185
|
+
'isolated',
|
|
186
|
+
runtimeSessionId,
|
|
187
|
+
null,
|
|
188
|
+
nowIso,
|
|
189
|
+
nowIso,
|
|
190
|
+
sdkSessionId,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
conversationId,
|
|
196
|
+
sdkSessionId,
|
|
197
|
+
runtimeSessionId,
|
|
198
|
+
createdRuntimeSession,
|
|
199
|
+
};
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
syncSession({ sdk_session_id, conversation_id }) {
|
|
204
|
+
return syncSessionTx(sdk_session_id, conversation_id);
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|