@openchambery/web 1.19.20 → 2.0.0-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 +13 -11
- package/bin/cli-opencode.test.js +208 -0
- package/bin/cli.js +62 -9
- package/bin/lib/cli-startup.js +105 -2
- package/bin/lib/cli-startup.test.js +165 -0
- package/dist/assets/{AgentAvatar-NT957MuI.js → AgentAvatar-CLWWNu_Q.js} +1 -1
- package/dist/assets/AssistantView-BTCkm8yk.js +3 -0
- package/dist/assets/{DiagramView-C_JfbxNl.js → DiagramView-xuSJTnv7.js} +1 -1
- package/dist/assets/{FadeInOnReveal-CilRl4gc.js → FadeInOnReveal-Ov0D96d0.js} +1 -1
- package/dist/assets/{Icon-i5GiQRLq.js → Icon-DD-BuMfZ.js} +1 -1
- package/dist/assets/MarkdownRenderer-bc_p1H7l.js +2 -0
- package/dist/assets/{MarkdownRendererImpl-B1iUwzOz.js → MarkdownRendererImpl-SKi4oG0c.js} +1 -1
- package/dist/assets/{MarkstreamRendererImpl-BPtMj3-X.js → MarkstreamRendererImpl-CnkodGJW.js} +1 -1
- package/dist/assets/MobileDetailNavigation-Bnx59U_J.js +1 -0
- package/dist/assets/{MobileShareBridge-CKClHsWN.js → MobileShareBridge-BQlP4J-F.js} +1 -1
- package/dist/assets/MobileSurface-BXLeiZ4d.js +2 -0
- package/dist/assets/{MultiRunWindow-DiA24bnN.js → MultiRunWindow-ChZGob0v.js} +1 -1
- package/dist/assets/{OnboardingScreen-BGRLk4Wi.js → OnboardingScreen-DtQJxZG-.js} +1 -1
- package/dist/assets/{OverlayScrollbar-NuQI58XD.js → OverlayScrollbar-pMwxaCca.js} +1 -1
- package/dist/assets/SettingsView-B5I0KDTZ.js +25 -0
- package/dist/assets/{SettingsWindow-XlzRSS7E.js → SettingsWindow-DfnsFa8y.js} +1 -1
- package/dist/assets/{TerminalView-DdIaaLba.js → TerminalView-Dh9MWCpS.js} +1 -1
- package/dist/assets/{ToolOutputDialog-BbwuLdXU.js → ToolOutputDialog-BejmjrRk.js} +1 -1
- package/dist/assets/{appThemeRegistry-ChzfD7mc.js → appThemeRegistry-COGfxTFV.js} +1 -1
- package/dist/assets/{button-DtzFDvij.js → button-D-rATN7w.js} +1 -1
- package/dist/assets/{collapsible-D-nzhcyF.js → collapsible-BXRd5_Hr.js} +1 -1
- package/dist/assets/{context-menu-BzVSzHLG.js → context-menu-jGtH6fR9.js} +1 -1
- package/dist/assets/{diagram-2YWalSb5.js → diagram-DQf7Y_uR.js} +1 -1
- package/dist/assets/{dialog-DGV7DsZC.js → dialog-bfbvR4VH.js} +1 -1
- package/dist/assets/es-c52LTHHe.js +15 -0
- package/dist/assets/{fileMentionTouchSelection-DyjmxUEN.js → fileMentionTouchSelection-BvrNCroG.js} +1 -1
- package/dist/assets/{fr-DsBZeN8Y.js → fr-PisFbUE8.js} +3 -3
- package/dist/assets/gitApi-ButwzQ0N.js +9 -0
- package/dist/assets/{input-CbB2HdLg.js → input-8r6F_Fza.js} +1 -1
- package/dist/assets/insertionBoundaries-DGs7S5zv.js +1 -0
- package/dist/assets/ja-C8Ikun3Z.js +15 -0
- package/dist/assets/ko-ClFys5o2.js +15 -0
- package/dist/assets/main-CCE_4y5F.js +2 -0
- package/dist/assets/main-Dn9rpsgl.js +169 -0
- package/dist/assets/{markdownHeightCache-C9ym9Tsb.js → markdownHeightCache-1Qo6y6IJ.js} +1 -1
- package/dist/assets/miniChat-BpjxVqgg.js +2 -0
- package/dist/assets/mobile-nMJCaNq1.js +2 -0
- package/dist/assets/{multirun-8fkF0Ji_.js → multirun-BvSG94mW.js} +1 -1
- package/dist/assets/pl-CnaKWN5F.js +15 -0
- package/dist/assets/platform-CWTIp02q.js +4 -0
- package/dist/assets/{projectMeta--y6tyjrv.js → projectMeta-DOe49dM_.js} +2 -2
- package/dist/assets/pt-BR-s8OpfRI6.js +15 -0
- package/dist/assets/renderElectronMiniChatApp-R6dKh79f.js +2 -0
- package/dist/assets/renderMobileApp-y-S8y8v_.js +28 -0
- package/dist/assets/{runtimeConfig-DYWtBrkW.js → runtimeConfig-BMvqa5we.js} +1 -1
- package/dist/assets/{runtimeEndpointReset-BSWg99jc.js → runtimeEndpointReset-B6BxhvR0.js} +5 -5
- package/dist/assets/{sessionChangeSummary-BwupJF-Q.js → sessionChangeSummary-VVBlEXuh.js} +1 -1
- package/dist/assets/{sessionLookup-DP2X1qRR.js → sessionLookup-gh-hVRIc.js} +1 -1
- package/dist/assets/{sortable-tabs-strip-CfgEJfB0.js → sortable-tabs-strip-CudoSgV4.js} +1 -1
- package/dist/assets/{src-DZ60kHBP.css → src-BzX5UI-c.css} +1 -1
- package/dist/assets/{store-B6dImdlY.js → store-DJczmczv.js} +4 -4
- package/dist/assets/{terminalApi-xjqssKke.js → terminalApi-DiF6JmBP.js} +1 -1
- package/dist/assets/{textarea-BnD799LW.js → textarea-3XR8sUfy.js} +1 -1
- package/dist/assets/{tooltip-Djn7MSOX.js → tooltip-CQvpK-UV.js} +1 -1
- package/dist/assets/uk-CaX0iqkB.js +15 -0
- package/dist/assets/{useAppFontEffects-DWLxpWlS.js → useAppFontEffects-Cpmpo7Wb.js} +45 -45
- package/dist/assets/{useDesktopWindowControlsLayout-BGPR_rq6.js → useDesktopWindowControlsLayout-B1GX59-L.js} +1 -1
- package/dist/assets/{useEffectiveDirectory-CbV9_r1s.js → useEffectiveDirectory-CN6JoCFw.js} +1 -1
- package/dist/assets/{useFontPreferences-B1JYyzpK.js → useFontPreferences-DcbXQOkc.js} +1 -1
- package/dist/assets/useMobileNavigationStore-BVjFtXSy.js +414 -0
- package/dist/assets/{useSessionAutoCleanup-Dnors7hB.js → useSessionAutoCleanup-BLjymaFt.js} +1 -1
- package/dist/assets/{useWorkerHighlightedLines-DQqqrOBU.js → useWorkerHighlightedLines-Bqn4AiBL.js} +1 -1
- package/dist/assets/vendor-opencode-client-BOe2qpQb.js +12 -0
- package/dist/assets/{vendor-yaml-DIcAK3EC.js → vendor-yaml-C9KlJAlj.js} +37 -37
- package/dist/assets/zh-CN-20MYEjha.js +15 -0
- package/dist/assets/{zh-TW-BsOQbrwT.js → zh-TW-DiN04qTw.js} +3 -3
- package/dist/index.html +6 -6
- package/dist/mini-chat.html +6 -6
- package/dist/mobile.html +6 -6
- package/package.json +2 -2
- package/server/index.js +107 -0
- package/server/lib/assistants/DOCUMENTATION.md +18 -18
- package/server/lib/assistants/assign.js +2 -1
- package/server/lib/assistants/contact-store.js +37 -19
- package/server/lib/assistants/projection-history.test.js +207 -0
- package/server/lib/assistants/routes.js +5 -5
- package/server/lib/assistants/service.js +493 -123
- package/server/lib/assistants/service.test.js +203 -133
- package/server/lib/conversations/DOCUMENTATION.md +4 -4
- package/server/lib/conversations/service.js +86 -57
- package/server/lib/conversations/service.test.js +116 -100
- package/server/lib/event-stream/global-hub.js +2 -1
- package/server/lib/llm/DOCUMENTATION.md +71 -84
- package/server/lib/llm/catalog.js +111 -45
- package/server/lib/llm/catalog.test.js +121 -59
- package/server/lib/llm/completions.js +37 -12
- package/server/lib/llm/completions.test.js +45 -4
- package/server/lib/llm/generate.js +421 -450
- package/server/lib/llm/generate.test.js +553 -487
- package/server/lib/llm/routes.js +6 -4
- package/server/lib/message-queue/DOCUMENTATION.md +5 -5
- package/server/lib/message-queue/opencode-adapter.js +191 -104
- package/server/lib/message-queue/opencode-adapter.test.js +328 -58
- package/server/lib/message-queue/runtime.test.js +6 -6
- package/server/lib/message-queue/worker.js +7 -1
- package/server/lib/opencode/DOCUMENTATION.md +28 -5
- package/server/lib/opencode/config-entity-routes.js +84 -13
- package/server/lib/opencode/config-entity-routes.test.js +41 -10
- package/server/lib/opencode/ensure-cli.js +239 -0
- package/server/lib/opencode/ensure-cli.test.js +119 -0
- package/server/lib/opencode/env-runtime.js +193 -110
- package/server/lib/opencode/env-runtime.test.js +221 -17
- package/server/lib/opencode/feature-routes-runtime.js +37 -1
- package/server/lib/opencode/lifecycle.js +204 -70
- package/server/lib/opencode/lifecycle.test.js +376 -6
- package/server/lib/opencode/network-runtime.js +28 -16
- package/server/lib/opencode/network-runtime.test.js +68 -4
- package/server/lib/opencode/opencode2-pin.js +139 -0
- package/server/lib/opencode/opencode2-pin.test.js +87 -0
- package/server/lib/opencode/opencode2-sidecar.e2e.test.js +203 -0
- package/server/lib/opencode/plugin-compatibility.js +15 -0
- package/server/lib/opencode/plugin-compatibility.test.js +21 -0
- package/server/lib/opencode/plugins.js +4 -3
- package/server/lib/opencode/plugins.test.js +12 -0
- package/server/lib/opencode/proxy.js +153 -16
- package/server/lib/opencode/proxy.test.js +120 -0
- package/server/lib/opencode/routes.js +25 -32
- package/server/lib/opencode/routes.test.js +51 -52
- package/server/lib/opencode/server-utils-runtime.js +2 -0
- package/server/lib/opencode/skill-routes.js +6 -7
- package/server/lib/opencode/skill-routes.test.js +36 -5
- package/server/lib/opencode/v1-migration-gate.js +157 -0
- package/server/lib/opencode/v1-migration-gate.test.js +111 -0
- package/server/lib/opencode/v2-client.js +49 -0
- package/server/lib/opencode/watcher.js +2 -1
- package/server/lib/opencode/watcher.test.js +1 -1
- package/server/lib/scheduled-tasks/DOCUMENTATION.md +71 -39
- package/server/lib/scheduled-tasks/managed-tool-route.js +31 -1
- package/server/lib/scheduled-tasks/managed-tool-route.test.js +23 -0
- package/server/lib/scheduled-tasks/routes.js +27 -0
- package/server/lib/scheduled-tasks/routes.test.js +94 -0
- package/server/lib/scheduled-tasks/runtime.js +331 -308
- package/server/lib/scheduled-tasks/runtime.test.js +530 -318
- package/server/lib/session-goal/DOCUMENTATION.md +65 -14
- package/server/lib/session-goal/capability.js +57 -0
- package/server/lib/session-goal/capability.test.js +33 -0
- package/server/lib/session-goal/routes.js +231 -2
- package/server/lib/session-goal/routes.test.js +211 -0
- package/server/lib/session-goal/runtime.js +46 -4
- package/server/lib/session-goal/runtime.test.js +62 -1
- package/server/lib/session-index/sync-runtime.js +20 -17
- package/server/lib/session-index/sync-runtime.test.js +9 -8
- package/server/lib/session-metadata/DOCUMENTATION.md +41 -0
- package/server/lib/session-metadata/routes.js +94 -0
- package/server/lib/session-metadata/routes.test.js +111 -0
- package/server/lib/session-metadata/session-metadata-store.js +235 -0
- package/server/lib/session-metadata/session-metadata-store.test.js +184 -0
- package/server/lib/session-turn-pages/DOCUMENTATION.md +3 -3
- package/server/lib/session-turn-pages/reconcile.service.js +1 -1
- package/server/lib/session-turn-pages/reconcile.service.test.js +16 -0
- package/server/lib/session-turn-pages/routes.js +204 -71
- package/server/lib/session-turn-pages/routes.test.js +285 -0
- package/server/lib/session-turn-pages/service.js +6 -2
- package/server/lib/session-turn-pages/service.test.js +34 -0
- package/server/lib/small-model/DOCUMENTATION.md +5 -4
- package/server/lib/small-model/catalog.js +56 -6
- package/server/lib/small-model/catalog.test.js +47 -0
- package/server/lib/small-model/index.js +3 -3
- package/server/lib/small-model/opencode-session.js +50 -206
- package/server/lib/small-model/opencode-session.test.js +58 -88
- package/server/opencode-proxy.test.js +61 -12
- package/dist/assets/AssistantView-BjvfkSRS.js +0 -3
- package/dist/assets/MarkdownRenderer-Cg82L3fN.js +0 -2
- package/dist/assets/MobileDetailNavigation-s_vvUD_F.js +0 -1
- package/dist/assets/MobileSurface-DHpXHbJp.js +0 -2
- package/dist/assets/SettingsView-l0J7pfYo.js +0 -25
- package/dist/assets/es-rqMiRP7-.js +0 -15
- package/dist/assets/gitApi-CHpibtno.js +0 -7
- package/dist/assets/insertionBoundaries-Yd1Lorjw.js +0 -1
- package/dist/assets/ja-Bgr8-dOg.js +0 -15
- package/dist/assets/ko-B_Gxh-0l.js +0 -15
- package/dist/assets/main-BoRXxmB3.js +0 -2
- package/dist/assets/main-DCDcdV5o.js +0 -169
- package/dist/assets/miniChat-12fxk5KR.js +0 -2
- package/dist/assets/mobile-CkZem9Ad.js +0 -2
- package/dist/assets/pl-DW2XJGC4.js +0 -15
- package/dist/assets/platform-0w_Q9_ji.js +0 -2
- package/dist/assets/pt-BR-CIsanf4T.js +0 -15
- package/dist/assets/renderElectronMiniChatApp-COFc0ZDJ.js +0 -2
- package/dist/assets/renderMobileApp-BKzx7JL3.js +0 -28
- package/dist/assets/uk-BPmPEUYJ.js +0 -15
- package/dist/assets/useMobileNavigationStore-DrB_awgk.js +0 -413
- package/dist/assets/vendor-opencode-sdk-C07HQypa.js +0 -7
- package/dist/assets/zh-CN-R8-mDcIp.js +0 -15
|
@@ -319,11 +319,23 @@ export const createSessionGoalRuntime = ({
|
|
|
319
319
|
* timers for the session tree (root + descendants).
|
|
320
320
|
*/
|
|
321
321
|
onGoalPaused,
|
|
322
|
+
/**
|
|
323
|
+
* OpenChamber session-metadata store seams. When both are functions, goal
|
|
324
|
+
* state is read/written only through the Host store (OpenCode 2.x has no
|
|
325
|
+
* session-metadata PATCH). When either is missing, keep the legacy OpenCode
|
|
326
|
+
* PATCH path so existing tests and unwired servers still work.
|
|
327
|
+
*/
|
|
328
|
+
persistSessionGoal = null,
|
|
329
|
+
readSessionMetadata = null,
|
|
322
330
|
}) => {
|
|
323
331
|
const timers = new Map();
|
|
324
332
|
const inflight = new Set();
|
|
325
333
|
let stopped = false;
|
|
326
334
|
|
|
335
|
+
const isMetadataStoreWired = () => (
|
|
336
|
+
typeof persistSessionGoal === 'function' && typeof readSessionMetadata === 'function'
|
|
337
|
+
);
|
|
338
|
+
|
|
327
339
|
const clearTimer = (sessionId) => {
|
|
328
340
|
const existing = timers.get(sessionId);
|
|
329
341
|
if (existing) {
|
|
@@ -383,7 +395,28 @@ export const createSessionGoalRuntime = ({
|
|
|
383
395
|
// metadata writes (assist payloads, dismissals, UI goal edits) survive.
|
|
384
396
|
// Returns the written goal, or null when the stored goal no longer matches
|
|
385
397
|
// the expected id (user replaced/cleared it while we worked).
|
|
398
|
+
/**
|
|
399
|
+
* When the Host metadata store is wired, the goal lives there — OpenCode 2.x
|
|
400
|
+
* accepts session metadata only at create time. Re-read before every write so
|
|
401
|
+
* a concurrent edit (the user pausing from the UI) is not overwritten.
|
|
402
|
+
*/
|
|
403
|
+
const readGoal = async (sessionId, directory) => {
|
|
404
|
+
if (isMetadataStoreWired()) {
|
|
405
|
+
return parseGoalMetadata({ metadata: await readSessionMetadata(sessionId) });
|
|
406
|
+
}
|
|
407
|
+
const session = await openCodeFetch(`/session/${encodeURIComponent(sessionId)}`, { directory });
|
|
408
|
+
return parseGoalMetadata(session);
|
|
409
|
+
};
|
|
410
|
+
|
|
386
411
|
const writeGoal = async (sessionId, directory, expectedGoalId, mutate) => {
|
|
412
|
+
if (isMetadataStoreWired()) {
|
|
413
|
+
const currentGoal = await readGoal(sessionId, directory);
|
|
414
|
+
if (!currentGoal || currentGoal.id !== expectedGoalId) return null;
|
|
415
|
+
const nextGoal = { ...currentGoal, ...mutate(currentGoal), updatedAt: Date.now() };
|
|
416
|
+
await persistSessionGoal(sessionId, directory, nextGoal);
|
|
417
|
+
return nextGoal;
|
|
418
|
+
}
|
|
419
|
+
|
|
387
420
|
const session = await openCodeFetch(`/session/${encodeURIComponent(sessionId)}`, { directory });
|
|
388
421
|
const currentGoal = parseGoalMetadata(session);
|
|
389
422
|
if (!currentGoal || currentGoal.id !== expectedGoalId) return null;
|
|
@@ -537,7 +570,9 @@ export const createSessionGoalRuntime = ({
|
|
|
537
570
|
// Sub-agent/task sessions never carry user goals — skip them.
|
|
538
571
|
if (typeof session.parentID === 'string' && session.parentID) return;
|
|
539
572
|
|
|
540
|
-
const goal =
|
|
573
|
+
const goal = isMetadataStoreWired()
|
|
574
|
+
? await readGoal(sessionId, directory)
|
|
575
|
+
: parseGoalMetadata(session);
|
|
541
576
|
if (!goal || goal.status !== 'active') return;
|
|
542
577
|
|
|
543
578
|
// File-backed objectives: the metadata carries only a flag; the objective
|
|
@@ -835,7 +870,9 @@ export const createSessionGoalRuntime = ({
|
|
|
835
870
|
// auto-delegate timers are cancelled (abort path used to no-op on non-active).
|
|
836
871
|
const pauseAfterAbort = async (sessionId, directory) => {
|
|
837
872
|
const owner = await resolveGoalOwner(sessionId, directory);
|
|
838
|
-
const goal =
|
|
873
|
+
const goal = isMetadataStoreWired()
|
|
874
|
+
? await readGoal(owner.sessionId, owner.directory)
|
|
875
|
+
: parseGoalMetadata(owner.session);
|
|
839
876
|
if (!goal) return;
|
|
840
877
|
if (goal.status === 'paused') {
|
|
841
878
|
if (!isQuestionDrivenPause(goal)) {
|
|
@@ -857,9 +894,14 @@ export const createSessionGoalRuntime = ({
|
|
|
857
894
|
// from a child/sub-agent (any depth) pauses the root owner session's goal.
|
|
858
895
|
const pauseForQuestion = async (sessionId, directory) => {
|
|
859
896
|
const owner = await resolveGoalOwner(sessionId, directory);
|
|
860
|
-
|
|
897
|
+
// When the Host store is wired, goal state does not live on the OpenCode
|
|
898
|
+
// record — owner.session may be null only if the session itself is missing.
|
|
899
|
+
if (!isMetadataStoreWired() && !owner.session) return;
|
|
900
|
+
if (isMetadataStoreWired() && !owner.sessionId) return;
|
|
861
901
|
clearTimer(owner.sessionId);
|
|
862
|
-
const goal =
|
|
902
|
+
const goal = isMetadataStoreWired()
|
|
903
|
+
? await readGoal(owner.sessionId, owner.directory)
|
|
904
|
+
: parseGoalMetadata(owner.session);
|
|
863
905
|
if (!goal || goal.status !== 'active') return;
|
|
864
906
|
await writeGoal(owner.sessionId, owner.directory, goal.id, () => ({
|
|
865
907
|
status: 'paused',
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, it } from '
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
2
|
import { accountGoalTokenSpend, createSessionGoalRuntime, messageTokenSpend } from './runtime.js';
|
|
3
3
|
|
|
4
4
|
// A session whose last assistant message is an orphaned incomplete turn: the
|
|
@@ -641,3 +641,64 @@ describe('session-goal runtime — pause for question', () => {
|
|
|
641
641
|
});
|
|
642
642
|
});
|
|
643
643
|
});
|
|
644
|
+
|
|
645
|
+
describe('session-goal runtime — Host metadata store seams', () => {
|
|
646
|
+
it('writes through persistSessionGoal and never PATCHes OpenCode metadata when seams are injected', async () => {
|
|
647
|
+
const active = buildSession('active');
|
|
648
|
+
const goal = active.metadata.openchamber.goal;
|
|
649
|
+
const metadata = {
|
|
650
|
+
openchamber: {
|
|
651
|
+
goal: { ...goal },
|
|
652
|
+
assist: { recap: 'keep-me' },
|
|
653
|
+
},
|
|
654
|
+
};
|
|
655
|
+
const readSessionMetadata = vi.fn(async () => metadata);
|
|
656
|
+
const persistSessionGoal = vi.fn(async (_sessionId, _directory, nextGoal) => {
|
|
657
|
+
metadata.openchamber = { ...metadata.openchamber, goal: nextGoal };
|
|
658
|
+
});
|
|
659
|
+
const fetchMock = vi.fn(async (input, init = {}) => {
|
|
660
|
+
const url = String(input);
|
|
661
|
+
const path = url.split('?')[0];
|
|
662
|
+
if (init.method === 'PATCH') {
|
|
663
|
+
throw new Error(`OpenCode metadata PATCH must not run when store seams are wired: ${path}`);
|
|
664
|
+
}
|
|
665
|
+
// resolveGoalOwner walks parentID via GET /session/:id
|
|
666
|
+
if (path.endsWith('/session/ses-goal')) {
|
|
667
|
+
return new Response(JSON.stringify({ id: 'ses-goal', directory: '/repo' }), { status: 200 });
|
|
668
|
+
}
|
|
669
|
+
return new Response(JSON.stringify({}), { status: 200 });
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
await withFetch(fetchMock, async () => {
|
|
673
|
+
const runtime = createSessionGoalRuntime({
|
|
674
|
+
buildOpenCodeUrl: (pathname) => `http://opencode${pathname}`,
|
|
675
|
+
getOpenCodeAuthHeaders: () => ({}),
|
|
676
|
+
getSmallModelService: async () => {
|
|
677
|
+
throw new Error('small model must not run');
|
|
678
|
+
},
|
|
679
|
+
idleQuietMs: 1_000_000,
|
|
680
|
+
kickoffQuietMs: 1,
|
|
681
|
+
maxAutoTurns: 20,
|
|
682
|
+
readSessionMetadata,
|
|
683
|
+
persistSessionGoal,
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
await runtime.pauseForQuestion('ses-goal', '/repo');
|
|
687
|
+
|
|
688
|
+
expect(readSessionMetadata).toHaveBeenCalledWith('ses-goal');
|
|
689
|
+
expect(persistSessionGoal).toHaveBeenCalledTimes(1);
|
|
690
|
+
expect(persistSessionGoal.mock.calls[0][0]).toBe('ses-goal');
|
|
691
|
+
expect(persistSessionGoal.mock.calls[0][1]).toBe('/repo');
|
|
692
|
+
expect(persistSessionGoal.mock.calls[0][2]).toMatchObject({
|
|
693
|
+
id: goal.id,
|
|
694
|
+
status: 'paused',
|
|
695
|
+
statusReason: 'paused for question',
|
|
696
|
+
});
|
|
697
|
+
// Neighbouring Host namespaces must survive the goal write (caller owns
|
|
698
|
+
// merge; this seam only receives the goal object).
|
|
699
|
+
expect(metadata.openchamber.assist).toEqual({ recap: 'keep-me' });
|
|
700
|
+
expect(fetchMock.mock.calls.some(([, init]) => init?.method === 'PATCH')).toBe(false);
|
|
701
|
+
runtime.stop();
|
|
702
|
+
});
|
|
703
|
+
});
|
|
704
|
+
});
|
|
@@ -117,21 +117,14 @@ export const createSessionIndexSyncRuntime = ({
|
|
|
117
117
|
|
|
118
118
|
const fetchDirectory = async (task) => {
|
|
119
119
|
const cached = readDirectory(task.directory);
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
&& now() - cached.lastFullSyncedAt < FULL_RECONCILE_INTERVAL_MS
|
|
127
|
-
);
|
|
128
|
-
// Keep parity with @opencode-ai/sdk/v2 experimental.session.list. The
|
|
129
|
-
// regular /session endpoint has different pagination semantics.
|
|
130
|
-
const url = new URL(buildOpenCodeUrl('/experimental/session'));
|
|
120
|
+
// v2 `session.list` (`GET /api/session`) exposes cursor pagination only —
|
|
121
|
+
// no timestamp-incremental (`start`) or `roots` filter. Sync therefore
|
|
122
|
+
// always fetches a fresh full page and lets replaceDirectory reconcile.
|
|
123
|
+
// Empty snapshots still store lastSyncedAt as a worktree topology hint.
|
|
124
|
+
const useIncremental = false;
|
|
125
|
+
const url = new URL(buildOpenCodeUrl('/session'));
|
|
131
126
|
url.searchParams.set('directory', task.directory);
|
|
132
|
-
url.searchParams.set('roots', 'true');
|
|
133
127
|
url.searchParams.set('limit', String(SESSION_LIMIT));
|
|
134
|
-
if (useIncremental) url.searchParams.set('start', String(cached.lastSyncedAt));
|
|
135
128
|
|
|
136
129
|
const controller = new AbortController();
|
|
137
130
|
currentController = controller;
|
|
@@ -148,7 +141,10 @@ export const createSessionIndexSyncRuntime = ({
|
|
|
148
141
|
error.status = response.status;
|
|
149
142
|
throw error;
|
|
150
143
|
}
|
|
151
|
-
const
|
|
144
|
+
const payload = await response.json();
|
|
145
|
+
// v2 responds `{ data: Session[], cursor }`; tolerate a bare array for
|
|
146
|
+
// non-v2 test doubles.
|
|
147
|
+
const sessions = Array.isArray(payload) ? payload : payload?.data;
|
|
152
148
|
if (!Array.isArray(sessions)) throw new Error('Invalid OpenCode session list payload');
|
|
153
149
|
const nextSessions = useIncremental
|
|
154
150
|
? mergeIncrementalSessions(cached?.sessions, sessions)
|
|
@@ -187,7 +183,10 @@ export const createSessionIndexSyncRuntime = ({
|
|
|
187
183
|
currentWasPreempted = false;
|
|
188
184
|
const timeout = setTimer(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
189
185
|
try {
|
|
190
|
-
|
|
186
|
+
// v2 protocol has no `/session/:id/children` route; child sessions are a
|
|
187
|
+
// `parentID` filter on the session list (`GET /api/session`).
|
|
188
|
+
const url = new URL(buildOpenCodeUrl('/session'));
|
|
189
|
+
url.searchParams.set('parentID', task.sessionID);
|
|
191
190
|
url.searchParams.set('directory', task.directory);
|
|
192
191
|
const response = await fetchFn(url, {
|
|
193
192
|
method: 'GET',
|
|
@@ -199,8 +198,12 @@ export const createSessionIndexSyncRuntime = ({
|
|
|
199
198
|
error.status = response.status;
|
|
200
199
|
throw error;
|
|
201
200
|
}
|
|
202
|
-
const
|
|
203
|
-
|
|
201
|
+
const payload = await response.json();
|
|
202
|
+
// v2 wraps list responses in `{ data, cursor }`; accept a bare array too.
|
|
203
|
+
const children = Array.isArray(payload)
|
|
204
|
+
? payload
|
|
205
|
+
: (Array.isArray(payload?.data) ? payload.data : null);
|
|
206
|
+
if (!children) throw new Error('Invalid OpenCode child-session list payload');
|
|
204
207
|
sessionIndexService.replaceChildSessions(task.directory, task.sessionID, children);
|
|
205
208
|
return 'completed';
|
|
206
209
|
} catch (error) {
|
|
@@ -68,7 +68,7 @@ describe('session index background sync runtime', () => {
|
|
|
68
68
|
|
|
69
69
|
expect(maxActive).toBe(1);
|
|
70
70
|
expect(fetchFn).toHaveBeenCalledTimes(4);
|
|
71
|
-
expect(fetchFn.mock.calls[0][0].pathname).toBe('/
|
|
71
|
+
expect(fetchFn.mock.calls[0][0].pathname).toBe('/session');
|
|
72
72
|
expect(service.replaceDirectory).toHaveBeenCalledTimes(2);
|
|
73
73
|
expect(service.replaceChildSessions).toHaveBeenCalledTimes(2);
|
|
74
74
|
expect(runtime.snapshot().sync).toMatchObject({ completed: 2, total: 2, failedDirectories: [] });
|
|
@@ -89,7 +89,7 @@ describe('session index background sync runtime', () => {
|
|
|
89
89
|
await expect(changed).resolves.toMatchObject({ revision: initialRevision + 1 });
|
|
90
90
|
});
|
|
91
91
|
|
|
92
|
-
it('
|
|
92
|
+
it('v2 session.list has no incremental start — always fetches a full page', async () => {
|
|
93
93
|
const service = createService([{
|
|
94
94
|
directory: '/repo',
|
|
95
95
|
sessions: [session('ses_old', 10)],
|
|
@@ -105,7 +105,7 @@ describe('session index background sync runtime', () => {
|
|
|
105
105
|
getOpenCodeAuthHeaders: () => ({}),
|
|
106
106
|
waitForOpenCodeReady: async () => true,
|
|
107
107
|
fetchFn: async (url) => {
|
|
108
|
-
if (url.pathname === '/
|
|
108
|
+
if (url.pathname === '/session') requestedUrl = url;
|
|
109
109
|
return new Response(JSON.stringify([session('ses_new', 20)]), { status: 200 });
|
|
110
110
|
},
|
|
111
111
|
now: () => 2000,
|
|
@@ -114,10 +114,11 @@ describe('session index background sync runtime', () => {
|
|
|
114
114
|
runtime.enqueue(['/repo']);
|
|
115
115
|
await waitUntil(() => isFullyIdle(runtime));
|
|
116
116
|
|
|
117
|
-
expect(requestedUrl.searchParams.get('start')).
|
|
117
|
+
expect(requestedUrl.searchParams.get('start')).toBeNull();
|
|
118
|
+
expect(requestedUrl.searchParams.get('roots')).toBeNull();
|
|
118
119
|
expect(service.replaceDirectory.mock.calls[0][0]).toMatchObject({
|
|
119
|
-
fullSync:
|
|
120
|
-
sessions: [expect.objectContaining({ id: 'ses_new' })
|
|
120
|
+
fullSync: true,
|
|
121
|
+
sessions: [expect.objectContaining({ id: 'ses_new' })],
|
|
121
122
|
});
|
|
122
123
|
});
|
|
123
124
|
|
|
@@ -195,7 +196,7 @@ describe('session index background sync runtime', () => {
|
|
|
195
196
|
});
|
|
196
197
|
});
|
|
197
198
|
|
|
198
|
-
it('
|
|
199
|
+
it('recovers an empty cached directory with a full page', async () => {
|
|
199
200
|
const service = createService([{
|
|
200
201
|
directory: '/repo',
|
|
201
202
|
sessions: [],
|
|
@@ -211,7 +212,7 @@ describe('session index background sync runtime', () => {
|
|
|
211
212
|
getOpenCodeAuthHeaders: () => ({}),
|
|
212
213
|
waitForOpenCodeReady: async () => true,
|
|
213
214
|
fetchFn: async (url) => {
|
|
214
|
-
if (url.pathname === '/
|
|
215
|
+
if (url.pathname === '/session') requestedUrl = url;
|
|
215
216
|
return new Response(JSON.stringify([session('ses_history', 10)]), { status: 200 });
|
|
216
217
|
},
|
|
217
218
|
now: () => 2000,
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Session metadata store
|
|
2
|
+
|
|
3
|
+
OpenChamber-owned per-session metadata for Host features that used to live on
|
|
4
|
+
OpenCode `session.metadata`.
|
|
5
|
+
|
|
6
|
+
## Why
|
|
7
|
+
|
|
8
|
+
OpenCode 2.x accepts `metadata` only at session create time. There is no
|
|
9
|
+
`PATCH /session/:id` (or equivalent) to update it afterwards. Session goal
|
|
10
|
+
progress — and later other Host state under the same namespace — therefore
|
|
11
|
+
lives in this Host store and is folded back onto proxy session list/detail
|
|
12
|
+
responses so clients still read `session.metadata`.
|
|
13
|
+
|
|
14
|
+
## Layout
|
|
15
|
+
|
|
16
|
+
| File | Role |
|
|
17
|
+
|---|---|
|
|
18
|
+
| `session-metadata-store.js` | Durable store: one JSON file per data dir, `{ [sessionID]: metadata }`, RFC 7386 merge patch, refuse-failed-read refuses write |
|
|
19
|
+
| `routes.js` | `PUT /api/openchamber/sessions/:sessionId/metadata` + re-exports `createSessionMetadataStore` |
|
|
20
|
+
| `*.test.js` | Store + route contracts |
|
|
21
|
+
|
|
22
|
+
## Contracts
|
|
23
|
+
|
|
24
|
+
- Writes are JSON Merge Patch (RFC 7386): nested objects merge key-by-key; `null` deletes a key.
|
|
25
|
+
- A failed file read is **not** empty success — the store refuses writes until a load succeeds, so unknown disk state is never overwritten with `{}`.
|
|
26
|
+
- Entries are keyed by `sessionID` only (not directory).
|
|
27
|
+
- Successful `PUT` may broadcast `openchamber:session-metadata` with the **full** merged metadata object.
|
|
28
|
+
|
|
29
|
+
## Server wiring
|
|
30
|
+
|
|
31
|
+
Wired from `packages/web/server/index.js`:
|
|
32
|
+
|
|
33
|
+
1. `createSessionMetadataStore({ dataDir: OPENCHAMBER_DATA_DIR })`
|
|
34
|
+
2. proxy `getStoredSessionMetadata: () => store.getAll()`
|
|
35
|
+
3. session-goal `readSessionMetadata` / `persistSessionGoal`
|
|
36
|
+
4. `registerSessionMetadataRoutes` — a successful PUT notifies the goal runtime with a synthetic `session.updated` so a UI create/resume arms the loop
|
|
37
|
+
|
|
38
|
+
Capability is open (`supported: true`). Manual UI metadata writes, session-goal
|
|
39
|
+
routes, and scheduled-task goal create all persist `openchamber.goal` through
|
|
40
|
+
this store. After first create/resume persist, the server notifies the goal
|
|
41
|
+
runtime with a synthetic `session.updated` so the loop can arm.
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP surface for OpenChamber-owned session metadata.
|
|
3
|
+
*
|
|
4
|
+
* OpenCode 2.x has no session-metadata update route. Clients (and Host
|
|
5
|
+
* features such as session goal) merge-patch state here; the proxy folds the
|
|
6
|
+
* store back onto session list/detail responses.
|
|
7
|
+
*
|
|
8
|
+
* This module exports the store factory and the route registrar. Wire them
|
|
9
|
+
* from `server/index.js` (store instance, proxy `getStoredSessionMetadata`,
|
|
10
|
+
* session-goal / scheduled-tasks `readSessionMetadata` / `persistSessionGoal`,
|
|
11
|
+
* then `registerSessionMetadataRoutes`).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import express from 'express';
|
|
15
|
+
|
|
16
|
+
import { createSessionMetadataStore } from './session-metadata-store.js';
|
|
17
|
+
|
|
18
|
+
const asNonEmptyString = (value) => {
|
|
19
|
+
if (typeof value !== 'string') return null;
|
|
20
|
+
const trimmed = value.trim();
|
|
21
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {import('express').Express} app
|
|
28
|
+
* @param {object} deps
|
|
29
|
+
* @param {{ get: Function, setSessionMetadata: Function }} deps.sessionMetadataStore
|
|
30
|
+
* @param {(event: { type: string, properties: object }) => void} [deps.broadcastGlobalUiEvent]
|
|
31
|
+
* @param {(event: { sessionID: string, directory: string, metadata: object }) => void} [deps.onMetadataWritten]
|
|
32
|
+
*/
|
|
33
|
+
export const registerSessionMetadataRoutes = (app, deps = {}) => {
|
|
34
|
+
const {
|
|
35
|
+
sessionMetadataStore = null,
|
|
36
|
+
broadcastGlobalUiEvent = null,
|
|
37
|
+
onMetadataWritten = null,
|
|
38
|
+
} = deps;
|
|
39
|
+
|
|
40
|
+
if (!sessionMetadataStore || typeof sessionMetadataStore.setSessionMetadata !== 'function') {
|
|
41
|
+
throw new Error('registerSessionMetadataRoutes requires a sessionMetadataStore');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const putMetadata = async (req, res) => {
|
|
45
|
+
const sessionId = asNonEmptyString(req.params?.sessionId);
|
|
46
|
+
if (!sessionId) {
|
|
47
|
+
return res.status(400).json({ error: 'a session id is required' });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const body = req.body && typeof req.body === 'object' && !Array.isArray(req.body) ? req.body : {};
|
|
51
|
+
const patch = body.patch;
|
|
52
|
+
if (!isPlainObject(patch)) {
|
|
53
|
+
return res.status(400).json({ error: 'patch must be an object' });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const metadata = await sessionMetadataStore.setSessionMetadata(sessionId, patch);
|
|
58
|
+
const directory = asNonEmptyString(body.directory) || '';
|
|
59
|
+
if (typeof onMetadataWritten === 'function') {
|
|
60
|
+
try {
|
|
61
|
+
onMetadataWritten({ sessionID: sessionId, directory, metadata });
|
|
62
|
+
} catch (error) {
|
|
63
|
+
console.warn('[session-metadata] metadata listener failed:', error?.message ?? error);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (typeof broadcastGlobalUiEvent === 'function') {
|
|
67
|
+
try {
|
|
68
|
+
broadcastGlobalUiEvent({
|
|
69
|
+
type: 'openchamber:session-metadata',
|
|
70
|
+
properties: { sessionID: sessionId, metadata },
|
|
71
|
+
});
|
|
72
|
+
} catch (error) {
|
|
73
|
+
console.warn('[session-metadata] broadcast failed:', error?.message ?? error);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return res.json({ metadata });
|
|
77
|
+
} catch (error) {
|
|
78
|
+
const message = error instanceof Error ? error.message : 'Failed to store session metadata';
|
|
79
|
+
// Load/read failure must not be answered as empty success — clients must
|
|
80
|
+
// not treat a refused write as cleared metadata.
|
|
81
|
+
const status = /could not be read|unavailable/i.test(message) ? 503 : 500;
|
|
82
|
+
console.error('[session-metadata] failed to store session metadata:', message);
|
|
83
|
+
return res.status(status).json({ error: message });
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
app.put(
|
|
88
|
+
'/api/openchamber/sessions/:sessionId/metadata',
|
|
89
|
+
express.json({ limit: '1mb' }),
|
|
90
|
+
putMetadata,
|
|
91
|
+
);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export { createSessionMetadataStore, mergeMetadataPatch } from './session-metadata-store.js';
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import express from 'express';
|
|
5
|
+
import request from 'supertest';
|
|
6
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
7
|
+
|
|
8
|
+
import { createSessionMetadataStore, registerSessionMetadataRoutes } from './routes.js';
|
|
9
|
+
|
|
10
|
+
const tempDirs = [];
|
|
11
|
+
|
|
12
|
+
const makeDataDir = () => {
|
|
13
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-metadata-routes-'));
|
|
14
|
+
tempDirs.push(dir);
|
|
15
|
+
return dir;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
while (tempDirs.length > 0) {
|
|
20
|
+
fs.rmSync(tempDirs.pop(), { recursive: true, force: true });
|
|
21
|
+
}
|
|
22
|
+
vi.restoreAllMocks();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const mount = (overrides = {}) => {
|
|
26
|
+
const store = overrides.sessionMetadataStore
|
|
27
|
+
?? createSessionMetadataStore({ dataDir: makeDataDir() });
|
|
28
|
+
const broadcastGlobalUiEvent = overrides.broadcastGlobalUiEvent ?? vi.fn();
|
|
29
|
+
const app = express();
|
|
30
|
+
registerSessionMetadataRoutes(app, {
|
|
31
|
+
sessionMetadataStore: store,
|
|
32
|
+
broadcastGlobalUiEvent,
|
|
33
|
+
onMetadataWritten: overrides.onMetadataWritten,
|
|
34
|
+
});
|
|
35
|
+
return { app, store, broadcastGlobalUiEvent };
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
describe('registerSessionMetadataRoutes', () => {
|
|
39
|
+
it('merge-patches metadata and broadcasts the full merged object', async () => {
|
|
40
|
+
const { app, store, broadcastGlobalUiEvent } = mount();
|
|
41
|
+
|
|
42
|
+
const first = await request(app)
|
|
43
|
+
.put('/api/openchamber/sessions/ses_1/metadata')
|
|
44
|
+
.send({ patch: { openchamber: { assist: { recap: 'a' } } } })
|
|
45
|
+
.expect(200);
|
|
46
|
+
expect(first.body.metadata).toEqual({ openchamber: { assist: { recap: 'a' } } });
|
|
47
|
+
|
|
48
|
+
const second = await request(app)
|
|
49
|
+
.put('/api/openchamber/sessions/ses_1/metadata')
|
|
50
|
+
.send({ patch: { openchamber: { goal: { status: 'active' } } }, directory: '/repo' })
|
|
51
|
+
.expect(200);
|
|
52
|
+
expect(second.body.metadata).toEqual({
|
|
53
|
+
openchamber: { assist: { recap: 'a' }, goal: { status: 'active' } },
|
|
54
|
+
});
|
|
55
|
+
await expect(store.get('ses_1')).resolves.toEqual(second.body.metadata);
|
|
56
|
+
expect(broadcastGlobalUiEvent).toHaveBeenLastCalledWith({
|
|
57
|
+
type: 'openchamber:session-metadata',
|
|
58
|
+
properties: { sessionID: 'ses_1', metadata: second.body.metadata },
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('notifies the metadata listener with the session directory', async () => {
|
|
63
|
+
const onMetadataWritten = vi.fn();
|
|
64
|
+
const { app } = mount({ onMetadataWritten });
|
|
65
|
+
|
|
66
|
+
await request(app)
|
|
67
|
+
.put('/api/openchamber/sessions/ses_2/metadata')
|
|
68
|
+
.send({ patch: { openchamber: { goal: { id: 'g1', status: 'active', objective: 'ship' } } }, directory: '/repo' })
|
|
69
|
+
.expect(200);
|
|
70
|
+
|
|
71
|
+
expect(onMetadataWritten).toHaveBeenCalledWith({
|
|
72
|
+
sessionID: 'ses_2',
|
|
73
|
+
directory: '/repo',
|
|
74
|
+
metadata: { openchamber: { goal: { id: 'g1', status: 'active', objective: 'ship' } } },
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('rejects a blank session id and a non-object patch with 400', async () => {
|
|
79
|
+
const { app } = mount();
|
|
80
|
+
await request(app)
|
|
81
|
+
.put('/api/openchamber/sessions/%20%20/metadata')
|
|
82
|
+
.send({ patch: { a: 1 } })
|
|
83
|
+
.expect(400);
|
|
84
|
+
await request(app)
|
|
85
|
+
.put('/api/openchamber/sessions/ses_1/metadata')
|
|
86
|
+
.send({})
|
|
87
|
+
.expect(400);
|
|
88
|
+
await request(app)
|
|
89
|
+
.put('/api/openchamber/sessions/ses_1/metadata')
|
|
90
|
+
.send({ patch: 'nope' })
|
|
91
|
+
.expect(400);
|
|
92
|
+
await request(app)
|
|
93
|
+
.put('/api/openchamber/sessions/ses_1/metadata')
|
|
94
|
+
.send({ patch: ['a'] })
|
|
95
|
+
.expect(400);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('does not answer a failed store load as empty success', async () => {
|
|
99
|
+
const store = {
|
|
100
|
+
setSessionMetadata: async () => {
|
|
101
|
+
throw new Error('session metadata is unavailable: its file could not be read');
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
const { app } = mount({ sessionMetadataStore: store });
|
|
105
|
+
const res = await request(app)
|
|
106
|
+
.put('/api/openchamber/sessions/ses_1/metadata')
|
|
107
|
+
.send({ patch: { a: 1 } })
|
|
108
|
+
.expect(503);
|
|
109
|
+
expect(res.body).toMatchObject({ error: expect.stringMatching(/could not be read/) });
|
|
110
|
+
});
|
|
111
|
+
});
|