@openchambery/web 2.1.8-beta.9 → 2.1.9-beta.1
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/README.md +1 -1
- package/dist/assets/{AgentAvatar-mchFM3n-.js → AgentAvatar-CJQwq8UM.js} +1 -1
- package/dist/assets/AssistantView-j9aI8OWI.js +3 -0
- package/dist/assets/{DiagramView-D_Ib5vBc.js → DiagramView-EsIuVPrU.js} +1 -1
- package/dist/assets/{FadeInOnReveal-BXDM9joC.js → FadeInOnReveal-BNVExwoL.js} +1 -1
- package/dist/assets/{Icon-BGp_ize2.js → Icon-B4xKSuz3.js} +1 -1
- package/dist/assets/{ImageLoadingPlaceholder-DmUxjyxq.js → ImageLoadingPlaceholder-xB8WeKks.js} +1 -1
- package/dist/assets/{LoadingImage-DGjlKW_O.js → LoadingImage-CoT3GWhg.js} +1 -1
- package/dist/assets/{MarkdownRenderer-Cfof1peT.js → MarkdownRenderer-TmfHeWHu.js} +2 -2
- package/dist/assets/{MarkdownRendererImpl-ChI65FCt.js → MarkdownRendererImpl-vkv1QLTd.js} +1 -1
- package/dist/assets/{MarkstreamRendererImpl-DVH-ftYX.js → MarkstreamRendererImpl-BbnksqWU.js} +1 -1
- package/dist/assets/{MobileDetailNavigation-DSO5eFBk.js → MobileDetailNavigation-CtGncmBY.js} +1 -1
- package/dist/assets/{MobileShareBridge-BeEyMgN3.js → MobileShareBridge-pYQFFYmm.js} +1 -1
- package/dist/assets/{MobileTabPageHeader-BnSDaiNY.js → MobileTabPageHeader-D7NAfatx.js} +1 -1
- package/dist/assets/{MultiRunWindow-BUMMBZVZ.js → MultiRunWindow-gZk-f5lA.js} +1 -1
- package/dist/assets/{OnboardingScreen-DmJgU0Jc.js → OnboardingScreen-BiLurnX5.js} +1 -1
- package/dist/assets/{OverlayScrollbar-BkJCkZp3.js → OverlayScrollbar-DIsrS93m.js} +1 -1
- package/dist/assets/SettingsView-D7Ykm-CM.js +26 -0
- package/dist/assets/{SettingsWindow-B23iyDA3.js → SettingsWindow-CyPZw4xx.js} +1 -1
- package/dist/assets/{TerminalView-DtakSdhD.js → TerminalView-D-X0mGO4.js} +1 -1
- package/dist/assets/{ToolOutputDialog-ClmLLL4n.js → ToolOutputDialog-C4vQBg3z.js} +1 -1
- package/dist/assets/{appThemeRegistry-Bo8eOMlc.js → appThemeRegistry-DUH4Fprj.js} +1 -1
- package/dist/assets/{button-BKjGCkH3.js → button-BupmKVUs.js} +1 -1
- package/dist/assets/{collapsible-03FGLsF4.js → collapsible-BAPWS6G-.js} +1 -1
- package/dist/assets/{diagram-D4QHcaSQ.js → diagram-0a7vH45N.js} +1 -1
- package/dist/assets/{dialog-nm_cQEUZ.js → dialog-BYLD4Cm3.js} +1 -1
- package/dist/assets/{dropdown-menu.styles-WkR8IcF2.js → dropdown-menu.styles-HOWSubFF.js} +1 -1
- package/dist/assets/{es-C9PRwAQm.js → es-ChfgOskU.js} +2 -2
- package/dist/assets/{fileMentionTouchSelection-9tUq311z.js → fileMentionTouchSelection-BcqQPEDT.js} +1 -1
- package/dist/assets/{fr-2tfGTcme.js → fr-DnKqN4E3.js} +2 -2
- package/dist/assets/{gitApi-C2zxDfMk.js → gitApi-BtJfaFQy.js} +1 -1
- package/dist/assets/{input-Buww8enN.js → input-DgrLdIys.js} +1 -1
- package/dist/assets/{insertionBoundaries-DB9s3jca.js → insertionBoundaries-z2K9a6oY.js} +1 -1
- package/dist/assets/{ja-D7NHmhJr.js → ja-DH-a_ThB.js} +2 -2
- package/dist/assets/{ko-DeGmXmv6.js → ko-BXABOKd9.js} +2 -2
- package/dist/assets/main-BDCqUxWf.js +169 -0
- package/dist/assets/{main-BjL5V6ie.js → main-DAHeBbJJ.js} +2 -2
- package/dist/assets/{markdownHeightCache-B6wZ-UwD.js → markdownHeightCache-Dyctmwqv.js} +1 -1
- package/dist/assets/{mcpQueries-CWB2bCOV.js → mcpQueries-BSwb38Pz.js} +1 -1
- package/dist/assets/{miniChat-CHCwBkzu.js → miniChat-DEIx3C4R.js} +2 -2
- package/dist/assets/{mobile-CG3SgbAO.js → mobile-DmNNI21w.js} +2 -2
- package/dist/assets/{multirun-47QshrE2.js → multirun-DldZm-Aq.js} +1 -1
- package/dist/assets/{pl-CyWLY1AT.js → pl-D-KNROrB.js} +2 -2
- package/dist/assets/{projectMeta-_nxyshbo.js → projectMeta-zESSNFVw.js} +1 -1
- package/dist/assets/{pt-BR-D9VVxvts.js → pt-BR-UDs5YUzv.js} +2 -2
- package/dist/assets/{renderElectronMiniChatApp-DHs8HXNl.js → renderElectronMiniChatApp-t1IsnPfc.js} +2 -2
- package/dist/assets/renderMobileApp-BrC28EkU.js +28 -0
- package/dist/assets/{runtimeConfig-CRWwlAOU.js → runtimeConfig-5NAbSrPI.js} +1 -1
- package/dist/assets/runtimeEndpointReset-CxreCHBU.js +149 -0
- package/dist/assets/{scheduledTaskNavigation-C-HGgCQA.js → scheduledTaskNavigation-C1PrFEhV.js} +1 -1
- package/dist/assets/{sessionChangeSummary-D_C2s2Fi.js → sessionChangeSummary-CUMpXSiU.js} +1 -1
- package/dist/assets/{sessionLookup-CLMT7sx_.js → sessionLookup-DD8YnthC.js} +1 -1
- package/dist/assets/{sortable-tabs-strip-cHZM0f0Z.js → sortable-tabs-strip-DogWaIrG.js} +1 -1
- package/dist/assets/speechSettingsQueries-PsDvs5QG.js +4 -0
- package/dist/assets/src-CdGit3gf.css +2 -0
- package/dist/assets/{store-Ds-7Em7C.js → store-CG7Ytsg9.js} +3 -3
- package/dist/assets/{textarea-BZHD_GND.js → textarea-Boj9axpc.js} +1 -1
- package/dist/assets/{tooltip-DCeWvAqN.js → tooltip-Dbgnq-Mh.js} +1 -1
- package/dist/assets/{uk-Df44svOl.js → uk-CkliYeRs.js} +2 -2
- package/dist/assets/{useAppFontEffects-AQXif_Vl.js → useAppFontEffects-Ba6H7bLU.js} +51 -51
- package/dist/assets/useDesktopWindowControlsLayout-CbeUwtDM.js +1 -0
- package/dist/assets/{useEffectiveDirectory-CJCTgfPc.js → useEffectiveDirectory-wtPL_zdo.js} +1 -1
- package/dist/assets/{useMobileNavigationStore-Buez5E56.js → useMobileNavigationStore-DXpdkzw_.js} +11 -11
- package/dist/assets/{useWorkerHighlightedLines-DFkH7EUL.js → useWorkerHighlightedLines-BXDSKlPX.js} +1 -1
- package/dist/assets/{zh-CN-wowfzo60.js → zh-CN-BH4APVTu.js} +2 -2
- package/dist/assets/{zh-TW-DwojcgA_.js → zh-TW-BEi6Yf50.js} +2 -2
- package/dist/index.html +3 -3
- package/dist/mini-chat.html +3 -3
- package/dist/mobile.html +3 -3
- package/package.json +1 -1
- package/server/index.js +0 -13
- package/server/lib/assistants/DOCUMENTATION.md +13 -2
- package/server/lib/assistants/contact-attachments.http.test.js +2 -2
- package/server/lib/assistants/contact-store.js +11 -1
- package/server/lib/assistants/contact-store.test.js +27 -8
- package/server/lib/assistants/service.js +6 -67
- package/server/lib/assistants/service.test.js +20 -5
- package/server/lib/opencode/DOCUMENTATION.md +1 -1
- package/server/lib/opencode/core-routes.js +2 -14
- package/server/lib/opencode/core-routes.test.js +6 -10
- package/server/lib/relay/DOCUMENTATION.md +2 -2
- package/server/lib/session-index/DOCUMENTATION.md +3 -0
- package/server/lib/session-index/sync-runtime.js +9 -4
- package/server/lib/session-index/sync-runtime.test.js +27 -0
- package/dist/assets/AssistantView-BKUwYL67.js +0 -3
- package/dist/assets/SettingsView-O01tqMQO.js +0 -26
- package/dist/assets/main-0PxRleO0.js +0 -169
- package/dist/assets/renderMobileApp-DYne1Jhg.js +0 -28
- package/dist/assets/runtimeEndpointReset-B082-H3n.js +0 -149
- package/dist/assets/speechSettingsQueries-BPjR3N-x.js +0 -4
- package/dist/assets/src-OjoeiMbd.css +0 -2
- package/dist/assets/useDesktopWindowControlsLayout-CDYvCUVb.js +0 -1
package/dist/index.html
CHANGED
|
@@ -552,7 +552,7 @@
|
|
|
552
552
|
opacity: 0.72;
|
|
553
553
|
}
|
|
554
554
|
</style>
|
|
555
|
-
<script type="module" crossorigin src="/assets/main-
|
|
555
|
+
<script type="module" crossorigin src="/assets/main-DAHeBbJJ.js"></script>
|
|
556
556
|
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-hePW80VL.js">
|
|
557
557
|
<link rel="modulepreload" crossorigin href="/assets/runtime-auth-C8w0nlKy.js">
|
|
558
558
|
<link rel="modulepreload" crossorigin href="/assets/runtime-switch-3ad3yzM7.js">
|
|
@@ -572,8 +572,8 @@
|
|
|
572
572
|
<link rel="modulepreload" crossorigin href="/assets/terminalApi-kRMttlcP.js">
|
|
573
573
|
<link rel="modulepreload" crossorigin href="/assets/types-BuiqMmh_.js">
|
|
574
574
|
<link rel="modulepreload" crossorigin href="/assets/capgoAdapter-KNLHDj7G.js">
|
|
575
|
-
<link rel="modulepreload" crossorigin href="/assets/runtimeConfig-
|
|
576
|
-
<link rel="stylesheet" crossorigin href="/assets/src-
|
|
575
|
+
<link rel="modulepreload" crossorigin href="/assets/runtimeConfig-5NAbSrPI.js">
|
|
576
|
+
<link rel="stylesheet" crossorigin href="/assets/src-CdGit3gf.css">
|
|
577
577
|
</head>
|
|
578
578
|
<body class="h-full bg-background text-foreground">
|
|
579
579
|
<!-- Keep the startup layer outside #root. React replaces every child of
|
package/dist/mini-chat.html
CHANGED
|
@@ -74,7 +74,7 @@
|
|
|
74
74
|
}
|
|
75
75
|
</style>
|
|
76
76
|
|
|
77
|
-
<script type="module" crossorigin src="/assets/miniChat-
|
|
77
|
+
<script type="module" crossorigin src="/assets/miniChat-DEIx3C4R.js"></script>
|
|
78
78
|
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-hePW80VL.js">
|
|
79
79
|
<link rel="modulepreload" crossorigin href="/assets/runtime-auth-C8w0nlKy.js">
|
|
80
80
|
<link rel="modulepreload" crossorigin href="/assets/runtime-switch-3ad3yzM7.js">
|
|
@@ -94,8 +94,8 @@
|
|
|
94
94
|
<link rel="modulepreload" crossorigin href="/assets/terminalApi-kRMttlcP.js">
|
|
95
95
|
<link rel="modulepreload" crossorigin href="/assets/types-BuiqMmh_.js">
|
|
96
96
|
<link rel="modulepreload" crossorigin href="/assets/capgoAdapter-KNLHDj7G.js">
|
|
97
|
-
<link rel="modulepreload" crossorigin href="/assets/runtimeConfig-
|
|
98
|
-
<link rel="stylesheet" crossorigin href="/assets/src-
|
|
97
|
+
<link rel="modulepreload" crossorigin href="/assets/runtimeConfig-5NAbSrPI.js">
|
|
98
|
+
<link rel="stylesheet" crossorigin href="/assets/src-CdGit3gf.css">
|
|
99
99
|
</head>
|
|
100
100
|
<body class="h-full bg-background text-foreground">
|
|
101
101
|
<div id="root" class="h-full">
|
package/dist/mobile.html
CHANGED
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
})();
|
|
64
64
|
</script>
|
|
65
65
|
|
|
66
|
-
<script type="module" crossorigin src="/assets/mobile-
|
|
66
|
+
<script type="module" crossorigin src="/assets/mobile-DmNNI21w.js"></script>
|
|
67
67
|
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-hePW80VL.js">
|
|
68
68
|
<link rel="modulepreload" crossorigin href="/assets/runtime-auth-C8w0nlKy.js">
|
|
69
69
|
<link rel="modulepreload" crossorigin href="/assets/runtime-switch-3ad3yzM7.js">
|
|
@@ -83,8 +83,8 @@
|
|
|
83
83
|
<link rel="modulepreload" crossorigin href="/assets/terminalApi-kRMttlcP.js">
|
|
84
84
|
<link rel="modulepreload" crossorigin href="/assets/types-BuiqMmh_.js">
|
|
85
85
|
<link rel="modulepreload" crossorigin href="/assets/capgoAdapter-KNLHDj7G.js">
|
|
86
|
-
<link rel="modulepreload" crossorigin href="/assets/runtimeConfig-
|
|
87
|
-
<link rel="stylesheet" crossorigin href="/assets/src-
|
|
86
|
+
<link rel="modulepreload" crossorigin href="/assets/runtimeConfig-5NAbSrPI.js">
|
|
87
|
+
<link rel="stylesheet" crossorigin href="/assets/src-CdGit3gf.css">
|
|
88
88
|
</head>
|
|
89
89
|
<body class="h-full bg-background text-foreground">
|
|
90
90
|
<div id="root" class="h-full"></div>
|
package/package.json
CHANGED
package/server/index.js
CHANGED
|
@@ -1822,19 +1822,6 @@ async function main(options = {}) {
|
|
|
1822
1822
|
// Re-evaluate the relay lifecycle after pairing/device changes (a revoked or
|
|
1823
1823
|
// redeemed device can flip relay demand on or off).
|
|
1824
1824
|
reconcileRelay: () => (relayServiceInstance ? relayServiceInstance.reconcile() : Promise.resolve()),
|
|
1825
|
-
// Canonical endpoints the host relay currently uses (primary first).
|
|
1826
|
-
// Pairing-session requests from paired desktop clients may carry a relayUrl
|
|
1827
|
-
// only when it matches one of these (an echo of an advertised endpoint,
|
|
1828
|
-
// never a re-point or an extension).
|
|
1829
|
-
getEffectiveRelayUrls: async () => {
|
|
1830
|
-
if (!relayServiceInstance) return [];
|
|
1831
|
-
try {
|
|
1832
|
-
const status = await relayServiceInstance.getStatus();
|
|
1833
|
-
return Array.isArray(status?.relayUrls) ? status.relayUrls : [];
|
|
1834
|
-
} catch {
|
|
1835
|
-
return [];
|
|
1836
|
-
}
|
|
1837
|
-
},
|
|
1838
1825
|
// Every configured relay endpoint as a pairing candidate (multi-relay),
|
|
1839
1826
|
// for the connection-candidates refresh.
|
|
1840
1827
|
getRelayPairingCandidates: async () => {
|
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Assistants Module
|
|
2
2
|
|
|
3
|
+
## Transient contact errors
|
|
4
|
+
|
|
5
|
+
Contact-turn failures are delivered through `openchamber:contact-turn-end` only,
|
|
6
|
+
never inserted into the durable contact transcript. The store rejects `status:error`
|
|
7
|
+
writes and atomically deletes legacy error messages and their parts during startup;
|
|
8
|
+
a cleanup failure rolls back and fails startup instead of exposing partial history.
|
|
9
|
+
User messages and already-published valid replies/cards remain intact. Live clients
|
|
10
|
+
show temporary error feedback until a newer message arrives; reconnect/restart does
|
|
11
|
+
not recover errors. Errors do not become list previews, unread counts, or LLM memory.
|
|
12
|
+
Worker-card execution status remains part of the card's authoritative state.
|
|
13
|
+
|
|
3
14
|
## Avatar identity
|
|
4
15
|
|
|
5
16
|
`avatarSeed` is an optional nonempty string (maximum 256 characters) on create
|
|
@@ -141,7 +152,7 @@ and keep their existing error result handling.
|
|
|
141
152
|
- The model is prompted to announce its next action and report verified milestones during execution, then give a short final outcome. Public messages share the existing SSE, durable contact rows, and turn-global indices across web, desktop, VS Code, hosted mobile, and Capacitor. No timer narration, tool logs, progress widgets, or synthetic chat messages are added. Timing of model-authored milestones remains model policy; the transport guarantees immediate delivery once an explicit public record arrives.
|
|
142
153
|
- Completion notifications use the latest spoken message, keeping earlier progress out of the final notification body. Transcript persistence reconciles by stable bubble IDs and adds only remaining messages.
|
|
143
154
|
- Tool results remain model context for every tool. The outcome projector uses assistant-authored text only. Terminal operations retain the mutation stop gate, followed by a tools-disabled completion on the same provider/model to communicate the actual result. A failed/empty summary surfaces a turn error; a completed side effect stays committed and is never replayed by that summary pass.
|
|
144
|
-
- Reset summaries receive only the successful reset tool result, excluding prior messages and attachments. The service retains one model-generated confirmation and discards pre-reset output for that turn.
|
|
155
|
+
- Reset summaries receive only the successful reset tool result, excluding prior messages and attachments. The service retains one model-generated confirmation and discards pre-reset output for that turn. Failures emit temporary live error feedback outside assistant speech bubbles, without a persisted failure row.
|
|
145
156
|
|
|
146
157
|
- Protocol correction never replays rejected assistant prose into model history: simulated User/Assistant turns and tool-result records are rejected as fabricated evidence. Known rejected coding calls cannot be skipped with a subsequent `complete` declaration; the model must issue actual calls or report a blocker. Corrections remain bounded and exhaustion is an error, not a completion.
|
|
147
158
|
|
|
@@ -178,7 +189,7 @@ and keep their existing error result handling.
|
|
|
178
189
|
- Worker sessions stamp `metadata.openchamber.assigned` (`from: 'contact'`, `assistantID`, `name`). They must **not** use `openchamber.assistant.assistantID` — that marker hides archived Assistant bindings from Chat.
|
|
179
190
|
- `AssistantDTO.assignedSessionIDs` lists in-flight (`busy`/`question`) watches for assigned worker sessions. Session-card busy stays on the card; it does **not** drive the list green dot.
|
|
180
191
|
- **Contact active turn (server authoritative):** process-local metadata tracks admission → `queued` → `running` → settled (removed). `AssistantDTO.working` is true only while that assistant has an unsettled contact turn. `AssistantDTO.activeContactTurn` is `{ turnID, messageID, status: 'queued'|'running', admittedAt }` or `null` so APP restart / reconnect can rehydrate list green dots and the contact 3-dot row from the snapshot HTTP GET. Spoken bubbles and cards persist to SQLite as soon as they publish (`done:true` / `onCard`) so another device or APP restart can GET them while the turn is still running. Token-level SSE deltas remain live paint only. A server process restart clears in-memory activity (no permanent green; tools are not auto-rerun) but already-published bubbles/cards stay in the transcript. Per-assistant lanes isolate turns across assistants/runtimes. Stale `contact-turn-end` for an older `turnID` must not clear a newer active turn.
|
|
181
|
-
- **List latest-message preview (server authoritative):** every `AssistantDTO` path (`snapshot` / `create` / `update` / other `output` rows) carries `latestMessagePreview: { messageID, ordinal, createdAt, role: 'user'|'assistant', text, fallbackKind: 'image'|'file'|'session'|'assistant'|'schedule'|null } | null`. Source is the contact transcript only (not `defaultPrompt` / `updatedAt`). Order is the same keyset as contact pages: newest `(ordinal DESC, message_id DESC)`. Visible roles are `user`, `assistant`, and `peer` (peer maps to `role: 'user'` because inbound DMs are user-visible). Internal assigned-session settle markers (`oc.settle.*`) are excluded in the SQL candidate probe (and again in `buildContactMessagePreview`).
|
|
192
|
+
- **List latest-message preview (server authoritative):** every `AssistantDTO` path (`snapshot` / `create` / `update` / other `output` rows) carries `latestMessagePreview: { messageID, ordinal, createdAt, role: 'user'|'assistant', text, fallbackKind: 'image'|'file'|'session'|'assistant'|'schedule'|null } | null`. Source is the contact transcript only (not `defaultPrompt` / `updatedAt`). Order is the same keyset as contact pages: newest `(ordinal DESC, message_id DESC)`. Visible roles are `user`, `assistant`, and `peer` (peer maps to `role: 'user'` because inbound DMs are user-visible). Internal assigned-session settle markers (`oc.settle.*`) are excluded in the SQL candidate probe (and again in `buildContactMessagePreview`). Transient errors are never stored and cannot replace the preview. Plain text is sanitized and capped at `CONTACT_PREVIEW_MAX_CHARS` (500) with no data-URL/base64 bodies. File-only rows set `fallbackKind` `image`/`file`; pure cards set `session`/`assistant`/`schedule`. Empty transcript → `null`. **Snapshot cost:** one prepared statement reused per assistant (`assistant_id` + indexed `ORDER BY ordinal DESC, message_id DESC LIMIT candidateLimit`, catalog ≤100) collecting only those bounded candidates, then **one** parts `IN` query — not a window/`ROW_NUMBER` over full multi-assistant history and not HTTP N+1. SQL already drops pure settle tails so they cannot exhaust the LIMIT and hide an older real bubble; remaining non-preview empty rows may still consume a candidate slot within that same LIMIT. Admission, assistant completion, peer DM, card insert, wipe, and clear-memory already bump domain revision / `openchamber:assistants-changed`; clients re-GET snapshot for the new preview. Clear-memory keeps transcript rows so the preview still reflects the last bubble; clear-history / reset remove rows so the preview becomes the wipe confirm (or `null` after a full unbounded wipe with no remaining user).
|
|
182
193
|
- No registered project → clear `project_required` (ask the user to configure a project). Never fall back to managed assistant-workspaces.
|
|
183
194
|
- Assign must not go through the peer DM channel.
|
|
184
195
|
- `assignWork` (service) resolves the worker model via the connected catalog (`capabilities.input.image` preferred), forwards tool `variant` (cross-model drops contact variant), passes AbortSignal into create/prompt/delete/catalog, and supplies `lookupMessage` over real SDK exact lookup (`v2.session.message` → `session.message` → bounded `message.list`). Ambiguous prompt admission returns `prompt_ambiguous` with `sessionID`/`messageID` and does **not** delete the worker; the contact-turn assign gate retains that identity so a same-args retry reuses the session and cannot blind-create another worker this turn.
|
|
@@ -256,7 +256,7 @@ describe('contact attachments real HTTP (core-routes + assistants routes)', () =
|
|
|
256
256
|
});
|
|
257
257
|
|
|
258
258
|
describe('contact attachment materialize failure terminal settle', () => {
|
|
259
|
-
it('deleting bytes after admit causes
|
|
259
|
+
it('deleting bytes after admit causes transient error, working=false, next turn ok', async () => {
|
|
260
260
|
const directory = mkRoot();
|
|
261
261
|
closers.push(async () => fsp.rm(directory, { recursive: true, force: true }).catch(() => {}));
|
|
262
262
|
|
|
@@ -339,7 +339,7 @@ describe('contact attachment materialize failure terminal settle', () => {
|
|
|
339
339
|
expect(snap.activeContactTurn).toBeNull();
|
|
340
340
|
|
|
341
341
|
const page = service.contactMessages(assistant.id, { limit: 20 });
|
|
342
|
-
expect(page.messages.some((m) => m.status === 'error' || (m.role === 'assistant' && m.text))).toBe(
|
|
342
|
+
expect(page.messages.some((m) => m.status === 'error' || (m.role === 'assistant' && m.text))).toBe(false);
|
|
343
343
|
const pageJson = JSON.stringify(page);
|
|
344
344
|
expect(pageJson).not.toMatch(/data:image\/png;base64,[A-Za-z0-9+/]{40,}/);
|
|
345
345
|
|
|
@@ -107,6 +107,15 @@ export function ensureContactSchema(db) {
|
|
|
107
107
|
// only counts assistant tails that belong to this delivery.
|
|
108
108
|
if (!watchColumns.has('message_id')) db.exec('ALTER TABLE assistant_contact_watch ADD COLUMN message_id TEXT');
|
|
109
109
|
// Attachment table ownership lives in contact-attachments.ensureContactAttachmentSchema.
|
|
110
|
+
// Errors are live feedback, not history. Remove legacy rows and their bodies atomically.
|
|
111
|
+
db.transaction(() => {
|
|
112
|
+
db.exec(`
|
|
113
|
+
DELETE FROM assistant_contact_part WHERE message_id IN (
|
|
114
|
+
SELECT message_id FROM assistant_contact_message WHERE status = 'error'
|
|
115
|
+
);
|
|
116
|
+
DELETE FROM assistant_contact_message WHERE status = 'error';
|
|
117
|
+
`);
|
|
118
|
+
})();
|
|
110
119
|
}
|
|
111
120
|
|
|
112
121
|
/** Current contact transcript generation for one assistant (default 0). */
|
|
@@ -719,7 +728,7 @@ export function sanitizeContactPreviewText(text, maxChars = CONTACT_PREVIEW_MAX_
|
|
|
719
728
|
* Build AssistantDTO.latestMessagePreview from one hydrated contact message.
|
|
720
729
|
* Returns null when the row is not list-visible (internal settle-only, empty,
|
|
721
730
|
* unsupported role). Peer inbox rows map to role `user` (user-visible DM).
|
|
722
|
-
*
|
|
731
|
+
* Cards/files set fallbackKind. Error feedback never enters persisted previews.
|
|
723
732
|
*/
|
|
724
733
|
export function buildContactMessagePreview(message) {
|
|
725
734
|
if (!message || typeof message !== 'object') return null;
|
|
@@ -1003,6 +1012,7 @@ export function insertContactMessage(db, {
|
|
|
1003
1012
|
fromAssistantID = null,
|
|
1004
1013
|
fromAssistantName = null,
|
|
1005
1014
|
}) {
|
|
1015
|
+
if (status === 'error') return;
|
|
1006
1016
|
db.prepare(
|
|
1007
1017
|
'INSERT INTO assistant_contact_message(message_id,assistant_id,role,turn_id,bubble_index,created_at,ordinal,status,from_assistant_id,from_assistant_name) VALUES (?,?,?,?,?,?,?,?,?,?)',
|
|
1008
1018
|
).run(messageID, assistantID, role, turnID, bubbleIndex, createdAt, ordinal, status, fromAssistantID, fromAssistantName);
|
|
@@ -120,7 +120,7 @@ describe('contact LLM history trim', () => {
|
|
|
120
120
|
{ role: 'assistant', content: 'keep-assistant' },
|
|
121
121
|
{ role: 'assistant', content: expect.stringContaining('\"sessionID\":\"ses_1\"') },
|
|
122
122
|
]);
|
|
123
|
-
expect(listContactMessages(db, assistantID, { limit: 50 }).messages).toHaveLength(
|
|
123
|
+
expect(listContactMessages(db, assistantID, { limit: 50 }).messages).toHaveLength(4);
|
|
124
124
|
db.close();
|
|
125
125
|
});
|
|
126
126
|
|
|
@@ -549,21 +549,40 @@ describe('contact latest message preview', () => {
|
|
|
549
549
|
db.close();
|
|
550
550
|
});
|
|
551
551
|
|
|
552
|
-
it('
|
|
552
|
+
it('never stores error rows or replaces the latest message preview with an error', () => {
|
|
553
553
|
const db = openDb();
|
|
554
554
|
const assistantID = 'asst_preview_err';
|
|
555
|
-
insert(db, assistantID, { role: 'user', text: 'hi' });
|
|
555
|
+
const user = insert(db, assistantID, { role: 'user', text: 'hi' });
|
|
556
556
|
const err = insert(db, assistantID, {
|
|
557
557
|
role: 'assistant',
|
|
558
558
|
text: 'No connected model',
|
|
559
559
|
status: 'error',
|
|
560
560
|
});
|
|
561
561
|
expect(getLatestContactMessagePreview(db, assistantID)).toMatchObject({
|
|
562
|
-
messageID:
|
|
563
|
-
role: '
|
|
564
|
-
text: '
|
|
562
|
+
messageID: user.messageID,
|
|
563
|
+
role: 'user',
|
|
564
|
+
text: 'hi',
|
|
565
565
|
fallbackKind: null,
|
|
566
566
|
});
|
|
567
|
+
expect(db.prepare('SELECT * FROM assistant_contact_message WHERE message_id=?').get(err.messageID)).toBeUndefined();
|
|
568
|
+
expect(db.prepare('SELECT * FROM assistant_contact_part WHERE message_id=?').all(err.messageID)).toEqual([]);
|
|
569
|
+
db.close();
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
it('atomically purges legacy error messages and parts on startup', () => {
|
|
573
|
+
const db = openDb();
|
|
574
|
+
const user = insert(db, 'legacy', { role: 'user', text: 'keep' });
|
|
575
|
+
const legacy = insert(db, 'legacy', { role: 'assistant', text: 'old failure' });
|
|
576
|
+
db.prepare("UPDATE assistant_contact_message SET status='error' WHERE message_id=?").run(legacy.messageID);
|
|
577
|
+
db.exec(`CREATE TRIGGER fail_error_cleanup BEFORE DELETE ON assistant_contact_message
|
|
578
|
+
WHEN OLD.status = 'error' BEGIN SELECT RAISE(ABORT, 'cleanup failed'); END;`);
|
|
579
|
+
expect(() => ensureContactSchema(db)).toThrow('cleanup failed');
|
|
580
|
+
expect(db.prepare('SELECT * FROM assistant_contact_part WHERE message_id=?').all(legacy.messageID)).toHaveLength(1);
|
|
581
|
+
db.exec('DROP TRIGGER fail_error_cleanup');
|
|
582
|
+
ensureContactSchema(db);
|
|
583
|
+
ensureContactSchema(db);
|
|
584
|
+
expect(listContactMessages(db, 'legacy').messages.map((message) => message.messageID)).toEqual([user.messageID]);
|
|
585
|
+
expect(db.prepare('SELECT * FROM assistant_contact_part WHERE message_id=?').all(legacy.messageID)).toEqual([]);
|
|
567
586
|
db.close();
|
|
568
587
|
});
|
|
569
588
|
|
|
@@ -718,7 +737,7 @@ describe('contact latest message preview', () => {
|
|
|
718
737
|
});
|
|
719
738
|
|
|
720
739
|
describe('contact read watermark / unread', () => {
|
|
721
|
-
it('counts
|
|
740
|
+
it('counts complete assistant and peer replies; ignores errors, user and settle-only', () => {
|
|
722
741
|
expect(isContactVisiblePart({ type: 'text', text: 'hi' })).toBe(true);
|
|
723
742
|
expect(isContactVisiblePart({ type: 'text', text: ' ' })).toBe(false);
|
|
724
743
|
expect(isContactVisiblePart({ type: 'text', text: CONTACT_SETTLE_TEXT.complete })).toBe(false);
|
|
@@ -793,7 +812,7 @@ describe('contact read watermark / unread', () => {
|
|
|
793
812
|
insert(db, assistantID, { role: 'peer', text: 'p1', fromAssistantID: 'other', fromAssistantName: 'Peer' });
|
|
794
813
|
insert(db, assistantID, { role: 'assistant', text: 'failed', status: 'error' });
|
|
795
814
|
// No watermark → all countable unread.
|
|
796
|
-
expect(countContactUnread(db, assistantID)).toBe(
|
|
815
|
+
expect(countContactUnread(db, assistantID)).toBe(2);
|
|
797
816
|
db.close();
|
|
798
817
|
});
|
|
799
818
|
|
|
@@ -1423,31 +1423,6 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1423
1423
|
throw error;
|
|
1424
1424
|
}
|
|
1425
1425
|
};
|
|
1426
|
-
/** Durable failure bubble so APP restart can recover error without SSE. */
|
|
1427
|
-
const persistContactTurnFailure = (assistantID, { userMessageID, turnID, error }) => {
|
|
1428
|
-
const detail = typeof error === 'string' && error.trim() ? error.trim() : 'upstream_error';
|
|
1429
|
-
const failureID = `${userMessageID}:error`;
|
|
1430
|
-
if (getContactMessage(db, failureID)) return;
|
|
1431
|
-
db.exec('BEGIN IMMEDIATE');
|
|
1432
|
-
try {
|
|
1433
|
-
insertContactMessage(db, {
|
|
1434
|
-
messageID: failureID,
|
|
1435
|
-
assistantID,
|
|
1436
|
-
role: 'assistant',
|
|
1437
|
-
turnID,
|
|
1438
|
-
bubbleIndex: 0,
|
|
1439
|
-
createdAt: now(),
|
|
1440
|
-
ordinal: nextContactOrdinal(db, assistantID),
|
|
1441
|
-
status: 'error',
|
|
1442
|
-
parts: [{ type: 'text', text: detail }],
|
|
1443
|
-
});
|
|
1444
|
-
bump();
|
|
1445
|
-
db.exec('COMMIT');
|
|
1446
|
-
} catch (writeError) {
|
|
1447
|
-
db.exec('ROLLBACK');
|
|
1448
|
-
throw writeError;
|
|
1449
|
-
}
|
|
1450
|
-
};
|
|
1451
1426
|
const contactBubbleMessageID = (userMessageID, bubbleIndex) => `${userMessageID}:bubble:${bubbleIndex + 1}`;
|
|
1452
1427
|
const contactCardMessageID = (userMessageID, index) => `${userMessageID}:card:${index + 1}`;
|
|
1453
1428
|
|
|
@@ -2695,7 +2670,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
2695
2670
|
const messageID = scheduledRun ? `scheduled_${assistantID}_${scheduledRun.id}` : assignedSessionResumeMessageID(assistantID, sessionID, status, updatedAt);
|
|
2696
2671
|
const capturedAssistant = scheduledRun ? output(row) : null;
|
|
2697
2672
|
const turnID = messageID;
|
|
2698
|
-
// Idempotent: same watch status already resumed
|
|
2673
|
+
// Idempotent: same watch status already resumed with published output.
|
|
2699
2674
|
if (
|
|
2700
2675
|
getContactMessage(db, `${messageID}:bubble:1`)
|
|
2701
2676
|
|| getContactMessage(db, `${messageID}:error`)
|
|
@@ -2966,16 +2941,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
2966
2941
|
].filter((card, index, list) => list.findIndex((item) => contactCardIdentity(item) === contactCardIdentity(card)) === index);
|
|
2967
2942
|
if (bubbles.length === 0 && cards.length === 0) {
|
|
2968
2943
|
const detail = 'Assistant returned no text';
|
|
2969
|
-
|
|
2970
|
-
persistContactTurnFailure(assistantID, {
|
|
2971
|
-
userMessageID: messageID,
|
|
2972
|
-
turnID,
|
|
2973
|
-
error: detail,
|
|
2974
|
-
});
|
|
2975
|
-
settleActiveContactTurn(assistantID, turnID);
|
|
2976
|
-
} catch {
|
|
2977
|
-
settleActiveContactTurn(assistantID, turnID, { bumpRevision: true });
|
|
2978
|
-
}
|
|
2944
|
+
settleActiveContactTurn(assistantID, turnID, { bumpRevision: true });
|
|
2979
2945
|
emitContactTurnEvent('openchamber:contact-turn-end', {
|
|
2980
2946
|
assistantID,
|
|
2981
2947
|
turnID,
|
|
@@ -3021,16 +2987,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
3021
2987
|
? error.message.trim()
|
|
3022
2988
|
: (error?.code || 'upstream_error');
|
|
3023
2989
|
const statusCode = error?.code === 'no_provider' ? 'no_provider' : (error?.code || 'upstream_error');
|
|
3024
|
-
|
|
3025
|
-
persistContactTurnFailure(assistantID, {
|
|
3026
|
-
userMessageID: messageID,
|
|
3027
|
-
turnID,
|
|
3028
|
-
error: detail,
|
|
3029
|
-
});
|
|
3030
|
-
settleActiveContactTurn(assistantID, turnID);
|
|
3031
|
-
} catch {
|
|
3032
|
-
settleActiveContactTurn(assistantID, turnID, { bumpRevision: true });
|
|
3033
|
-
}
|
|
2990
|
+
settleActiveContactTurn(assistantID, turnID, { bumpRevision: true });
|
|
3034
2991
|
emitContactTurnEvent('openchamber:contact-turn-end', {
|
|
3035
2992
|
assistantID,
|
|
3036
2993
|
turnID,
|
|
@@ -3182,7 +3139,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
3182
3139
|
};
|
|
3183
3140
|
const modelContext = await loadContactModelContext(controller.signal);
|
|
3184
3141
|
try {
|
|
3185
|
-
// Materialize inside turn try so failures
|
|
3142
|
+
// Materialize inside turn try so failures emit live feedback and settle working.
|
|
3186
3143
|
// DB/UI keep descriptors; harness + assign get execution-time data URLs under budget.
|
|
3187
3144
|
const executionHistory = await materializeContactHistory(
|
|
3188
3145
|
db,
|
|
@@ -3340,16 +3297,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
3340
3297
|
].filter((card, index, list) => list.findIndex((item) => contactCardIdentity(item) === contactCardIdentity(card)) === index);
|
|
3341
3298
|
if (bubbles.length === 0 && cards.length === 0) {
|
|
3342
3299
|
const detail = 'Assistant returned no text';
|
|
3343
|
-
|
|
3344
|
-
persistContactTurnFailure(row.assistant_id, {
|
|
3345
|
-
userMessageID: messageID,
|
|
3346
|
-
turnID,
|
|
3347
|
-
error: detail,
|
|
3348
|
-
});
|
|
3349
|
-
settleActiveContactTurn(row.assistant_id, turnID);
|
|
3350
|
-
} catch {
|
|
3351
|
-
settleActiveContactTurn(row.assistant_id, turnID, { bumpRevision: true });
|
|
3352
|
-
}
|
|
3300
|
+
settleActiveContactTurn(row.assistant_id, turnID, { bumpRevision: true });
|
|
3353
3301
|
emitContactTurnEvent('openchamber:contact-turn-end', {
|
|
3354
3302
|
assistantID: row.assistant_id,
|
|
3355
3303
|
turnID,
|
|
@@ -3409,16 +3357,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
3409
3357
|
? error.message.trim()
|
|
3410
3358
|
: (error?.code || 'upstream_error');
|
|
3411
3359
|
const statusCode = error?.code === 'no_provider' ? 'no_provider' : (error?.code || 'upstream_error');
|
|
3412
|
-
|
|
3413
|
-
persistContactTurnFailure(row.assistant_id, {
|
|
3414
|
-
userMessageID: messageID,
|
|
3415
|
-
turnID,
|
|
3416
|
-
error: detail,
|
|
3417
|
-
});
|
|
3418
|
-
settleActiveContactTurn(row.assistant_id, turnID);
|
|
3419
|
-
} catch {
|
|
3420
|
-
settleActiveContactTurn(row.assistant_id, turnID, { bumpRevision: true });
|
|
3421
|
-
}
|
|
3360
|
+
settleActiveContactTurn(row.assistant_id, turnID, { bumpRevision: true });
|
|
3422
3361
|
emitContactTurnEvent('openchamber:contact-turn-end', {
|
|
3423
3362
|
assistantID: row.assistant_id,
|
|
3424
3363
|
turnID,
|
|
@@ -4230,8 +4230,11 @@ describe('assistants service', () => {
|
|
|
4230
4230
|
service.close();
|
|
4231
4231
|
});
|
|
4232
4232
|
|
|
4233
|
-
it('
|
|
4234
|
-
const
|
|
4233
|
+
it('delivers contact errors without persisting a transcript message', async () => {
|
|
4234
|
+
const directory = root();
|
|
4235
|
+
const events = [];
|
|
4236
|
+
const service = setup(directory, {}, {
|
|
4237
|
+
onContactTurnEvent: (event) => events.push(event),
|
|
4235
4238
|
runContactTurn: async () => {
|
|
4236
4239
|
const error = new Error('No connected model');
|
|
4237
4240
|
error.code = 'no_provider';
|
|
@@ -4247,10 +4250,23 @@ describe('assistants service', () => {
|
|
|
4247
4250
|
const page = service.contactMessages(assistant.id);
|
|
4248
4251
|
expect(page.messages.map((message) => ({ role: message.role, status: message.status, text: message.text }))).toEqual([
|
|
4249
4252
|
{ role: 'user', status: 'complete', text: 'hi' },
|
|
4250
|
-
{ role: 'assistant', status: 'error', text: 'No connected model' },
|
|
4251
4253
|
]);
|
|
4252
4254
|
expect(service.snapshot().assistants[0]).toMatchObject({ working: false, activeContactTurn: null });
|
|
4255
|
+
expect(events).toContainEqual(expect.objectContaining({
|
|
4256
|
+
type: 'openchamber:contact-turn-end',
|
|
4257
|
+
properties: expect.objectContaining({ status: 'error', error: 'No connected model' }),
|
|
4258
|
+
}));
|
|
4253
4259
|
service.close();
|
|
4260
|
+
const restarted = setup(directory, {}, {
|
|
4261
|
+
runContactTurn: async () => ({ text: 'ok', bubbles: ['ok'] }),
|
|
4262
|
+
});
|
|
4263
|
+
expect(restarted.contactMessages(assistant.id).messages.map((message) => message.text)).toEqual(['hi']);
|
|
4264
|
+
await restarted.send(assistant.id, {
|
|
4265
|
+
messageID: 'after_failure', parts: [{ type: 'text', text: 'again' }],
|
|
4266
|
+
});
|
|
4267
|
+
await restarted.whenContactTurnSettled('after_failure');
|
|
4268
|
+
expect(restarted.contactMessages(assistant.id).messages.map((message) => message.text)).toEqual(['hi', 'again', 'ok']);
|
|
4269
|
+
restarted.close();
|
|
4254
4270
|
});
|
|
4255
4271
|
|
|
4256
4272
|
it('bumps revision when queued contact turn becomes running', async () => {
|
|
@@ -4395,10 +4411,9 @@ describe('assistants service', () => {
|
|
|
4395
4411
|
&& event.properties?.status === 'error'
|
|
4396
4412
|
&& event.properties?.turnID === 'client_no_provider'
|
|
4397
4413
|
))).toBe(true);
|
|
4398
|
-
// User row stays;
|
|
4414
|
+
// User row stays; error feedback is delivered only through the live event.
|
|
4399
4415
|
expect(service.contactMessages(assistant.id).messages.map((message) => ({ role: message.role, status: message.status }))).toEqual([
|
|
4400
4416
|
{ role: 'user', status: 'complete' },
|
|
4401
|
-
{ role: 'assistant', status: 'error' },
|
|
4402
4417
|
]);
|
|
4403
4418
|
service.close();
|
|
4404
4419
|
});
|
|
@@ -414,7 +414,7 @@ When adding or changing Host HTTP APIs that mobile/desktop clients reach over Pr
|
|
|
414
414
|
- `GET /api/passkeys`
|
|
415
415
|
- `DELETE /api/passkeys/:id`
|
|
416
416
|
- `POST /api/auth/reset`
|
|
417
|
-
|
|
417
|
+
- `POST /api/client-auth/pairing/sessions`: accepts an optional `relayUrl` when Relay is included. An owner UI session, local `desktop-local` shell, or paired `desktop` operator may select a custom endpoint, including through Relay. Other client kinds cannot create pairing sessions. Canonical form is `ws://`/`wss://` scheme/host/path: userinfo is rejected (HTTP 400); query and fragment are stripped and are not part of endpoint identity. The Host adds a new endpoint without replacing existing endpoints before returning the pairing-v2 Relay candidate; an `OPENCHAMBER_RELAY_URL` override stays authoritative. The remote-host dialog permits custom endpoint editing unless that override locks it; remote selections are not saved as local-host preferences.
|
|
418
418
|
- `GET /api/client-auth/pairing/transports`: returns direct transport availability. `relayAvailable` is true for desktop and SSH-managed remote hosts (`OPENCHAMBER_RUNTIME=desktop|ssh-remote`); local `dev` / `web` / ordinary CLI servers report false and omit Relay URL fields so the create-device dialog never defaults to Anywhere or probes the relay.
|
|
419
419
|
- `GET /connect`
|
|
420
420
|
- `POST /api/system/probe-url`
|
|
@@ -395,7 +395,6 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
|
|
|
395
395
|
// [] when relay status is unavailable. Gates remote-bearer pairing requests
|
|
396
396
|
// that carry a relayUrl: paired desktops may only use an endpoint the host
|
|
397
397
|
// already has (echo of any configured endpoint, never a re-point).
|
|
398
|
-
getEffectiveRelayUrls = async () => [],
|
|
399
398
|
// Candidates for EVERY configured relay endpoint (multi-relay), or null /
|
|
400
399
|
// a non-array when only the single-endpoint accessor is available. Feeds
|
|
401
400
|
// the connection-candidates refresh so paired devices learn the full
|
|
@@ -897,19 +896,8 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
|
|
|
897
896
|
if (req.body?.relayUrl !== undefined && !requestedRelayUrl) {
|
|
898
897
|
return res.status(400).json({ error: 'Relay URL must use ws:// or wss://' });
|
|
899
898
|
}
|
|
900
|
-
//
|
|
901
|
-
//
|
|
902
|
-
// but may only name an endpoint the host already uses — anything else
|
|
903
|
-
// would re-point (or extend) the Host relay from a remote bearer.
|
|
904
|
-
if (requestedRelayUrl !== undefined && authContext?.type === 'client') {
|
|
905
|
-
const actingClient = authContext.client || await clientRecordFromAuthContext(authContext);
|
|
906
|
-
if (actingClient?.clientKind !== 'desktop-local') {
|
|
907
|
-
const effectiveRelayUrls = await getEffectiveRelayUrls();
|
|
908
|
-
if (!Array.isArray(effectiveRelayUrls) || !effectiveRelayUrls.includes(requestedRelayUrl)) {
|
|
909
|
-
return res.status(403).json({ error: 'Only the owner UI session can set a custom Relay endpoint' });
|
|
910
|
-
}
|
|
911
|
-
}
|
|
912
|
-
}
|
|
899
|
+
// The create-auth gate grants paired desktop operators the same pairing
|
|
900
|
+
// endpoint selection as the owner, including adding a new Relay endpoint.
|
|
913
901
|
const candidates = await pairingServerCandidates(req, {
|
|
914
902
|
preferredServerUrl: req.body?.serverUrl,
|
|
915
903
|
includeRelay: typeof req.body?.includeRelay === 'boolean' ? req.body.includeRelay : undefined,
|
|
@@ -990,12 +990,10 @@ describe('core-routes', () => {
|
|
|
990
990
|
expect(dependencies.clientPairingRuntime.createPairingSession).not.toHaveBeenCalled();
|
|
991
991
|
});
|
|
992
992
|
|
|
993
|
-
it('
|
|
994
|
-
const getRelayPairingCandidate = vi.fn();
|
|
995
|
-
const getEffectiveRelayUrls = vi.fn(async () => ['wss://relay.example/ws']);
|
|
993
|
+
it('lets paired desktop clients select a new Relay endpoint when pairing', async () => {
|
|
994
|
+
const getRelayPairingCandidate = vi.fn(async ({ relayUrl }) => ({ type: 'relay', relayUrl }));
|
|
996
995
|
const { app, dependencies } = createPairingRouteApp({
|
|
997
996
|
getRelayPairingCandidate,
|
|
998
|
-
getEffectiveRelayUrls,
|
|
999
997
|
uiAuthController: {
|
|
1000
998
|
resolveAuthContext: vi.fn(async () => ({
|
|
1001
999
|
type: 'client',
|
|
@@ -1020,11 +1018,11 @@ describe('core-routes', () => {
|
|
|
1020
1018
|
|
|
1021
1019
|
await request(app)
|
|
1022
1020
|
.post('/api/client-auth/pairing/sessions')
|
|
1023
|
-
.send({ includeRelay: true, relayUrl: 'wss://
|
|
1024
|
-
.expect(
|
|
1021
|
+
.send({ includeRelay: true, relayUrl: 'wss://custom-relay.example/ws' })
|
|
1022
|
+
.expect(201);
|
|
1025
1023
|
|
|
1026
|
-
expect(getRelayPairingCandidate).
|
|
1027
|
-
expect(dependencies.clientPairingRuntime.createPairingSession).
|
|
1024
|
+
expect(getRelayPairingCandidate).toHaveBeenCalledWith({ ensureEnabled: true, relayUrl: 'wss://custom-relay.example/ws' });
|
|
1025
|
+
expect(dependencies.clientPairingRuntime.createPairingSession).toHaveBeenCalledWith(expect.objectContaining({ usesRelay: true, createdByClientId: 'remote-desktop-1' }));
|
|
1028
1026
|
});
|
|
1029
1027
|
|
|
1030
1028
|
it('lets paired desktop clients echo the current Relay endpoint when pairing', async () => {
|
|
@@ -1035,10 +1033,8 @@ describe('core-routes', () => {
|
|
|
1035
1033
|
hostEncPubJwk: { kty: 'EC', crv: 'P-256', x: 'aaa', y: 'bbb' },
|
|
1036
1034
|
priority: 30,
|
|
1037
1035
|
}));
|
|
1038
|
-
const getEffectiveRelayUrls = vi.fn(async () => ['wss://relay.example/ws', 'wss://relay2.example/ws']);
|
|
1039
1036
|
const { app } = createPairingRouteApp({
|
|
1040
1037
|
getRelayPairingCandidate,
|
|
1041
|
-
getEffectiveRelayUrls,
|
|
1042
1038
|
uiAuthController: {
|
|
1043
1039
|
resolveAuthContext: vi.fn(async () => ({
|
|
1044
1040
|
type: 'client',
|
|
@@ -19,7 +19,7 @@ Traffic is modeled as three stacked layers. The relay understands only Layer 1;
|
|
|
19
19
|
## Entrypoints and structure
|
|
20
20
|
|
|
21
21
|
Host side (`packages/web/server/lib/relay/`):
|
|
22
|
-
- `service.js` — thin entrypoint: relay config (enabled flag + endpoints), the management routes (`GET/POST /api/openchamber/relay/{status,enable,disable}`, `POST /api/openchamber/relay/endpoints`), a `getPairingCandidate()` accessor (the relay transport candidate folded into pairing-v2 links when enabled, consumed by the pairing-session route in `core-routes.js`), and lifecycle wiring. **Multi-relay:** `settings.privateRelay = { enabled, relayUrl, extraRelayUrls }` — the host dials EVERY configured endpoint simultaneously with one identity (same serverId); clients treat `serverId + relayUrl` as the instance identity, so the same machine exposed via two domains appears as two separately named instances. The primary (`relayUrl`) owns push registration and stays the default pairing candidate. A pairing link created for an endpoint the host does not have yet APPENDS it as an extra endpoint
|
|
22
|
+
- `service.js` — thin entrypoint: relay config (enabled flag + endpoints), the management routes (`GET/POST /api/openchamber/relay/{status,enable,disable}`, `POST /api/openchamber/relay/endpoints`), a `getPairingCandidate()` accessor (the relay transport candidate folded into pairing-v2 links when enabled, consumed by the pairing-session route in `core-routes.js`), and lifecycle wiring. **Multi-relay:** `settings.privateRelay = { enabled, relayUrl, extraRelayUrls }` — the host dials EVERY configured endpoint simultaneously with one identity (same serverId); clients treat `serverId + relayUrl` as the instance identity, so the same machine exposed via two domains appears as two separately named instances. The primary (`relayUrl`) owns push registration and stays the default pairing candidate. A pairing link created for an endpoint the host does not have yet APPENDS it as an extra endpoint instead of replacing the primary, so devices paired over the old domain keep working. `POST /relay/endpoints` (owner session / `desktop-local` only) replaces the extra-endpoint list wholesale and starts/stops host connections incrementally. Paired desktop operators may also add an endpoint through pairing creation. Host lifecycle is gated by `isRelayHostRuntime()` (`desktop` or `ssh-remote`): other runtimes (`bun run dev`, `dev:web:hmr`, ordinary CLI `serve`, VS Code, plain `node server`) report `state: 'unavailable'`, refuse `/relay/enable`, `/relay/disable`, `/relay/endpoints`, and relay pairing with 403, never mint a relay identity, never advertise `relayAvailable`, and never call `startRelayHost`. Direct `node server/index.js` forces `OPENCHAMBER_RUNTIME=web` unless it is already `ssh-remote`, so a leftover desktop env cannot open the host-control socket. On host runtimes, started from `packages/web/server/index.js` when demand/opt-in enables the relay. Desktop auto-clears `privateRelay.enabled` when demand drops; `ssh-remote` keeps an explicit opt-in (e.g. `openchamber serve --relay-host`) sticky until disabled. The relay endpoint defaults to the OpenChamber-hosted relay but can be pinned to a self-hosted relay via the `OPENCHAMBER_RELAY_URL` env var; when set it overrides the stored setting for the host connection, the pairing candidate, and status, so paired clients inherit the endpoint automatically. Endpoint identity is scheme/host/path only: accepted schemes are `ws://` and `wss://`; URLs with userinfo are rejected (no silent default fallback); query and fragment are stripped before persistence and pairing candidates. Custom endpoint persistence and Host control-connection switching are explicit authenticated management actions. Pairing creation with `relayUrl` requires an owner UI session, local `desktop-local` shell client, or paired `desktop` operator; `/relay/enable` follows its API auth gate. `derivePushSendUrlFromRelayUrl` maps a canonical Relay `wss://`/`ws://` URL to the same host's `https://`/`http://` `/v1/push/send` path (register is `/v1/push/register-token`); `OPENCHAMBER_PUSH_RELAY_URL` on the Host overrides that derivation. The isolated Push process lives in `packages/relay-server/src/push/` and does not share Layer 1 credentials or tunnel state.
|
|
23
23
|
- `identity.js` — the host's stable identity: the long-lived signing keypair (shared with the push relay, defines the routing id) plus a long-lived encryption keypair (the E2EE trust anchor). Reused across restarts; never rotated implicitly.
|
|
24
24
|
- `signing-key.js` — storage/derivation of the signing keypair and the routing id, shared with the notifications runtime. Canonical public JWK hashing matches `packages/relay-server/src/push/crypto.js`.
|
|
25
25
|
- `host-client.js` — the long-lived connection manager: one outbound control connection to the relay, a per-client data connection for each connected device, reconnect/backoff, and the E2EE responder handshake per connection. Control reconnect backoff does not reset on WebSocket `open`; `consecutiveFailures` clears only after the control socket stays up for `controlStableMs` (15s default), so an open-then-drop loop backs off to 30s instead of retrying every 1s.
|
|
@@ -61,7 +61,7 @@ See also `packages/web/server/lib/opencode/DOCUMENTATION.md` (“Catalog / OpenC
|
|
|
61
61
|
## Authentication model
|
|
62
62
|
|
|
63
63
|
- The tunnel is **transport only**. The OpenChamber server still authenticates every tunneled request exactly as it authenticates a direct remote client. The relay path grants reachability, not authorization.
|
|
64
|
-
- A paired **desktop** client is a device-fleet operator: through the tunnel it can create pairing links, list every paired device, and revoke other devices (parity with the SSH attach flow
|
|
64
|
+
- A paired **desktop** client is a device-fleet operator: through the tunnel it can create pairing links using existing or custom Relay endpoints, list every paired device, and revoke other devices (parity with the SSH attach flow). Pairing with a new endpoint appends it without replacing existing endpoints. Wholesale endpoint-list replacement/removal remains an owner-UI / local-desktop-shell action. Mobile (and any other) paired clients stay scoped to their own record.
|
|
65
65
|
- Clients carry their normal credential. HTTP and SSE requests authenticate with the client's bearer token (a header). **WebSocket upgrades cannot send headers**, so they authenticate with a short-lived URL-scoped token minted beforehand and passed as a query parameter. This asymmetry is important when adding new WebSocket features (see the skill).
|
|
66
66
|
- Desktop switch preflight calls `/auth/session` with the saved bearer over a pinned tunnel before adopting it. `/health` and cached reachability badges provide transport information only. Failed or timed-out probes close their temporary tunnel and preserve the active runtime; successful switch probes transfer ownership of the authenticated tunnel to the runtime.
|
|
67
67
|
- The host authenticates itself to the relay with a signed handshake using its long-lived signing key.
|
|
@@ -100,6 +100,9 @@ consume further pages within a small page budget so the newest-20 **active**
|
|
|
100
100
|
window can still fill; exhausting the budget before the window is full is an
|
|
101
101
|
explicit partial result (`hasMore` stays true). `replaceDirectory` /
|
|
102
102
|
event-ingest upsert keep active-only rows (archived clears the summary and pin).
|
|
103
|
+
An envelope without a next cursor is exhausted even when the final page is full.
|
|
104
|
+
After exhaustion, `hasMore` is true only when visible root sessions overflow the
|
|
105
|
+
20-row index window; child, archived, and system rows do not create a Show-more hint.
|
|
103
106
|
It publishes an in-memory revision after every externally observable index or
|
|
104
107
|
synchronization-state change. A successful empty directory refresh remains in
|
|
105
108
|
the snapshot so another client can use it as a worktree-topology recovery hint.
|
|
@@ -225,8 +225,9 @@ export const createSessionIndexSyncRuntime = ({
|
|
|
225
225
|
continue;
|
|
226
226
|
}
|
|
227
227
|
|
|
228
|
-
//
|
|
229
|
-
|
|
228
|
+
// An envelope without a next token is authoritative exhaustion, even
|
|
229
|
+
// when its terminal page is full. Bare arrays still use the page size.
|
|
230
|
+
if (isEnvelope || !pageFull) {
|
|
230
231
|
upstreamHasMore = false;
|
|
231
232
|
break;
|
|
232
233
|
}
|
|
@@ -254,9 +255,13 @@ export const createSessionIndexSyncRuntime = ({
|
|
|
254
255
|
|
|
255
256
|
// Root + system filters before filling the active-20 capacity.
|
|
256
257
|
const active = [];
|
|
258
|
+
let hasOverflowSessions = false;
|
|
257
259
|
for (const session of projected) {
|
|
258
260
|
if (!isRootActiveCandidate(session)) continue;
|
|
259
|
-
if (active.length >= SESSION_LIMIT)
|
|
261
|
+
if (active.length >= SESSION_LIMIT) {
|
|
262
|
+
hasOverflowSessions = true;
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
260
265
|
active.push(session);
|
|
261
266
|
}
|
|
262
267
|
|
|
@@ -268,7 +273,7 @@ export const createSessionIndexSyncRuntime = ({
|
|
|
268
273
|
directory: task.directory,
|
|
269
274
|
sessions: active,
|
|
270
275
|
cursor: cursorNumber,
|
|
271
|
-
hasMore: upstreamHasMore || hitPageBudget ||
|
|
276
|
+
hasMore: upstreamHasMore || hitPageBudget || hasOverflowSessions,
|
|
272
277
|
fullSync: true,
|
|
273
278
|
now: now(),
|
|
274
279
|
});
|