@maudecode/talaria-web 1.18.0 → 1.19.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/_release.json +8 -8
- package/dist/api/auth-raw.js +16 -16
- package/dist/api/auth-raw.js.map +1 -1
- package/dist/api/automation-raw.d.ts +2 -2
- package/dist/api/automation-raw.js +32 -32
- package/dist/api/automation-raw.js.map +1 -1
- package/dist/api/automation-router.d.ts +102 -22
- package/dist/api/automation-router.js +15 -13
- package/dist/api/automation-router.js.map +1 -1
- package/dist/api/chat-router.d.ts +1205 -34
- package/dist/api/chat-router.js +158 -78
- package/dist/api/chat-router.js.map +1 -1
- package/dist/api/git-router.d.ts +2 -2
- package/dist/api/git-router.js +29 -29
- package/dist/api/git-router.js.map +1 -1
- package/dist/api/raw-routes.d.ts +3 -3
- package/dist/api/raw-routes.js +63 -61
- package/dist/api/raw-routes.js.map +1 -1
- package/dist/api/router.d.ts +6 -6
- package/dist/api/router.js +22 -22
- package/dist/api/router.js.map +1 -1
- package/dist/api/session-visibility.d.ts +4 -4
- package/dist/api/session-visibility.js +26 -16
- package/dist/api/session-visibility.js.map +1 -1
- package/dist/api/sessions-router.d.ts +1928 -124
- package/dist/api/sessions-router.js +177 -173
- package/dist/api/sessions-router.js.map +1 -1
- package/dist/api/settings-router.d.ts +427 -9
- package/dist/api/settings-router.js +48 -32
- package/dist/api/settings-router.js.map +1 -1
- package/dist/api/sse-routes.d.ts +2 -2
- package/dist/api/sse-routes.js +63 -63
- package/dist/api/sse-routes.js.map +1 -1
- package/dist/api/tools-raw.d.ts +5 -0
- package/dist/api/tools-raw.js +58 -33
- package/dist/api/tools-raw.js.map +1 -1
- package/dist/api/tools-router.d.ts +663 -91
- package/dist/api/tools-router.js +309 -52
- package/dist/api/tools-router.js.map +1 -1
- package/dist/api/transcribe-stream.d.ts +16 -0
- package/dist/api/transcribe-stream.js +172 -0
- package/dist/api/transcribe-stream.js.map +1 -0
- package/dist/app.d.ts +4 -1
- package/dist/app.js +66 -47
- package/dist/app.js.map +1 -1
- package/dist/auth/gate.js +10 -9
- package/dist/auth/gate.js.map +1 -1
- package/dist/auth/passkeys.d.ts +15 -14
- package/dist/auth/passkeys.js +89 -91
- package/dist/auth/passkeys.js.map +1 -1
- package/dist/auth/store.d.ts +17 -7
- package/dist/auth/store.js +93 -52
- package/dist/auth/store.js.map +1 -1
- package/dist/bin/talaria-web.js +12 -81
- package/dist/bin/talaria-web.js.map +1 -1
- package/dist/cli/ctl.js +2 -1
- package/dist/cli/ctl.js.map +1 -1
- package/dist/cli/dotenv.d.ts +5 -0
- package/dist/cli/dotenv.js +14 -2
- package/dist/cli/dotenv.js.map +1 -1
- package/dist/cli/launcher.js +5 -5
- package/dist/cli/launcher.js.map +1 -1
- package/dist/cli/main.d.ts +1 -0
- package/dist/cli/main.js +85 -0
- package/dist/cli/main.js.map +1 -0
- package/dist/cli/supervise.d.ts +2 -0
- package/dist/cli/supervise.js +67 -4
- package/dist/cli/supervise.js.map +1 -1
- package/dist/config/agent-config.d.ts +34 -11
- package/dist/config/agent-config.js +113 -58
- package/dist/config/agent-config.js.map +1 -1
- package/dist/config.d.ts +2 -0
- package/dist/config.js +14 -5
- package/dist/config.js.map +1 -1
- package/dist/fs/atomic.d.ts +1 -0
- package/dist/fs/atomic.js +4 -1
- package/dist/fs/atomic.js.map +1 -1
- package/dist/fs/stat.d.ts +1 -0
- package/dist/fs/stat.js +11 -0
- package/dist/fs/stat.js.map +1 -0
- package/dist/http/context.d.ts +14 -9
- package/dist/http/context.js +29 -20
- package/dist/http/context.js.map +1 -1
- package/dist/onboarding.d.ts +2 -2
- package/dist/onboarding.js +10 -8
- package/dist/onboarding.js.map +1 -1
- package/dist/profiles/profiles.d.ts +10 -4
- package/dist/profiles/profiles.js +77 -30
- package/dist/profiles/profiles.js.map +1 -1
- package/dist/projects.d.ts +15 -6
- package/dist/projects.js +55 -47
- package/dist/projects.js.map +1 -1
- package/dist/providers/catalog.d.ts +78 -12
- package/dist/providers/catalog.js +445 -141
- package/dist/providers/catalog.js.map +1 -1
- package/dist/providers/env-file.d.ts +10 -3
- package/dist/providers/env-file.js +24 -75
- package/dist/providers/env-file.js.map +1 -1
- package/dist/providers/runtime-env.d.ts +4 -2
- package/dist/providers/runtime-env.js +9 -6
- package/dist/providers/runtime-env.js.map +1 -1
- package/dist/providers/tables.d.ts +3 -1
- package/dist/providers/tables.js +4 -2
- package/dist/providers/tables.js.map +1 -1
- package/dist/redact.js +18 -9
- package/dist/redact.js.map +1 -1
- package/dist/release.d.ts +7 -0
- package/dist/release.js +14 -0
- package/dist/release.js.map +1 -1
- package/dist/runtime.d.ts +5 -1
- package/dist/runtime.js +233 -90
- package/dist/runtime.js.map +1 -1
- package/dist/server.js +10 -0
- package/dist/server.js.map +1 -1
- package/dist/sessions/anchor.d.ts +9 -3
- package/dist/sessions/anchor.js +28 -36
- package/dist/sessions/anchor.js.map +1 -1
- package/dist/sessions/background-events.d.ts +2 -0
- package/dist/sessions/background-events.js +10 -0
- package/dist/sessions/background-events.js.map +1 -0
- package/dist/sessions/background-tasks.d.ts +26 -20
- package/dist/sessions/background-tasks.js +88 -86
- package/dist/sessions/background-tasks.js.map +1 -1
- package/dist/sessions/background-updates.d.ts +15 -8
- package/dist/sessions/background-updates.js +101 -25
- package/dist/sessions/background-updates.js.map +1 -1
- package/dist/sessions/cli-sessions.d.ts +15 -5
- package/dist/sessions/cli-sessions.js +62 -43
- package/dist/sessions/cli-sessions.js.map +1 -1
- package/dist/sessions/completions.d.ts +30 -2
- package/dist/sessions/completions.js +294 -53
- package/dist/sessions/completions.js.map +1 -1
- package/dist/sessions/compress.d.ts +8 -0
- package/dist/sessions/compress.js +2 -2
- package/dist/sessions/compress.js.map +1 -1
- package/dist/sessions/drafts.d.ts +14 -10
- package/dist/sessions/drafts.js +38 -43
- package/dist/sessions/drafts.js.map +1 -1
- package/dist/sessions/gateway-sessions.d.ts +46 -0
- package/dist/sessions/gateway-sessions.js +378 -0
- package/dist/sessions/gateway-sessions.js.map +1 -0
- package/dist/sessions/goals.d.ts +31 -0
- package/dist/sessions/goals.js +75 -0
- package/dist/sessions/goals.js.map +1 -0
- package/dist/sessions/journal.d.ts +5 -79
- package/dist/sessions/journal.js +15 -606
- package/dist/sessions/journal.js.map +1 -1
- package/dist/sessions/list.d.ts +27 -18
- package/dist/sessions/list.js +133 -131
- package/dist/sessions/list.js.map +1 -1
- package/dist/sessions/merge.d.ts +68 -10
- package/dist/sessions/merge.js +259 -51
- package/dist/sessions/merge.js.map +1 -1
- package/dist/sessions/notices.d.ts +22 -0
- package/dist/sessions/notices.js +72 -0
- package/dist/sessions/notices.js.map +1 -0
- package/dist/sessions/pending.d.ts +10 -4
- package/dist/sessions/pending.js +83 -7
- package/dist/sessions/pending.js.map +1 -1
- package/dist/sessions/prefix-index.d.ts +82 -0
- package/dist/sessions/prefix-index.js +232 -0
- package/dist/sessions/prefix-index.js.map +1 -0
- package/dist/sessions/recovery.d.ts +4 -7
- package/dist/sessions/recovery.js +97 -281
- package/dist/sessions/recovery.js.map +1 -1
- package/dist/sessions/relay.d.ts +20 -20
- package/dist/sessions/relay.js +205 -127
- package/dist/sessions/relay.js.map +1 -1
- package/dist/sessions/service.d.ts +162 -59
- package/dist/sessions/service.js +1133 -521
- package/dist/sessions/service.js.map +1 -1
- package/dist/sessions/session.d.ts +18 -2
- package/dist/sessions/session.js +29 -5
- package/dist/sessions/session.js.map +1 -1
- package/dist/sessions/shares.d.ts +7 -6
- package/dist/sessions/shares.js +22 -45
- package/dist/sessions/shares.js.map +1 -1
- package/dist/sessions/source-kind.js +1 -0
- package/dist/sessions/source-kind.js.map +1 -1
- package/dist/sessions/state-db-identity.d.ts +18 -0
- package/dist/sessions/state-db-identity.js +67 -0
- package/dist/sessions/state-db-identity.js.map +1 -0
- package/dist/sessions/state-db-worker.js +3 -1
- package/dist/sessions/state-db-worker.js.map +1 -1
- package/dist/sessions/state-db.d.ts +53 -8
- package/dist/sessions/state-db.js +576 -50
- package/dist/sessions/state-db.js.map +1 -1
- package/dist/sessions/storage/backend.d.ts +162 -0
- package/dist/sessions/storage/backend.js +19 -0
- package/dist/sessions/storage/backend.js.map +1 -0
- package/dist/sessions/storage/conformance.d.ts +20 -0
- package/dist/sessions/storage/conformance.js +237 -0
- package/dist/sessions/storage/conformance.js.map +1 -0
- package/dist/sessions/storage/document.d.ts +16 -0
- package/dist/sessions/storage/document.js +44 -0
- package/dist/sessions/storage/document.js.map +1 -0
- package/dist/sessions/storage/json-documents.d.ts +23 -0
- package/dist/sessions/storage/json-documents.js +233 -0
- package/dist/sessions/storage/json-documents.js.map +1 -0
- package/dist/sessions/storage/json-files.d.ts +12 -0
- package/dist/sessions/storage/json-files.js +124 -0
- package/dist/sessions/storage/json-files.js.map +1 -0
- package/dist/sessions/storage/json-installation.d.ts +3 -0
- package/dist/sessions/storage/json-installation.js +31 -0
- package/dist/sessions/storage/json-installation.js.map +1 -0
- package/dist/sessions/storage/json-journal.d.ts +114 -0
- package/dist/sessions/storage/json-journal.js +686 -0
- package/dist/sessions/storage/json-journal.js.map +1 -0
- package/dist/sessions/storage/json-recovery.d.ts +4 -0
- package/dist/sessions/storage/json-recovery.js +195 -0
- package/dist/sessions/storage/json-recovery.js.map +1 -0
- package/dist/sessions/storage/json.d.ts +68 -0
- package/dist/sessions/storage/json.js +258 -0
- package/dist/sessions/storage/json.js.map +1 -0
- package/dist/sessions/storage/row.d.ts +6 -0
- package/dist/sessions/storage/row.js +49 -0
- package/dist/sessions/storage/row.js.map +1 -0
- package/dist/sessions/storage/search-text.d.ts +4 -0
- package/dist/sessions/storage/search-text.js +16 -0
- package/dist/sessions/storage/search-text.js.map +1 -0
- package/dist/sessions/store.d.ts +63 -73
- package/dist/sessions/store.js +305 -611
- package/dist/sessions/store.js.map +1 -1
- package/dist/sessions/streams.d.ts +4 -0
- package/dist/sessions/streams.js +9 -1
- package/dist/sessions/streams.js.map +1 -1
- package/dist/sessions/titles.js +1 -1
- package/dist/sessions/titles.js.map +1 -1
- package/dist/sessions/todo.d.ts +5 -0
- package/dist/sessions/todo.js +28 -21
- package/dist/sessions/todo.js.map +1 -1
- package/dist/sessions/tool-display.d.ts +5 -4
- package/dist/sessions/tool-display.js +22 -16
- package/dist/sessions/tool-display.js.map +1 -1
- package/dist/sessions/turn-context.d.ts +10 -1
- package/dist/sessions/turn-context.js +7 -4
- package/dist/sessions/turn-context.js.map +1 -1
- package/dist/sessions/turn.d.ts +87 -17
- package/dist/sessions/turn.js +795 -347
- package/dist/sessions/turn.js.map +1 -1
- package/dist/sessions/wakeup-pause.d.ts +8 -13
- package/dist/sessions/wakeup-pause.js +31 -49
- package/dist/sessions/wakeup-pause.js.map +1 -1
- package/dist/sessions/window.js +13 -12
- package/dist/sessions/window.js.map +1 -1
- package/dist/settings.d.ts +19 -7
- package/dist/settings.js +179 -183
- package/dist/settings.js.map +1 -1
- package/dist/sidecar/client.d.ts +17 -3
- package/dist/sidecar/client.js +78 -8
- package/dist/sidecar/client.js.map +1 -1
- package/dist/sidecar/discover.d.ts +2 -1
- package/dist/sidecar/discover.js +93 -10
- package/dist/sidecar/discover.js.map +1 -1
- package/dist/sidecar/env.d.ts +4 -0
- package/dist/sidecar/env.js +3 -0
- package/dist/sidecar/env.js.map +1 -0
- package/dist/sidecar/fake.js +19 -2
- package/dist/sidecar/fake.js.map +1 -1
- package/dist/test/env-file.d.ts +5 -0
- package/dist/test/env-file.js +88 -0
- package/dist/test/env-file.js.map +1 -0
- package/dist/test/harness.d.ts +1 -0
- package/dist/test/harness.js +1 -1
- package/dist/test/harness.js.map +1 -1
- package/dist/test/passkeys.d.ts +22 -0
- package/dist/test/passkeys.js +62 -0
- package/dist/test/passkeys.js.map +1 -0
- package/dist/test/storage.d.ts +15 -0
- package/dist/test/storage.js +26 -0
- package/dist/test/storage.js.map +1 -0
- package/dist/tools/commands.d.ts +6 -1
- package/dist/tools/commands.js +8 -2
- package/dist/tools/commands.js.map +1 -1
- package/dist/tools/connectors.d.ts +33 -0
- package/dist/tools/connectors.js +121 -0
- package/dist/tools/connectors.js.map +1 -0
- package/dist/tools/crons.d.ts +38 -1
- package/dist/tools/crons.js +82 -15
- package/dist/tools/crons.js.map +1 -1
- package/dist/tools/dashboard-plugins.d.ts +5 -5
- package/dist/tools/dashboard-plugins.js +14 -14
- package/dist/tools/dashboard-plugins.js.map +1 -1
- package/dist/tools/extensions.d.ts +5 -4
- package/dist/tools/extensions.js +213 -207
- package/dist/tools/extensions.js.map +1 -1
- package/dist/tools/gateway-restart.d.ts +62 -0
- package/dist/tools/gateway-restart.js +142 -0
- package/dist/tools/gateway-restart.js.map +1 -0
- package/dist/tools/health.d.ts +15 -3
- package/dist/tools/health.js +97 -34
- package/dist/tools/health.js.map +1 -1
- package/dist/tools/hygiene.d.ts +4 -3
- package/dist/tools/hygiene.js +23 -9
- package/dist/tools/hygiene.js.map +1 -1
- package/dist/tools/kanban.d.ts +4 -2
- package/dist/tools/kanban.js +28 -19
- package/dist/tools/kanban.js.map +1 -1
- package/dist/tools/memory-masks.d.ts +5 -0
- package/dist/tools/memory-masks.js +256 -0
- package/dist/tools/memory-masks.js.map +1 -0
- package/dist/tools/memory.d.ts +29 -3
- package/dist/tools/memory.js +69 -27
- package/dist/tools/memory.js.map +1 -1
- package/dist/tools/plugin-settings.d.ts +15 -0
- package/dist/tools/plugin-settings.js +90 -0
- package/dist/tools/plugin-settings.js.map +1 -0
- package/dist/tools/skills.d.ts +9 -4
- package/dist/tools/skills.js +44 -13
- package/dist/tools/skills.js.map +1 -1
- package/dist/tools/update-notifications.d.ts +34 -15
- package/dist/tools/update-notifications.js +141 -57
- package/dist/tools/update-notifications.js.map +1 -1
- package/dist/tools/update-rollback.d.ts +11 -0
- package/dist/tools/update-rollback.js +28 -0
- package/dist/tools/update-rollback.js.map +1 -0
- package/dist/tools/updates.d.ts +93 -33
- package/dist/tools/updates.js +1051 -345
- package/dist/tools/updates.js.map +1 -1
- package/dist/util.d.ts +2 -0
- package/dist/util.js +5 -0
- package/dist/util.js.map +1 -1
- package/dist/workspace/git.d.ts +16 -1
- package/dist/workspace/git.js +291 -11
- package/dist/workspace/git.js.map +1 -1
- package/dist/workspace/media.d.ts +12 -9
- package/dist/workspace/media.js +31 -20
- package/dist/workspace/media.js.map +1 -1
- package/dist/workspace/rollback.d.ts +2 -2
- package/dist/workspace/rollback.js +5 -5
- package/dist/workspace/rollback.js.map +1 -1
- package/dist/workspace/workspaces.d.ts +15 -19
- package/dist/workspace/workspaces.js +55 -127
- package/dist/workspace/workspaces.js.map +1 -1
- package/dist/workspace/worktrees.js +170 -32
- package/dist/workspace/worktrees.js.map +1 -1
- package/package.json +5 -3
- package/sidecar/agent_dependency.json +4 -4
- package/sidecar/agent_support.json +26 -0
- package/sidecar/scripts/record-fixtures.py +12 -3
- package/sidecar/scripts/replay_sidecar.py +8 -0
- package/sidecar/scripts/suite_runner.py +302 -0
- package/sidecar/scripts/test.sh +12 -4
- package/sidecar/talaria_sidecar/__init__.py +6 -1
- package/sidecar/talaria_sidecar/__main__.py +4 -2
- package/sidecar/talaria_sidecar/agent_mutation.py +975 -0
- package/sidecar/talaria_sidecar/agent_services.py +1053 -0
- package/sidecar/talaria_sidecar/dependency_sync.py +85 -0
- package/sidecar/talaria_sidecar/home.py +43 -1
- package/sidecar/talaria_sidecar/methods/__init__.py +2 -2
- package/sidecar/talaria_sidecar/methods/aux.py +139 -31
- package/sidecar/talaria_sidecar/methods/chat.py +847 -134
- package/sidecar/talaria_sidecar/methods/commands.py +213 -4
- package/sidecar/talaria_sidecar/methods/config.py +123 -4
- package/sidecar/talaria_sidecar/methods/connectors.py +343 -0
- package/sidecar/talaria_sidecar/methods/cron.py +366 -34
- package/sidecar/talaria_sidecar/methods/cron_execution.py +284 -0
- package/sidecar/talaria_sidecar/methods/gateway.py +194 -54
- package/sidecar/talaria_sidecar/methods/gateway_sessions.py +234 -0
- package/sidecar/talaria_sidecar/methods/goals.py +81 -41
- package/sidecar/talaria_sidecar/methods/kanban.py +80 -27
- package/sidecar/talaria_sidecar/methods/memory.py +84 -0
- package/sidecar/talaria_sidecar/methods/oauth.py +5 -2
- package/sidecar/talaria_sidecar/methods/process.py +160 -11
- package/sidecar/talaria_sidecar/methods/profiles.py +44 -6
- package/sidecar/talaria_sidecar/methods/providers.py +196 -26
- package/sidecar/talaria_sidecar/methods/runtime.py +69 -1
- package/sidecar/talaria_sidecar/methods/skills.py +67 -4
- package/sidecar/talaria_sidecar/methods/state_db.py +277 -70
- package/sidecar/talaria_sidecar/methods/stt.py +159 -78
- package/sidecar/talaria_sidecar/methods/text.py +14 -1
- package/sidecar/talaria_sidecar/methods/toolsets.py +49 -0
- package/sidecar/talaria_sidecar/methods/usage.py +113 -14
- package/sidecar/talaria_sidecar/methods/worktree.py +26 -19
- package/sidecar/talaria_sidecar/methods/write_approval.py +181 -0
- package/sidecar/talaria_sidecar/rpc.py +45 -4
- package/sidecar/talaria_sidecar/runtime.py +81 -12
- package/sidecar/talaria_sidecar/speed.py +35 -0
- package/static/dist/FILES.txt +126 -127
- package/static/dist/assets/AppShell-cey33v9s.js +8 -0
- package/static/dist/assets/ChatPage-i8wnugr7.js +18 -0
- package/static/dist/assets/ConnectTargets-hg19553x.js +1 -0
- package/static/dist/assets/ExtensionRoute-cly4apm5.js +1 -0
- package/static/dist/assets/Field-j8uqoigz.js +9 -0
- package/static/dist/assets/HubRoute-e4bgz41j.js +2 -0
- package/static/dist/assets/Markdown-lahuichm.js +598 -0
- package/static/dist/assets/RightPanel-lbo4i0fn.js +1 -0
- package/static/dist/assets/Select-gqatm2e0.js +1 -0
- package/static/dist/assets/SessionListPanel-mfqayfjh.js +1 -0
- package/static/dist/assets/SettingsLayout-b8980u7k.js +1 -0
- package/static/dist/assets/States-e7dkgtxf.js +1 -0
- package/static/dist/assets/Toaster-bfqqy786.js +1 -0
- package/static/dist/assets/_app-n1n6pfx1.js +1 -0
- package/static/dist/assets/{_app.ext._extensionId-gfbzpoou.js → _app.ext._extensionId-buxjxav3.js} +1 -1
- package/static/dist/assets/_app.index-jt8zwiu2.js +1 -0
- package/static/dist/assets/_app.insights-cv76ml71.js +1 -0
- package/static/dist/assets/_app.kanban-h6ts53j7.js +1 -0
- package/static/dist/assets/_app.logs-dm873z90.js +1 -0
- package/static/dist/assets/_app.memory-glk5y6m8.js +1 -0
- package/static/dist/assets/_app.profiles-ka6zpstq.js +1 -0
- package/static/dist/assets/{_app.session._sessionId-fn1bhde6.js → _app.session._sessionId-g2vzghvc.js} +1 -1
- package/static/dist/assets/_app.settings-hgjdhzcd.js +1 -0
- package/static/dist/assets/_app.settings._section-jf2kmhae.js +3 -0
- package/static/dist/assets/_app.skills-cii852yq.js +1 -0
- package/static/dist/assets/_app.tasks-fp9q7atm.js +1 -0
- package/static/dist/assets/_app.todos-dw94qzue.js +1 -0
- package/static/dist/assets/_app.workspaces-inyygzze.js +1 -0
- package/static/dist/assets/{abnfDiagram-VCTEODGH-c8kdixke.js → abnfDiagram-VCTEODGH-gjayq3ra.js} +1 -1
- package/static/dist/assets/architecture-7GRP2DOG-lp56972r.js +1 -0
- package/static/dist/assets/{architectureDiagram-5GKGNRK7-cku06x1p.js → architectureDiagram-5GKGNRK7-nwb3xovn.js} +1 -1
- package/static/dist/assets/archive-b1j004vn.js +1 -0
- package/static/dist/assets/back-g74gkozu.js +1 -0
- package/static/dist/assets/{blockDiagram-I7D4REHJ-jzthp1wa.js → blockDiagram-I7D4REHJ-joedghi3.js} +1 -1
- package/static/dist/assets/{c4Diagram-7LVT6UL2-mwr67mt8.js → c4Diagram-7LVT6UL2-hyrjrohe.js} +1 -1
- package/static/dist/assets/channel-es95o4y8.js +1 -0
- package/static/dist/assets/{chunk-4HAMMTFA-gpq1y228.js → chunk-4HAMMTFA-koouqbut.js} +1 -1
- package/static/dist/assets/{chunk-75Z2AOVW-ktje58c4.js → chunk-75Z2AOVW-bl4bhdnt.js} +1 -1
- package/static/dist/assets/{chunk-DU6HZSFF-ofajdixh.js → chunk-DU6HZSFF-gpedttsp.js} +3 -3
- package/static/dist/assets/{chunk-F27PBJKO-cmev85ti.js → chunk-F27PBJKO-d8xxi0zi.js} +1 -1
- package/static/dist/assets/{chunk-GMAD6QVW-htjgt22y.js → chunk-GMAD6QVW-jro0hl73.js} +1 -1
- package/static/dist/assets/{chunk-GVQU2GXP-qndvoxl7.js → chunk-GVQU2GXP-djn6gbyu.js} +1 -1
- package/static/dist/assets/{chunk-IMKFNOWR-e5bkd5q6.js → chunk-IMKFNOWR-vw4p5p7o.js} +1 -1
- package/static/dist/assets/{chunk-L3NEJ4N5-mj1155r6.js → chunk-L3NEJ4N5-n1hj77q8.js} +1 -1
- package/static/dist/assets/{chunk-OSK3NFVY-e7gkikcw.js → chunk-OSK3NFVY-oy0gwn2n.js} +1 -1
- package/static/dist/assets/{chunk-P2QGCYS3-f7t54xfm.js → chunk-P2QGCYS3-onf1hz6c.js} +1 -1
- package/static/dist/assets/{chunk-POPQ4Y6H-i927v9oa.js → chunk-POPQ4Y6H-m8sqn7cs.js} +1 -1
- package/static/dist/assets/{chunk-PWAF6VOD-m84lmnyt.js → chunk-PWAF6VOD-ek4smc8x.js} +1 -1
- package/static/dist/assets/{chunk-SHT3W25Y-mi0tlcu9.js → chunk-SHT3W25Y-g9gfvefo.js} +3 -3
- package/static/dist/assets/{chunk-SVP7TREG-dnau702j.js → chunk-SVP7TREG-f9nfv2cu.js} +1 -1
- package/static/dist/assets/{chunk-TICWLB2K-edem1gkg.js → chunk-TICWLB2K-bnk3m1mq.js} +1 -1
- package/static/dist/assets/classDiagram-ZZMXUADV-kmihoqxh.js +1 -0
- package/static/dist/assets/classDiagram-v2-VYDZK3BY-kmihoqxh.js +1 -0
- package/static/dist/assets/copied-bli9tbcx.js +1 -0
- package/static/dist/assets/{copy-magtofyp.js → copy-jamtd1pf.js} +1 -1
- package/static/dist/assets/{cynefin-OW5HDTMX-siqlulkl.js → cynefin-OW5HDTMX-f8xfdd3f.js} +1 -1
- package/static/dist/assets/{cynefinDiagram-5FMLGOSQ-i648tah9.js → cynefinDiagram-5FMLGOSQ-n9lb99h9.js} +1 -1
- package/static/dist/assets/{dagre-GXQ25YYZ-mwn6uc17.js → dagre-GXQ25YYZ-dgailddi.js} +4 -4
- package/static/dist/assets/{diagram-S7CK7UJ4-l8abxaes.js → diagram-S7CK7UJ4-m6aeoe7c.js} +1 -1
- package/static/dist/assets/{diagram-UQ7AKVKN-mekq7zgq.js → diagram-UQ7AKVKN-jall3j0x.js} +1 -1
- package/static/dist/assets/{diagram-VSXAHHWV-fnky6lvc.js → diagram-VSXAHHWV-hbawi7hn.js} +1 -1
- package/static/dist/assets/{diagram-VX7I27RA-xe6r5qfw.js → diagram-VX7I27RA-hendl2tt.js} +1 -1
- package/static/dist/assets/{diagram-Z3DM3KII-nq07dg7k.js → diagram-Z3DM3KII-igwc35p3.js} +1 -1
- package/static/dist/assets/{ebnfDiagram-PWID7BFC-c62ipm3g.js → ebnfDiagram-PWID7BFC-c64knz08.js} +1 -1
- package/static/dist/assets/{erDiagram-RLTQ6QDP-h39kmnzy.js → erDiagram-RLTQ6QDP-brdu9lcm.js} +1 -1
- package/static/dist/assets/eventmodeling-NTZA5JFV-njk1j8wl.js +1 -0
- package/static/dist/assets/flowDiagram-HODETNUW-nma6f0ga.js +1 -0
- package/static/dist/assets/folder-plus-lvt9kudb.js +1 -0
- package/static/dist/assets/{ganttDiagram-EL5Y4UJY-ngbsyp37.js → ganttDiagram-EL5Y4UJY-ma6jxj5i.js} +1 -1
- package/static/dist/assets/{gitGraph-4MIJSDKK-fr2i7caa.js → gitGraph-4MIJSDKK-naa9w6xa.js} +1 -1
- package/static/dist/assets/{gitGraphDiagram-WWUBYQGX-eravdti4.js → gitGraphDiagram-WWUBYQGX-hzltvsod.js} +1 -1
- package/static/dist/assets/{highlighted-body-KPVGNVTW-jwpop9p8.js → highlighted-body-KPVGNVTW-ghoavj8d.js} +1 -1
- package/static/dist/assets/host-hzm8n85n.js +1 -0
- package/static/dist/assets/index-drdcaqeq.css +2 -0
- package/static/dist/assets/index-jzm844he.js +11 -0
- package/static/dist/assets/{info-A6RAGUB7-h11x0dxo.js → info-A6RAGUB7-mn2ut5ko.js} +1 -1
- package/static/dist/assets/{infoDiagram-27XIBGKW-dfh47dw9.js → infoDiagram-27XIBGKW-h6kuqvsb.js} +1 -1
- package/static/dist/assets/{ishikawaDiagram-5VMMS53U-i6ypq351.js → ishikawaDiagram-5VMMS53U-e7bnwq7h.js} +1 -1
- package/static/dist/assets/{journeyDiagram-3NMN7TZE-lduf1z95.js → journeyDiagram-3NMN7TZE-etd1bi6p.js} +1 -1
- package/static/dist/assets/{kanban-definition-UXKFOSKX-e18bu4pg.js → kanban-definition-UXKFOSKX-emb1g9dz.js} +4 -4
- package/static/dist/assets/lazyRouteComponent-o816ytzl.js +1 -0
- package/static/dist/assets/{line-meizqsvn.js → line-h0ojzsvs.js} +1 -1
- package/static/dist/assets/link-k01ryaf9.js +1 -0
- package/static/dist/assets/login-bsae49ew.js +1 -0
- package/static/dist/assets/mermaid-HWGCJPDP-ihl34s74.js +1 -0
- package/static/dist/assets/{mermaid-parser.core-ntne45i9.js → mermaid-parser.core-bnre27u8.js} +3 -3
- package/static/dist/assets/{mindmap-definition-YA3MSWOX-27b26eok.js → mindmap-definition-YA3MSWOX-o1vathns.js} +1 -1
- package/static/dist/assets/onboarding-c65tysya.js +1 -0
- package/static/dist/assets/onboarding_model_label-gpn28uix.js +1 -0
- package/static/dist/assets/{packet-AYTQ26CC-l6jdka8p.js → packet-AYTQ26CC-hatq7m2e.js} +1 -1
- package/static/dist/assets/pcmTap.worklet-74EKjWAZ.js +1 -0
- package/static/dist/assets/{pegDiagram-XKGWAZYB-g1er3bxj.js → pegDiagram-XKGWAZYB-gwisz8jv.js} +1 -1
- package/static/dist/assets/{pie-WAS4IAKB-i0ylcrt9.js → pie-WAS4IAKB-fpftf0mk.js} +1 -1
- package/static/dist/assets/{pieDiagram-E7YTZNPT-fbz1e55z.js → pieDiagram-E7YTZNPT-khm1o9a6.js} +1 -1
- package/static/dist/assets/{quadrantDiagram-AXDQQJYC-hhue7d8o.js → quadrantDiagram-AXDQQJYC-owqc4uqn.js} +1 -1
- package/static/dist/assets/{radar-RG4KPBEZ-f9b70aa4.js → radar-RG4KPBEZ-cntfwjpq.js} +1 -1
- package/static/dist/assets/{railroad-74A4TZTK-lgsuoeda.js → railroad-74A4TZTK-a7t5eibp.js} +1 -1
- package/static/dist/assets/railroad-abnf-HS5TGJTU-ol8cdktf.js +1 -0
- package/static/dist/assets/railroad-ebnf-LZEXJU2U-i8vzd429.js +1 -0
- package/static/dist/assets/railroad-peg-WCYAUIDC-nw3ulewz.js +1 -0
- package/static/dist/assets/{railroadDiagram-O6MQD6OU-eqpy9fnz.js → railroadDiagram-O6MQD6OU-etuh0qa0.js} +1 -1
- package/static/dist/assets/{refresh-cw-hlwb02qv.js → refresh-cw-mqbkv2a3.js} +1 -1
- package/static/dist/assets/refresh-l29ljp76.js +1 -0
- package/static/dist/assets/{requirementDiagram-BXWQKSXE-ogf6puhi.js → requirementDiagram-BXWQKSXE-oq2ulk22.js} +1 -1
- package/static/dist/assets/{sankeyDiagram-P5KCCOFB-fqb8xcjf.js → sankeyDiagram-P5KCCOFB-iqc02emv.js} +1 -1
- package/static/dist/assets/saved-ndd4t2xb.js +1 -0
- package/static/dist/assets/{sequenceDiagram-WJ2MYXX4-dlcz6qjz.js → sequenceDiagram-WJ2MYXX4-i0v116q0.js} +2 -2
- package/static/dist/assets/settings_section_extensions_title-jhfo6o8z.js +1 -0
- package/static/dist/assets/share._token-c1ri0hy1.js +2 -0
- package/static/dist/assets/sliders-horizontal-j3jjt9tv.js +1 -0
- package/static/dist/assets/{stateDiagram-D77RDMKH-fyoznpcs.js → stateDiagram-D77RDMKH-dnpt1ato.js} +1 -1
- package/static/dist/assets/stateDiagram-v2-MP3YSRHH-bxjacpk6.js +1 -0
- package/static/dist/assets/{swimlanes-42K2YHIH-oefldhfy.js → swimlanes-42K2YHIH-loqbdazb.js} +1 -1
- package/static/dist/assets/swimlanesDiagram-VR7AAH4N-id62p9hz.js +8 -0
- package/static/dist/assets/{timeline-definition-24CTP7MA-b1vl2zil.js → timeline-definition-24CTP7MA-ol2hnvma.js} +1 -1
- package/static/dist/assets/toast-i3h34v6o.js +64 -0
- package/static/dist/assets/{treeView-Q6P3EWNA-f7hcci5o.js → treeView-Q6P3EWNA-idrj6iox.js} +1 -1
- package/static/dist/assets/{treemap-WGGIJYW6-nl85071a.js → treemap-WGGIJYW6-lzr49vv5.js} +1 -1
- package/static/dist/assets/untitled-oaoya4qj.js +1 -0
- package/static/dist/assets/useForm-nfaxgmsk.js +1 -0
- package/static/dist/assets/{useParams-b4dq6n4q.js → useParams-gjc70qru.js} +1 -1
- package/static/dist/assets/{useSearch-ipfmv3he.js → useSearch-e6ae539u.js} +1 -1
- package/static/dist/assets/{vennDiagram-4TSXK5OY-h5hzbk0n.js → vennDiagram-4TSXK5OY-bvwr9o2z.js} +1 -1
- package/static/dist/assets/{wardley-WFR3VGLG-jftvpm3e.js → wardley-WFR3VGLG-31ui8tma.js} +1 -1
- package/static/dist/assets/{wardleyDiagram-VM6X3IG4-hedh85ge.js → wardleyDiagram-VM6X3IG4-k5wqtx4b.js} +1 -1
- package/static/dist/assets/{xychartDiagram-S5SC5T6Z-e49523ab.js → xychartDiagram-S5SC5T6Z-aqbgvujg.js} +1 -1
- package/static/dist/index.html +1 -1
- package/static/dist/sw.js +1 -1
- package/static/dist/assets/AppShell-g3bkgnvt.js +0 -1
- package/static/dist/assets/ChatPage-oo7bdan6.js +0 -25
- package/static/dist/assets/Dialog-g355qxhp.js +0 -1
- package/static/dist/assets/ExtensionRoute-i2y9fvjm.js +0 -1
- package/static/dist/assets/HubRoute-lmkrbw47.js +0 -2
- package/static/dist/assets/Markdown-j4kut4j2.js +0 -598
- package/static/dist/assets/RightPanel-ohlsdzfl.js +0 -1
- package/static/dist/assets/Select-kddq75qc.js +0 -1
- package/static/dist/assets/SessionListPanel-lu3oy0hw.js +0 -1
- package/static/dist/assets/SettingsLayout-o9bv4mgb.js +0 -1
- package/static/dist/assets/States-eq6fe29m.js +0 -1
- package/static/dist/assets/Toaster-pc247u9f.js +0 -1
- package/static/dist/assets/_app-f8n1i0m1.js +0 -1
- package/static/dist/assets/_app.index-bzzv80hk.js +0 -1
- package/static/dist/assets/_app.insights-dzixbryi.js +0 -1
- package/static/dist/assets/_app.kanban-c6hep2us.js +0 -1
- package/static/dist/assets/_app.logs-i69qds98.js +0 -1
- package/static/dist/assets/_app.memory-kl4vf0gd.js +0 -1
- package/static/dist/assets/_app.profiles-b8zinmyp.js +0 -1
- package/static/dist/assets/_app.settings-cg8plwmf.js +0 -1
- package/static/dist/assets/_app.settings._section-bkwbi9zn.js +0 -3
- package/static/dist/assets/_app.skills-cs47sbvs.js +0 -1
- package/static/dist/assets/_app.tasks-dwvk64tc.js +0 -1
- package/static/dist/assets/_app.todos-c8xjm8p3.js +0 -1
- package/static/dist/assets/_app.workspaces-ehvd1gds.js +0 -1
- package/static/dist/assets/architecture-7GRP2DOG-6crvnylg.js +0 -1
- package/static/dist/assets/back-h7aun7ew.js +0 -1
- package/static/dist/assets/cancel-fhi0vcnu.js +0 -1
- package/static/dist/assets/channel-b2zo73z1.js +0 -1
- package/static/dist/assets/classDiagram-ZZMXUADV-oe99st8x.js +0 -1
- package/static/dist/assets/classDiagram-v2-VYDZK3BY-oe99st8x.js +0 -1
- package/static/dist/assets/copied-hcplv46p.js +0 -1
- package/static/dist/assets/endpoints-klbxod4t.js +0 -64
- package/static/dist/assets/eventmodeling-NTZA5JFV-obfede4u.js +0 -1
- package/static/dist/assets/flowDiagram-HODETNUW-kdpsp1je.js +0 -1
- package/static/dist/assets/host-i828xuo7.js +0 -1
- package/static/dist/assets/index-eh8zilnv.css +0 -2
- package/static/dist/assets/index-r648f4o1.js +0 -11
- package/static/dist/assets/lazyRouteComponent-e1y4t2i9.js +0 -1
- package/static/dist/assets/link-oot2yn73.js +0 -1
- package/static/dist/assets/login-gu28l9ou.js +0 -1
- package/static/dist/assets/mermaid-HWGCJPDP-mtwrj8fn.js +0 -1
- package/static/dist/assets/onboarding-lkphzu1s.js +0 -1
- package/static/dist/assets/onboarding_model_label-ftpjdwqk.js +0 -1
- package/static/dist/assets/railroad-abnf-HS5TGJTU-nf9d6pdx.js +0 -1
- package/static/dist/assets/railroad-ebnf-LZEXJU2U-f7ummkt5.js +0 -1
- package/static/dist/assets/railroad-peg-WCYAUIDC-oczq5tqs.js +0 -1
- package/static/dist/assets/refresh-abtu93ke.js +0 -1
- package/static/dist/assets/saved-girnabkf.js +0 -1
- package/static/dist/assets/settings_section_extensions_title-h14cwk6f.js +0 -1
- package/static/dist/assets/share._token-kl0r7ido.js +0 -2
- package/static/dist/assets/sliders-horizontal-n1zm34qq.js +0 -1
- package/static/dist/assets/sse-iwku0sjo.js +0 -1
- package/static/dist/assets/stateDiagram-v2-MP3YSRHH-ixhx8r46.js +0 -1
- package/static/dist/assets/swimlanesDiagram-VR7AAH4N-h9ersk2e.js +0 -8
- package/static/dist/assets/todoStore-jjr5yr78.js +0 -1
- package/static/dist/assets/units-kgxzxfhi.js +0 -1
- package/static/dist/assets/untitled-cikfjntn.js +0 -1
- package/static/dist/assets/useForm-kqoqg0k9.js +0 -1
- package/static/dist/assets/useLocale-o59tbiea.js +0 -1
package/dist/sessions/service.js
CHANGED
|
@@ -1,28 +1,32 @@
|
|
|
1
|
+
import { filterAsync } from '../util.js';
|
|
2
|
+
import { statSignature } from '../fs/stat.js';
|
|
3
|
+
import { SessionPrefixIndex } from './prefix-index.js';
|
|
1
4
|
import { str } from '../util.js';
|
|
2
|
-
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { createHmac, randomUUID } from 'node:crypto';
|
|
3
6
|
import { rmSync } from 'node:fs';
|
|
4
7
|
import { buildActiveTurnToken, copyJson, redactSessionData, redactValue, stripPublicInternalFields, withSceneToolDisplay } from '../redact.js';
|
|
5
8
|
import { DraftVersionConflict, normalizeDraftVersion } from './drafts.js';
|
|
6
|
-
import { allSessions, buildSessionListPayload, CLI_IDENTITY_FIELDS, isClaimableCliSource, isMessagingSessionRecord, ownerLockCandidates, withOwnerLocks, withSessionWireFlags, lineageRootId, mergeCliSidebarMetadata, sessionListResponse, sessionSearchMatches,
|
|
9
|
+
import { allSessions, buildSessionListPayload, reconcileLocalSessionRows, sessionLineageIds, CLI_IDENTITY_FIELDS, isClaimableCliSource, isMessagingSessionRecord, ownerLockCandidates, withOwnerLocks, withSessionWireFlags, lineageRootId, mergeCliSidebarMetadata, sessionListResponse, sessionSearchMatches, sessionSearchPreview, sessionSearchTerms } from './list.js';
|
|
7
10
|
import { anchorSceneIntOrNull, fullToolResult, hydrateAnchorActivityScenes, normalizeAnchorSceneMessageRef, readAnchorSceneRows, storeAnchorScene, withTurnIds } from './anchor.js';
|
|
8
|
-
import { COMPRESSION_RECOVERY_ACTION_START_FOCUSED, compressionRecoveryPayload, isSafeSessionId, lastMessageTimestamp, Session, sharePath, stripAttachedFilesMarker, titleFrom } from './session.js';
|
|
9
|
-
import { SessionBusy, SessionNotFound
|
|
10
|
-
import { UNSETTLED_TODO_KEY, attachTodoState } from './todo.js';
|
|
11
|
+
import { contextUsage, COMPRESSION_RECOVERY_ACTION_START_FOCUSED, compressionRecoveryPayload, forgetContextFigures, isSafeSessionId, lastMessageTimestamp, Session, sharePath, stripAttachedFilesMarker, titleFrom } from './session.js';
|
|
12
|
+
import { SessionBusy, SessionNotFound } from './store.js';
|
|
13
|
+
import { UNSETTLED_TODO_KEY, attachTodoSnapshot, attachTodoState } from './todo.js';
|
|
11
14
|
import { isClaudeCodeSessionId } from './claude-code.js';
|
|
12
15
|
import { auditSessionRecovery, repairSafeSessionRecovery } from './recovery.js';
|
|
13
|
-
import { agentSessionRowsExisting, agentSessionRowsExistingAsync, CONVERSATION_ROUND_THRESHOLD, countConversationRounds, stateDbCompressionLineage, stateDbTailToolContent, stateDbLineageReport, stateDbSessionMessages, stateDbSessionRead, stateDbSessionRow, stateDbSessionSources, stateDbSessionSourcesAsync, stateDbTimestampSeconds } from './state-db.js';
|
|
16
|
+
import { agentSessionRowsExisting, agentSessionRowsExistingAsync, CANONICAL_READ_ONLY_REASON, OWNERSHIP_UNAVAILABLE_REASON, CONVERSATION_ROUND_THRESHOLD, countConversationRounds, stateDbCompressionLineage, stateDbTailToolContent, stateDbLineageReport, stateDbLocalTarget, stateDbSessionMessages, stateDbSessionRead, stateDbSessionTailRead, stateDbSessionRow, stateDbSessionSources, stateDbSessionSourcesAsync, stateDbTimestampSeconds } from './state-db.js';
|
|
14
17
|
import { completionIncomplete, fallbackHandoffSummary, HANDOFF_SYSTEM_PROMPT, handoffMarker, handoffPayload, handoffTranscript, messageHandoffPayload, sameHandoff } from './handoff.js';
|
|
15
18
|
import { anchorMessageKey, anchorSummary, CompressionJobs, compressionReference, visibleMessagesForAnchor } from './compress.js';
|
|
16
|
-
import { SidecarError } from '../sidecar/client.js';
|
|
17
|
-
import { agentSteerText, attachedFilesPrompt, attachmentObjects, checkpointTurnStart, dedupeContext, isContextCompressionMarker, journalOutputRows, looksLikeCurrentUserTurn, stoppedTurnContext, mergeSessionMessagesAppendOnly, messageIdentity, pendingUserRow, reasoningFieldsText, sanitizeMessagesForApi, stateDbSeenId, stripWorkspacePrefix, withAttachmentObjects, withBodyExcerpts, withDisplayMedia, withMarkerKinds, withSceneRowMedia, withPendingUserTurn, withToolCallOutcomes, withoutRunningTurnOutput } from './merge.js';
|
|
19
|
+
import { chatReadiness, runtimeFailureDetails, SidecarError } from '../sidecar/client.js';
|
|
20
|
+
import { agentSteerText, ApiMessageSummary, appendStateDbIdentityRows, attachedFilesPrompt, attachmentObjects, checkpointTurnStart, dedupeContext, isContextCompressionMarker, journalOutputRows, looksLikeCurrentUserTurn, stoppedTurnContext, mergeSessionMessagesAppendOnly, messageIdentity, pendingUserRow, reasoningFieldsText, sanitizeMessagesForApi, stateDbSeenId, stripWorkspacePrefix, withAttachmentObjects, newestSettledReply, withBodyExcerpts, withDisplayMedia, withMarkerKinds, withMirrorSources, withSceneRowMedia, withPendingUserTurn, withToolCallOutcomes, withoutRunningTurnOutput } from './merge.js';
|
|
18
21
|
import { withBackgroundUpdates } from './background-updates.js';
|
|
22
|
+
import { markedStateDbGeneration, stateDbIdentityKeys, stampStateDbGeneration, stateDbMessageRepresented } from './state-db-identity.js';
|
|
19
23
|
import { sourceKind } from './source-kind.js';
|
|
20
24
|
import { withBackgroundLinks } from './background-tasks.js';
|
|
21
|
-
import { messagesForLimitedPayload, messageWindowForDisplay, MAX_MSG_LIMIT, parseMsgLimit, toolCallsForMessageWindow } from './window.js';
|
|
25
|
+
import { messagesForLimitedPayload, messageCountsAsRenderable, messageWindowForDisplay, MAX_MSG_LIMIT, parseMsgLimit, toolCallsForMessageWindow } from './window.js';
|
|
22
26
|
import { redactText } from '../redact.js';
|
|
23
27
|
import { workspaceDisplayName } from '../workspace/workspaces.js';
|
|
24
28
|
import { buildShareSnapshot } from './shares.js';
|
|
25
|
-
import {
|
|
29
|
+
import { mediaAnchorRootResolved, resolveMediaAccess, mediaRefPath, mediaTarget } from '../workspace/media.js';
|
|
26
30
|
import { projectMediaRefs } from '../workspace/media-refs.js';
|
|
27
31
|
import { loadGatewaySessionIdentityMap } from './list.js';
|
|
28
32
|
import { join } from 'node:path';
|
|
@@ -51,8 +55,31 @@ export async function ensureAgentRuntimeCurrent(sidecar) {
|
|
|
51
55
|
throw staleRuntimeFailure(error);
|
|
52
56
|
}
|
|
53
57
|
}
|
|
58
|
+
/** The health probe is also the browser's server-reachability probe (30 s), so the readiness check answers well inside it. */
|
|
59
|
+
const CHAT_READINESS_CHECK_TIMEOUT_MS = 5_000;
|
|
60
|
+
/**
|
|
61
|
+
* TAL-951: whether a chat turn would be admitted now. Ready and source-stale incompatible sidecars both run turn
|
|
62
|
+
* admission's live check: recovery state can change after the handshake. A check
|
|
63
|
+
* that cannot answer fails closed as `sidecar_unavailable`.
|
|
64
|
+
*/
|
|
65
|
+
export async function currentChatReadiness(sidecar) {
|
|
66
|
+
const readiness = chatReadiness(sidecar);
|
|
67
|
+
if (!sidecar || (!readiness.available && readiness.condition !== 'agent_runtime_stale'))
|
|
68
|
+
return readiness;
|
|
69
|
+
try {
|
|
70
|
+
await sidecar.call('runtime.ensure_current', {}, { timeoutMs: CHAT_READINESS_CHECK_TIMEOUT_MS });
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
if (error instanceof SidecarError && error.condition === 'agent_runtime_stale')
|
|
74
|
+
return { available: false, ...runtimeFailureDetails(error.condition, error.data), condition: 'agent_runtime_stale' };
|
|
75
|
+
return { available: false, condition: error instanceof SidecarError && error.condition === 'agent_incompatible' ? 'agent_incompatible' : 'sidecar_unavailable' };
|
|
76
|
+
}
|
|
77
|
+
// The live guard can clear after a rollback, but the client's incompatible admission gate still refuses chat.
|
|
78
|
+
return readiness.available ? readiness : { available: false, ...runtimeFailureDetails('agent_runtime_stale'), condition: 'agent_runtime_stale' };
|
|
79
|
+
}
|
|
54
80
|
function staleRuntimeFailure(error) {
|
|
55
|
-
|
|
81
|
+
const { message, ...details } = runtimeFailureDetails(error.condition, error.data);
|
|
82
|
+
return new HttpFailure(409, message ?? error.message, details);
|
|
56
83
|
}
|
|
57
84
|
/** TAL-536: the most of a dead run's journal stale-stream cleanup reads (Python's recovery window). */
|
|
58
85
|
const RECOVERY_JOURNAL_MAX_BYTES = 8 * 1024 * 1024;
|
|
@@ -60,29 +87,32 @@ const RECOVERY_JOURNAL_MAX_ROWS = 65536;
|
|
|
60
87
|
const isDict = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v);
|
|
61
88
|
export class SessionService {
|
|
62
89
|
deps;
|
|
90
|
+
owner = null;
|
|
63
91
|
/** TAL-255: one manual compression worker per session, shared by every compress route. */
|
|
64
92
|
compressionJobs;
|
|
93
|
+
/** TAL-1005: the turns now waiting on their session's compression job before they read its history. */
|
|
94
|
+
compactionWaiters = new Set();
|
|
65
95
|
constructor(deps) {
|
|
66
96
|
this.deps = deps;
|
|
67
97
|
this.compressionJobs = new CompressionJobs();
|
|
68
98
|
}
|
|
69
99
|
get store() { return this.deps.store; }
|
|
70
100
|
/**
|
|
71
|
-
*
|
|
101
|
+
* User-turn admission shared by `chat.start` and automatic turns through `TurnRunner.startServerTurn`.
|
|
72
102
|
* A requested workspace must be trusted; the session's own one, when its directory is gone, falls back to the last one.
|
|
73
103
|
*/
|
|
74
|
-
turnWorkspace(s, requested) {
|
|
104
|
+
async turnWorkspace(s, requested) {
|
|
75
105
|
const ws = this.deps.workspaces;
|
|
76
106
|
if (requested !== undefined && requested !== null && requested !== '') {
|
|
77
107
|
try {
|
|
78
|
-
return ws.resolveTrusted(str(requested), s.profile);
|
|
108
|
+
return (await ws.resolveTrusted(str(requested), s.profile));
|
|
79
109
|
}
|
|
80
110
|
catch (error) {
|
|
81
111
|
throw new HttpFailure(400, error.message);
|
|
82
112
|
}
|
|
83
113
|
}
|
|
84
114
|
try {
|
|
85
|
-
const [workspace, recovered] = ws.resolveImplicitWithRecovery(s.workspace, (p) => ws.lastWorkspace(p), s.profile);
|
|
115
|
+
const [workspace, recovered] = (await ws.resolveImplicitWithRecovery(s.workspace, async (p) => (await ws.lastWorkspace(p)), s.profile));
|
|
86
116
|
if (recovered)
|
|
87
117
|
s.workspace = workspace;
|
|
88
118
|
return workspace;
|
|
@@ -105,18 +135,19 @@ export class SessionService {
|
|
|
105
135
|
* TAL-186: rewrites a session's media references to their URLs. A local path is rewritten only when `/api/media`
|
|
106
136
|
* serves it for this session, under the same resolution, so every rewritten reference loads.
|
|
107
137
|
*/
|
|
108
|
-
mediaProjector(s) {
|
|
138
|
+
async mediaProjector(s) {
|
|
109
139
|
const media = this.deps.media;
|
|
140
|
+
const access = media ? await resolveMediaAccess(media.access) : null;
|
|
110
141
|
const served = new Map();
|
|
111
142
|
const localUrl = (path) => {
|
|
112
|
-
if (!media?.localIo(s.profile))
|
|
143
|
+
if (!media?.localIo(s.profile) || !access)
|
|
113
144
|
return null;
|
|
114
145
|
if (served.has(path))
|
|
115
146
|
return served.get(path) ?? null;
|
|
116
147
|
let url = null;
|
|
117
148
|
try {
|
|
118
149
|
const target = mediaTarget(mediaRefPath(path));
|
|
119
|
-
if (
|
|
150
|
+
if (mediaAnchorRootResolved(target, s, access))
|
|
120
151
|
url = `./api/media?${new URLSearchParams({ path: target, session_id: s.session_id }).toString()}`;
|
|
121
152
|
}
|
|
122
153
|
catch { /* an invalid path stays text */ }
|
|
@@ -143,7 +174,7 @@ export class SessionService {
|
|
|
143
174
|
* not slip past the profile check into a handler that trims and trusts it. A non-empty id that is still unsafe after
|
|
144
175
|
* trimming can name no session and is refused here instead of falling through.
|
|
145
176
|
*/
|
|
146
|
-
sessionIdVisible(raw) {
|
|
177
|
+
async sessionIdVisible(raw) {
|
|
147
178
|
if (typeof raw !== 'string' || !raw.trim())
|
|
148
179
|
return true;
|
|
149
180
|
const sid = raw.trim();
|
|
@@ -151,62 +182,95 @@ export class SessionService {
|
|
|
151
182
|
return false;
|
|
152
183
|
let session;
|
|
153
184
|
try {
|
|
154
|
-
session = this.store.get(sid, { metadataOnly: true });
|
|
185
|
+
session = (await this.store.get(sid, { metadataOnly: true }));
|
|
155
186
|
}
|
|
156
187
|
catch {
|
|
157
188
|
return true;
|
|
158
189
|
}
|
|
159
190
|
return this.visibleToActiveProfile(session.profile);
|
|
160
191
|
}
|
|
192
|
+
localTargets = null;
|
|
193
|
+
/** One receipt-backed physical target for every operation on a local conversation alias. */
|
|
194
|
+
resolveSessionId(sid) {
|
|
195
|
+
try {
|
|
196
|
+
const path = this.stateDbPath(null);
|
|
197
|
+
const key = JSON.stringify([statSignature(path), statSignature(`${path}-wal`), path]);
|
|
198
|
+
if (this.localTargets?.key !== key)
|
|
199
|
+
this.localTargets = { key, ids: new Map() };
|
|
200
|
+
const cached = this.localTargets.ids.get(sid);
|
|
201
|
+
if (cached)
|
|
202
|
+
return cached;
|
|
203
|
+
return stateDbLocalTarget(path, sid, { onRead: (target) => {
|
|
204
|
+
// A failed or changing read cannot seed a routing cache.
|
|
205
|
+
if (key !== JSON.stringify([statSignature(path), statSignature(`${path}-wal`), path]))
|
|
206
|
+
return;
|
|
207
|
+
if (this.localTargets.ids.size >= 500)
|
|
208
|
+
this.localTargets.ids.clear();
|
|
209
|
+
this.localTargets.ids.set(sid, target);
|
|
210
|
+
} });
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
return sid;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
161
216
|
/** Python `_lookup_cli_session_metadata(...).get('read_only')` plus the persisted sidecar flag and a not-claimable foreign owner. */
|
|
162
|
-
isReadOnlyImport(sid) {
|
|
217
|
+
async isReadOnlyImport(sid) {
|
|
163
218
|
try {
|
|
164
|
-
const s = this.store.get(sid, { metadataOnly: true });
|
|
165
|
-
if (this.isReadOnly(s))
|
|
219
|
+
const s = (await this.store.get(sid, { metadataOnly: true }));
|
|
220
|
+
if ((await this.isReadOnly(s)))
|
|
166
221
|
return true;
|
|
167
222
|
}
|
|
168
223
|
catch {
|
|
169
224
|
// No sidecar: a foreign state.db transcript whose owner refuses claiming is read-only too.
|
|
170
|
-
const { session, reason } = this.claimOrSynthesizeCliSession(sid);
|
|
225
|
+
const { session, reason } = (await this.claimOrSynthesizeCliSession(sid));
|
|
171
226
|
if (session && reason === 'not_claimable')
|
|
172
227
|
return true;
|
|
173
228
|
}
|
|
174
|
-
const meta = this.lookupCliMeta(sid);
|
|
229
|
+
const meta = (await this.lookupCliMeta(sid));
|
|
175
230
|
return meta !== null && Boolean(meta.read_only);
|
|
176
231
|
}
|
|
177
232
|
/** Python `_session_is_subagent_view_only`: a delegated child by any signal — the persisted sidecar or the state.db row. */
|
|
178
|
-
isSubagentViewOnly(sid) {
|
|
233
|
+
async isSubagentViewOnly(sid) {
|
|
179
234
|
try {
|
|
180
|
-
const s = this.store.get(sid);
|
|
235
|
+
const s = (await this.store.get(sid, { metadataOnly: true, cacheOnMiss: false, promote: false }));
|
|
181
236
|
if (str(s.source_tag || s.raw_source || s.session_source).trim().toLowerCase() === 'subagent')
|
|
182
237
|
return true;
|
|
183
238
|
}
|
|
184
239
|
catch { /* no sidecar */ }
|
|
185
|
-
const meta = this.lookupCliMeta(sid);
|
|
240
|
+
const meta = (await this.lookupCliMeta(sid));
|
|
186
241
|
return meta !== null && str(meta.source_tag || meta.raw_source || meta.source).trim().toLowerCase() === 'subagent';
|
|
187
242
|
}
|
|
188
243
|
/** TAL-358: batched `sessions.source` owners from the active profile's state.db. */
|
|
189
|
-
stateDbSources = (ids
|
|
244
|
+
stateDbSources = (ids, profile = this.deps.activeProfile()) => {
|
|
245
|
+
try {
|
|
246
|
+
return stateDbSessionSources(this.stateDbPath(profile), ids);
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
};
|
|
190
252
|
/**
|
|
191
253
|
* TAL-590 (Python #3238): the read-only CLI or API-server imports among `rows` whose active-profile state.db row is
|
|
192
254
|
* gone, so their source was deleted outside Web and no Web action could remove them. An unreadable state.db proves
|
|
193
255
|
* nothing (`agentSessionRowsExisting`); another profile's session or one with a run the runtime still holds is never
|
|
194
256
|
* an orphan, while a stream id a crash left behind does not count as one.
|
|
195
257
|
*/
|
|
196
|
-
orphanedImports(rows) {
|
|
197
|
-
const candidates = this.orphanCandidates(rows);
|
|
258
|
+
async orphanedImports(rows, existing, owners) {
|
|
259
|
+
const candidates = (await this.orphanCandidates(rows));
|
|
198
260
|
if (!candidates.length)
|
|
199
261
|
return new Set();
|
|
200
|
-
|
|
201
|
-
|
|
262
|
+
existing ??= agentSessionRowsExisting(this.stateDbPath(null), candidates);
|
|
263
|
+
if (owners === undefined)
|
|
264
|
+
owners = this.stateDbSources(candidates);
|
|
265
|
+
return new Set(candidates.filter((sid) => !existing.has(sid) && owners !== null && typeof owners.get(sid) !== 'object'));
|
|
202
266
|
}
|
|
203
|
-
orphanCandidates(rows) {
|
|
204
|
-
return rows
|
|
267
|
+
async orphanCandidates(rows) {
|
|
268
|
+
return (await filterAsync(rows, async (r) => r.read_only && ['cli', 'api'].includes(sourceKind(r)) && this.visibleToActiveProfile(str(r.profile) || null) && !(await this.activeRunBlocking(str(r.session_id)))))
|
|
205
269
|
.map((r) => str(r.session_id));
|
|
206
270
|
}
|
|
207
|
-
isOrphanedImport(sid) {
|
|
271
|
+
async isOrphanedImport(sid) {
|
|
208
272
|
try {
|
|
209
|
-
return this.orphanedImports([this.store.get(sid, { metadataOnly: true }).compact()]).size > 0;
|
|
273
|
+
return (await this.orphanedImports([(await this.store.get(sid, { metadataOnly: true })).compact()])).size > 0;
|
|
210
274
|
}
|
|
211
275
|
catch {
|
|
212
276
|
return false;
|
|
@@ -216,51 +280,97 @@ export class SessionService {
|
|
|
216
280
|
* The list's owner locks and orphan prune (an orphan's sidecar goes, as nothing else ever removes it), with their
|
|
217
281
|
* state.db reads made on the listing worker up front so the synchronous build never opens state.db (TAL-920).
|
|
218
282
|
*/
|
|
219
|
-
async listStateDbLookups(rows) {
|
|
283
|
+
async listStateDbLookups(rows, cliRowIds) {
|
|
220
284
|
const dbPath = this.stateDbPath(null);
|
|
221
|
-
const
|
|
222
|
-
const
|
|
223
|
-
const
|
|
285
|
+
const activeProfile = this.deps.activeProfile();
|
|
286
|
+
const byProfile = ownerLockCandidates(rows);
|
|
287
|
+
const activeIds = byProfile.get(activeProfile) ?? new Set();
|
|
288
|
+
for (const id of cliRowIds)
|
|
289
|
+
activeIds.add(id);
|
|
290
|
+
byProfile.set(activeProfile, activeIds);
|
|
291
|
+
const owners = new Map(await Promise.all([...byProfile].map(async ([profile, ids]) => [profile, ids.size ? await stateDbSessionSourcesAsync(this.stateDbPath(profile), ids) : new Map()])));
|
|
292
|
+
const checked = new Set((await this.orphanCandidates(rows)));
|
|
224
293
|
const existing = checked.size ? await agentSessionRowsExistingAsync(dbPath, checked) : new Set();
|
|
225
294
|
return {
|
|
226
|
-
stateDbSources: () => owners,
|
|
227
|
-
pruneOrphanedImports: (current) => {
|
|
295
|
+
stateDbSources: (_ids, profile = activeProfile) => owners.get(profile) ?? null,
|
|
296
|
+
pruneOrphanedImports: async (current) => {
|
|
228
297
|
// Only an id the worker checked can be an orphan; a row that became a candidate meanwhile waits for the next list.
|
|
229
|
-
const orphans =
|
|
298
|
+
const orphans = (await this.orphanedImports(current.filter((r) => checked.has(str(r.session_id))), existing, owners.get(activeProfile) ?? null));
|
|
230
299
|
for (const sid of orphans)
|
|
231
|
-
this.store.
|
|
300
|
+
(await this.store.delete(sid));
|
|
232
301
|
return orphans;
|
|
233
302
|
},
|
|
234
303
|
};
|
|
235
304
|
}
|
|
236
305
|
/** The persisted flag folded with the state.db owner lock (TAL-358): the one read-only rule for mutation gates and the wire. */
|
|
237
|
-
|
|
238
|
-
if (s
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
306
|
+
async readOnlyReason(s, profile) {
|
|
307
|
+
if (typeof s === 'string') {
|
|
308
|
+
try {
|
|
309
|
+
s = await this.store.get(s, { metadataOnly: true, cacheOnMiss: false, promote: false });
|
|
310
|
+
}
|
|
311
|
+
catch (error) {
|
|
312
|
+
if (!(error instanceof SessionNotFound))
|
|
313
|
+
return OWNERSHIP_UNAVAILABLE_REASON;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return this.loadedReadOnlyReason(s, profile);
|
|
317
|
+
}
|
|
318
|
+
loadedReadOnlyReason(s, profile) {
|
|
319
|
+
let raw = typeof s === 'string' ? { session_id: s, profile: this.deps.activeProfile() }
|
|
320
|
+
: { session_id: s.session_id, profile: s.profile, source_tag: s.source_tag, raw_source: s.raw_source, session_source: s.session_source, read_only: s.read_only };
|
|
321
|
+
if (profile !== undefined)
|
|
322
|
+
raw = this.deps.profilesMatch(str(raw.profile) || null, profile) ? { ...raw, profile } : { session_id: raw.session_id, profile };
|
|
323
|
+
const row = withOwnerLocks([raw], this.stateDbSources)[0];
|
|
324
|
+
return row.read_only ? str(row.read_only_reason) || 'Read-only imported sessions cannot be modified from WebUI' : null;
|
|
325
|
+
}
|
|
326
|
+
assertWritable(session, profile) {
|
|
327
|
+
const check = (reason) => { if (reason)
|
|
328
|
+
throw new HttpFailure(403, reason); };
|
|
329
|
+
if (typeof session === 'string')
|
|
330
|
+
return this.readOnlyReason(session, profile).then(check);
|
|
331
|
+
check(this.loadedReadOnlyReason(session, profile));
|
|
332
|
+
}
|
|
333
|
+
/** Controls can exist without a local session file; use the same id the sidecar will address. */
|
|
334
|
+
async idForMutation(sid) {
|
|
335
|
+
sid = sid.trim();
|
|
336
|
+
if (!sid)
|
|
337
|
+
throw new HttpFailure(400, 'Missing required field(s): session_id');
|
|
338
|
+
await this.assertWritable(sid);
|
|
339
|
+
return sid;
|
|
340
|
+
}
|
|
341
|
+
async isReadOnly(s) { return (await this.readOnlyReason(s)) !== null; }
|
|
342
|
+
/** Recheck after the mutex wait; legacy local metadata/handoff actions keep their existing read-only allowance. */
|
|
343
|
+
withMutationLock(sid, fn, opts = {}) {
|
|
344
|
+
return this.store.withLock(sid, async () => {
|
|
345
|
+
if (opts.allowReadOnly)
|
|
346
|
+
(await this.rejectViewOnly(sid, 'modified'));
|
|
347
|
+
else
|
|
348
|
+
(await this.assertWritable(sid));
|
|
349
|
+
return fn();
|
|
350
|
+
}, opts);
|
|
242
351
|
}
|
|
243
352
|
/** Full session for mutation, refusing read-only imports (Python `_get_or_materialize_session`). */
|
|
244
|
-
getForMutation(sid) {
|
|
245
|
-
let s = this.get404(sid);
|
|
246
|
-
s = this.store.ensureFull(sid, s);
|
|
247
|
-
|
|
248
|
-
throw new HttpFailure(403, 'Read-only imported sessions cannot be modified from WebUI');
|
|
353
|
+
async getForMutation(sid) {
|
|
354
|
+
let s = (await this.get404(sid));
|
|
355
|
+
s = (await this.store.ensureFull(sid, s));
|
|
356
|
+
this.assertWritable(s);
|
|
249
357
|
if (str(s.source_tag || s.raw_source).trim().toLowerCase() === 'subagent')
|
|
250
358
|
throw new HttpFailure(403, 'Read-only subagent child session');
|
|
251
359
|
return s;
|
|
252
360
|
}
|
|
253
|
-
get404(sid, opts = {}) {
|
|
361
|
+
async get404(sid, opts = {}) {
|
|
362
|
+
sid = this.resolveSessionId(sid);
|
|
254
363
|
try {
|
|
255
|
-
return this.store.get(sid, opts);
|
|
364
|
+
return (await this.store.get(sid, opts));
|
|
256
365
|
}
|
|
257
366
|
catch (error) {
|
|
258
367
|
if (error instanceof SessionNotFound) {
|
|
259
368
|
// Python `_get_or_materialize_session`: a claimable foreign (CLI/TUI/Desktop) session gains a WebUI sidecar
|
|
260
369
|
// on its first mutation; a read-only foreign source answers 403 from `getForMutation`, a missing one 404.
|
|
261
|
-
const synth = this.claimOrSynthesizeCliSession(sid);
|
|
370
|
+
const synth = (await this.claimOrSynthesizeCliSession(sid));
|
|
262
371
|
if (synth.session && synth.reason === 'materialized') {
|
|
263
|
-
this.store.save(synth.session);
|
|
372
|
+
await this.store.save(synth.session);
|
|
373
|
+
await this.store.touch(synth.session);
|
|
264
374
|
this.deps.titleClaimed?.(synth.session);
|
|
265
375
|
return synth.session;
|
|
266
376
|
}
|
|
@@ -278,11 +388,16 @@ export class SessionService {
|
|
|
278
388
|
/** `stateDbRows` and whether the read succeeded on a state.db whose messages carry ids (TAL-493). */
|
|
279
389
|
stateDbRead(s) {
|
|
280
390
|
if (str(s.source_tag || s.raw_source || s.session_source).trim().toLowerCase() === 'subagent')
|
|
281
|
-
return { rows: [], idCapable: false, ok: false };
|
|
391
|
+
return { rows: [], idCapable: false, ok: false, reason: null, generation: null };
|
|
282
392
|
// TAL-529: after a compression rotation the Agent writes to the continuation, so the read follows the recorded lineage.
|
|
283
393
|
const lineage = s.state_db_lineage?.[0] === s.session_id ? s.state_db_lineage : null;
|
|
284
394
|
return stateDbSessionRead(this.stateDbPath(s.profile), s.session_id, { stitch: false, lineage });
|
|
285
395
|
}
|
|
396
|
+
/** Preserve existing transcript provenance before a turn adds Agent rows from its starting database read. */
|
|
397
|
+
beginStateDbTurn(s) {
|
|
398
|
+
stampStateDbGeneration([...s.messages, ...s.context_messages], markedStateDbGeneration(s));
|
|
399
|
+
return this.stateDbRead(s);
|
|
400
|
+
}
|
|
286
401
|
stateDbPath(profile) {
|
|
287
402
|
return join(this.deps.profileHome(profile ?? this.deps.activeProfile()), 'state.db');
|
|
288
403
|
}
|
|
@@ -294,7 +409,7 @@ export class SessionService {
|
|
|
294
409
|
mergedTranscript(s, local = s.messages, stateRows = this.stateDbRows(s)) {
|
|
295
410
|
if (!stateRows.length)
|
|
296
411
|
return local;
|
|
297
|
-
return mergeSessionMessagesAppendOnly(local, stateRows, { truncationWatermark: s.truncation_watermark, compressedWatermark: s.truncation_watermark_compressed, stateDbSeenId: currentStateDbSeenId(s, stateRows) });
|
|
412
|
+
return mergeSessionMessagesAppendOnly(local, stateRows, { truncationWatermark: s.truncation_watermark, compressedWatermark: s.truncation_watermark_compressed, stateDbSeenId: currentStateDbSeenId(s, stateRows), stateDbGeneration: markedStateDbGeneration(s) });
|
|
298
413
|
}
|
|
299
414
|
/**
|
|
300
415
|
* TAL-493: record the highest state.db id this boundary or settled turn read, under the session lock in the same save,
|
|
@@ -302,6 +417,10 @@ export class SessionService {
|
|
|
302
417
|
* fields are set: the marker holds only while they and the row it names stay as recorded.
|
|
303
418
|
*/
|
|
304
419
|
markStateDbSeen(s, read = this.stateDbRead(s), opts = {}) {
|
|
420
|
+
// Freeze numeric provenance before replacing the marker, including after a database replacement.
|
|
421
|
+
const priorGeneration = markedStateDbGeneration(s);
|
|
422
|
+
if (priorGeneration !== null)
|
|
423
|
+
stampStateDbGeneration([...s.messages, ...s.context_messages], priorGeneration);
|
|
305
424
|
// A failed read says nothing. A settlement keeps the marker; a boundary drops it (fail closed): it may cut rows the
|
|
306
425
|
// marker still admits without moving a field, and the timestamp rules honour its watermark.
|
|
307
426
|
if (!read.ok) {
|
|
@@ -327,21 +446,37 @@ export class SessionService {
|
|
|
327
446
|
*/
|
|
328
447
|
settleStateDb(s, turn) {
|
|
329
448
|
const read = turn.read ?? this.stateDbRead(s);
|
|
330
|
-
const
|
|
331
|
-
const
|
|
449
|
+
const agentAdded = withoutRows(turn.agentRows ?? [], turn.previousContext);
|
|
450
|
+
const contextAdded = withoutRows(s.context_messages, turn.previousContext);
|
|
451
|
+
if (typeof turn.startGeneration === 'string') {
|
|
452
|
+
stampStateDbGeneration(s.messages.filter((m) => m._turn_id !== turn.turnId), markedStateDbGeneration(s));
|
|
453
|
+
stampStateDbGeneration([...s.messages.filter((m) => m._turn_id === turn.turnId), ...contextAdded, ...agentAdded], turn.startGeneration);
|
|
454
|
+
}
|
|
455
|
+
const startGeneration = turn.previousContext.findLast((m) => typeof m._state_db_generation === 'string')?._state_db_generation;
|
|
456
|
+
const generation = turn.startGeneration ?? markedStateDbGeneration(s) ?? (typeof startGeneration === 'string' ? startGeneration : null);
|
|
457
|
+
const readGeneration = read.generation;
|
|
458
|
+
const generationChanged = generation !== null && typeof readGeneration === 'string' && generation !== readGeneration;
|
|
459
|
+
const includeUids = read.rows.some((m) => stateDbIdentityKeys(m).some((id) => id.startsWith('uid:')));
|
|
460
|
+
const identities = (m) => stateDbIdentityKeys(m, includeUids, generation);
|
|
461
|
+
const startedWith = new Set(turn.previousContext.flatMap((m) => identities(m)));
|
|
462
|
+
const wasInContext = (m) => stateDbMessageRepresented(m, startedWith, includeUids, generation);
|
|
463
|
+
const earlier = mergeSessionMessagesAppendOnly(s.messages, read.rows.filter(wasInContext), { stateDbSeenId: -1, stateDbGeneration: generation }).slice(s.messages.length);
|
|
332
464
|
const added = new Map();
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
465
|
+
const addedIds = new Set(agentAdded.flatMap((m) => identities(m)));
|
|
466
|
+
for (const m of agentAdded) {
|
|
467
|
+
if (!identities(m).length)
|
|
468
|
+
added.set(settledIdentity(m), (added.get(settledIdentity(m)) ?? 0) + 1);
|
|
469
|
+
}
|
|
470
|
+
const ownWorkAfter = turn.agentRows?.length || generationChanged ? null : turn.startId ?? null;
|
|
336
471
|
// Without a current marker, the turn's own starting read is the baseline: rows committed after it are judged here.
|
|
337
|
-
const seenId = currentStateDbSeenId(s, read.rows) ?? turn.startId ?? null;
|
|
338
|
-
const shown = read.rows.length ? mergeSessionMessagesAppendOnly(s.messages, read.rows, { truncationWatermark: s.truncation_watermark, compressedWatermark: s.truncation_watermark_compressed, stateDbSeenId: seenId }) : s.messages;
|
|
472
|
+
const seenId = currentStateDbSeenId(s, read.rows) ?? (generationChanged ? null : turn.startId ?? null);
|
|
473
|
+
const shown = read.rows.length ? mergeSessionMessagesAppendOnly(s.messages, read.rows, { truncationWatermark: s.truncation_watermark, compressedWatermark: s.truncation_watermark_compressed, stateDbSeenId: seenId, stateDbGeneration: generation }) : s.messages;
|
|
339
474
|
const fresh = new Set(shown.slice(s.messages.length));
|
|
340
475
|
// The turn's own rows the merge already matched to their local copies use up their share before any shown row can.
|
|
341
476
|
if (seenId !== null) {
|
|
342
477
|
for (const m of read.rows) {
|
|
343
478
|
const id = m._state_db_row_id;
|
|
344
|
-
if (typeof id !== 'number' || id <= seenId ||
|
|
479
|
+
if (typeof id !== 'number' || id <= seenId || wasInContext(m) || fresh.has(m))
|
|
345
480
|
continue;
|
|
346
481
|
const left = added.get(settledIdentity(m)) ?? 0;
|
|
347
482
|
if (left > 0)
|
|
@@ -350,7 +485,9 @@ export class SessionService {
|
|
|
350
485
|
}
|
|
351
486
|
const missed = [...fresh].filter((m) => {
|
|
352
487
|
const id = m._state_db_row_id;
|
|
353
|
-
if (
|
|
488
|
+
if (wasInContext(m))
|
|
489
|
+
return false;
|
|
490
|
+
if (stateDbMessageRepresented(m, addedIds, includeUids, generation))
|
|
354
491
|
return false;
|
|
355
492
|
const key = settledIdentity(m);
|
|
356
493
|
const left = added.get(key) ?? 0;
|
|
@@ -367,7 +504,7 @@ export class SessionService {
|
|
|
367
504
|
if (missed.length) {
|
|
368
505
|
s.messages.push(...copyJson(missed));
|
|
369
506
|
if (s.context_messages.length)
|
|
370
|
-
s.context_messages.
|
|
507
|
+
s.context_messages = mergeSessionMessagesAppendOnly(s.context_messages, copyJson(missed), { stateDbSeenId: -1, stateDbGeneration: generation });
|
|
371
508
|
}
|
|
372
509
|
this.markStateDbSeen(s, read);
|
|
373
510
|
// TAL-529: a compression rotation moved the Agent to a continuation, whose first rows restate the compressed context.
|
|
@@ -423,34 +560,36 @@ export class SessionService {
|
|
|
423
560
|
return pending ? withPendingUserTurn(context, pending) : context;
|
|
424
561
|
}
|
|
425
562
|
/** Python `_is_messaging_session_id`: from the WebUI metadata or the Agent's row. */
|
|
426
|
-
isMessagingSession(sid) {
|
|
563
|
+
async isMessagingSession(sid) {
|
|
427
564
|
try {
|
|
428
|
-
if (isMessagingSessionRecord(this.store.get(sid, { metadataOnly: true }).compact()))
|
|
565
|
+
if (isMessagingSessionRecord((await this.store.get(sid, { metadataOnly: true })).compact()))
|
|
429
566
|
return true;
|
|
430
567
|
}
|
|
431
568
|
catch { /* absent */ }
|
|
432
|
-
const meta = this.lookupCliMeta(sid);
|
|
569
|
+
const meta = (await this.lookupCliMeta(sid));
|
|
433
570
|
return meta !== null && isMessagingSessionRecord(meta);
|
|
434
571
|
}
|
|
435
572
|
/** Python `_lookup_cli_session_metadata`: the sidebar row for a state.db session in the active profile. */
|
|
436
|
-
lookupCliMeta(sid) {
|
|
573
|
+
async lookupCliMeta(sid) {
|
|
437
574
|
try {
|
|
438
575
|
if (isClaudeCodeSessionId(sid))
|
|
439
|
-
return this.claudeCodeRows().find((r) => str(r.session_id) === sid) ?? null;
|
|
440
|
-
|
|
576
|
+
return (await this.claudeCodeRows()).find((r) => str(r.session_id) === sid) ?? null;
|
|
577
|
+
const profile = this.deps.activeProfile();
|
|
578
|
+
return (await this.deps.cliSessionsSync(profile, { sourceFilter: null })).rows.find((r) => str(r.session_id) === sid)
|
|
579
|
+
?? (await this.deps.cliSessionsSync(profile, { sourceFilter: null, sessionIds: [sid] })).rows[0] ?? null;
|
|
441
580
|
}
|
|
442
581
|
catch {
|
|
443
582
|
return null;
|
|
444
583
|
}
|
|
445
584
|
}
|
|
446
|
-
claudeCodeRows() {
|
|
447
|
-
return this.deps.claudeCode?.rows(this.deps.workspaces.lastWorkspace(this.deps.activeProfile())) ?? [];
|
|
585
|
+
async claudeCodeRows() {
|
|
586
|
+
return this.deps.claudeCode?.rows((await this.deps.workspaces.lastWorkspace(this.deps.activeProfile()))) ?? [];
|
|
448
587
|
}
|
|
449
588
|
/** Python `_session_index_marks_was_webui`: an index row that once owned a WebUI sidecar (self-heal 404). */
|
|
450
|
-
indexMarksWasWebui(sid) {
|
|
589
|
+
async indexMarksWasWebui(sid) {
|
|
451
590
|
let entries;
|
|
452
591
|
try {
|
|
453
|
-
entries = this.store.readIndexEntries();
|
|
592
|
+
entries = (await this.store.readIndexEntries());
|
|
454
593
|
}
|
|
455
594
|
catch {
|
|
456
595
|
return false;
|
|
@@ -472,20 +611,23 @@ export class SessionService {
|
|
|
472
611
|
* a WebUI sidecar. `materialized` sessions are writeable (the caller persists the sidecar), `not_claimable` ones are
|
|
473
612
|
* read-only stubs, `was_webui`/`no_foreign_state`/`invalid_sid` answer 404.
|
|
474
613
|
*/
|
|
475
|
-
claimOrSynthesizeCliSession(sid, metaIn) {
|
|
614
|
+
async claimOrSynthesizeCliSession(sid, metaIn) {
|
|
476
615
|
if (!isSafeSessionId(sid))
|
|
477
616
|
return { session: null, reason: 'invalid_sid' };
|
|
478
617
|
const profile = this.deps.activeProfile();
|
|
479
618
|
const dbPath = join(this.deps.profileHome(profile), 'state.db');
|
|
619
|
+
sid = this.resolveSessionId(sid);
|
|
480
620
|
const row = stateDbSessionRow(dbPath, sid);
|
|
481
621
|
const stateDbSource = str(row?.source).trim().toLowerCase();
|
|
482
622
|
const subagentChild = stateDbSource === 'subagent';
|
|
483
|
-
|
|
623
|
+
const owners = this.stateDbSources([sid]);
|
|
624
|
+
const canonical = typeof owners?.get(sid) === 'object';
|
|
625
|
+
if (((await this.indexMarksWasWebui(sid)) || ((await this.store.wasDeleted(sid)) && ['', 'webui', 'fork'].includes(stateDbSource))) && !subagentChild && !canonical)
|
|
484
626
|
return { session: null, reason: 'was_webui' };
|
|
485
627
|
const msgs = isClaudeCodeSessionId(sid) ? this.deps.claudeCode?.messages(sid) ?? [] : stateDbSessionMessages(dbPath, sid, { stitch: true });
|
|
486
|
-
|
|
628
|
+
const meta = { ...(metaIn ?? (await this.lookupCliMeta(sid)) ?? {}) };
|
|
629
|
+
if (!msgs.length && !(meta._lineage_root_id && meta._lineage_tip_id))
|
|
487
630
|
return { session: null, reason: 'no_foreign_state' };
|
|
488
|
-
const meta = { ...(metaIn ?? this.lookupCliMeta(sid) ?? {}) };
|
|
489
631
|
if (row) {
|
|
490
632
|
if (!meta.source_tag && stateDbSource)
|
|
491
633
|
meta.source_tag = stateDbSource;
|
|
@@ -502,12 +644,16 @@ export class SessionService {
|
|
|
502
644
|
if (!meta.updated_at && (row.ended_at || row.started_at))
|
|
503
645
|
meta.updated_at = row.ended_at || row.started_at;
|
|
504
646
|
}
|
|
505
|
-
|
|
506
|
-
const
|
|
507
|
-
const
|
|
647
|
+
// An owner read that fails is an unknown owner, which never releases a claim (TAL-793).
|
|
648
|
+
const claimable = owners !== null && isClaimableCliSource(meta, owners.get(sid) || stateDbSource);
|
|
649
|
+
const workspace = str(meta.workspace || meta.cwd).trim() || (await this.deps.workspaces.lastWorkspace(profile));
|
|
650
|
+
const defaults = (await this.store.deps.defaults(profile));
|
|
651
|
+
const context = Array.isArray(meta._lineage_ids) ? stateDbSessionMessages(dbPath, sid, { stitch: false }) : null;
|
|
508
652
|
const session = new Session({
|
|
509
653
|
session_id: sid, title: str(meta.title) || 'CLI Session', workspace, model: str(meta.model) || 'unknown', model_provider: str(meta.model_provider) || null,
|
|
510
654
|
messages: msgs, created_at: Number(meta.created_at) || 0, updated_at: Number(meta.updated_at) || 0, profile: str(meta.profile) || null,
|
|
655
|
+
pinned: Boolean(meta.pinned), archived: Boolean(meta.archived),
|
|
656
|
+
...(context ? { context_messages: context } : {}),
|
|
511
657
|
is_cli_session: claimable ? true : !subagentChild, source_tag: str(meta.source_tag) || null, raw_source: str(meta.raw_source) || null,
|
|
512
658
|
session_source: str(meta.session_source) || null, source_label: str(meta.source_label) || null, read_only: !claimable,
|
|
513
659
|
// Python `import_cli_session`: the claimed sidecar keeps the row's lineage, background project and channel identity
|
|
@@ -517,9 +663,9 @@ export class SessionService {
|
|
|
517
663
|
}, defaults);
|
|
518
664
|
return { session, reason: claimable ? 'materialized' : 'not_claimable' };
|
|
519
665
|
}
|
|
520
|
-
mutationTarget(sid, verb) {
|
|
666
|
+
async mutationTarget(sid, verb) {
|
|
521
667
|
try {
|
|
522
|
-
return this.getForMutation(sid);
|
|
668
|
+
return (await this.getForMutation(sid));
|
|
523
669
|
}
|
|
524
670
|
catch (error) {
|
|
525
671
|
if (error instanceof SessionNotFound)
|
|
@@ -537,49 +683,61 @@ export class SessionService {
|
|
|
537
683
|
if (isClaudeCodeSessionId(sid))
|
|
538
684
|
throw new HttpFailure(400, `Claude Code sessions are view-only and cannot be ${verb} from WebUI`);
|
|
539
685
|
}
|
|
540
|
-
|
|
686
|
+
async rejectViewOnly(sid, verb) {
|
|
687
|
+
sid = sid.trim();
|
|
541
688
|
this.rejectClaudeCode(sid, verb);
|
|
542
|
-
|
|
689
|
+
let profile = this.deps.activeProfile();
|
|
690
|
+
try {
|
|
691
|
+
profile = (await this.store.get(sid, { metadataOnly: true, cacheOnMiss: false, promote: false })).profile || 'default';
|
|
692
|
+
}
|
|
693
|
+
catch { /* foreign row */ }
|
|
694
|
+
const owners = this.stateDbSources([sid], profile);
|
|
695
|
+
const owner = owners?.get(sid);
|
|
696
|
+
if (!owners || typeof owner === 'object')
|
|
697
|
+
throw new HttpFailure(403, typeof owner === 'object' ? owner.reason ?? CANONICAL_READ_ONLY_REASON : OWNERSHIP_UNAVAILABLE_REASON);
|
|
698
|
+
if ((await this.isSubagentViewOnly(sid)))
|
|
543
699
|
throw new HttpFailure(400, `Subagent sessions are view-only and cannot be ${verb} from WebUI`);
|
|
700
|
+
return sid;
|
|
701
|
+
}
|
|
702
|
+
/** All transcript transports use the same live-run and durable-pending evidence. */
|
|
703
|
+
async backgroundUpdates(s, messages, reviewRows) {
|
|
704
|
+
return withBackgroundUpdates(messages, s, { activeTurnId: this.deps.runtime.activeRunStream(s.session_id), pendingDelegations: (await this.deps.backgroundPending?.(s.session_id)) ?? new Set(), ...(reviewRows ? { reviewRows } : {}) });
|
|
544
705
|
}
|
|
545
706
|
/** TAL-372: each delegation row of a turn's scene carries the status of the work it started. */
|
|
546
|
-
backgroundLinked(s, messages) {
|
|
547
|
-
return withBackgroundLinks(messages, this.deps.backgroundReceipts?.(s.session_id) ?? []);
|
|
707
|
+
async backgroundLinked(s, messages) {
|
|
708
|
+
return withBackgroundLinks(messages, (await this.deps.backgroundReceipts?.(s.session_id)) ?? []);
|
|
548
709
|
}
|
|
549
710
|
/** `compact()` plus messages, redacted for the wire (Python `_public_session_projection`). */
|
|
550
|
-
publicSession(s, withMessages = true) {
|
|
551
|
-
const payload = this.wireRow(s);
|
|
711
|
+
async publicSession(s, withMessages = true, messages = s.messages) {
|
|
712
|
+
const payload = (await this.wireRow(s));
|
|
552
713
|
// Mutation replies replace a client's transcript, so they carry the same server-built scenes as the detail.
|
|
553
714
|
if (withMessages) {
|
|
554
|
-
payload.messages = this.backgroundLinked(s, hydrateAnchorActivityScenes(withToolCallOutcomes(
|
|
715
|
+
payload.messages = (await this.backgroundLinked(s, hydrateAnchorActivityScenes(withToolCallOutcomes((await this.backgroundUpdates(s, withMarkerKinds(withTurnIds(messages), s))), s.tool_calls, s.active_stream_id), s.anchor_activity_scenes, { activeTurnId: s.active_stream_id, runningScene: !(await this.journaledActiveTurn(s)) })));
|
|
555
716
|
payload.compression_reference = compressionReference(s, payload.messages);
|
|
556
717
|
}
|
|
557
718
|
return redactSessionData(payload, this.deps.redactEnabled());
|
|
558
719
|
}
|
|
559
720
|
/** `compact()` with the wire streaming/read-only flags (TAL-312), for replies that return the session row. */
|
|
560
|
-
wireRow(s) {
|
|
561
|
-
return withSessionWireFlags({ ...s.compact({ contextLengthFor: this.deps.contextLengthFor, modelOptionFor: this.deps.modelOptionFor }),
|
|
721
|
+
async wireRow(s) {
|
|
722
|
+
return withSessionWireFlags(withOwnerLocks([{ ...s.compact({ contextLengthFor: this.deps.contextLengthFor, modelOptionFor: this.deps.modelOptionFor }), assistant_name: this.assistantName(s), workspace_name: (await this.workspaceNames([s]))(s) }], this.stateDbSources)[0], this.deps.runtime.activeStreamIds);
|
|
562
723
|
}
|
|
563
724
|
/**
|
|
564
725
|
* TAL-303: the label for a session's workspace, from its own profile's registry (a profileless row is the root's).
|
|
565
726
|
* One resolver serves one response, so each profile's registry is read once however many rows it names.
|
|
566
727
|
*/
|
|
567
|
-
workspaceNames() {
|
|
728
|
+
async workspaceNames(rows) {
|
|
568
729
|
const registries = new Map();
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
try {
|
|
574
|
-
entries = this.deps.workspaces.entries(profile);
|
|
575
|
-
}
|
|
576
|
-
catch {
|
|
577
|
-
entries = [];
|
|
578
|
-
}
|
|
579
|
-
registries.set(profile, entries);
|
|
730
|
+
for (const profile of new Set(rows.map((row) => str(row.profile) || 'default'))) {
|
|
731
|
+
let entries;
|
|
732
|
+
try {
|
|
733
|
+
entries = await this.deps.workspaces.entries(profile);
|
|
580
734
|
}
|
|
581
|
-
|
|
582
|
-
|
|
735
|
+
catch {
|
|
736
|
+
entries = [];
|
|
737
|
+
}
|
|
738
|
+
registries.set(profile, entries);
|
|
739
|
+
}
|
|
740
|
+
return (row) => workspaceDisplayName(str(row.workspace), registries.get(str(row.profile) || 'default') ?? []);
|
|
583
741
|
}
|
|
584
742
|
/** The agent's display name for a session's profile (`assistant_name`). */
|
|
585
743
|
assistantName(s) {
|
|
@@ -590,48 +748,219 @@ export class SessionService {
|
|
|
590
748
|
return redactSessionData(s.toDocument(), this.deps.redactEnabled());
|
|
591
749
|
}
|
|
592
750
|
// ── detail ──────────────────────────────────────────────────────────────
|
|
593
|
-
loadRevision(s) {
|
|
594
|
-
return
|
|
751
|
+
async loadRevision(s) {
|
|
752
|
+
return (await this.store.revision(s.session_id));
|
|
753
|
+
}
|
|
754
|
+
pageTails = new WeakMap();
|
|
755
|
+
pageContexts = new WeakMap();
|
|
756
|
+
prefixIndex(s, revision) {
|
|
757
|
+
const previous = s.prefixIndex;
|
|
758
|
+
if (previous?.rows === s.messages && previous.revision === revision && previous.length <= s.messages.length) {
|
|
759
|
+
previous.extend();
|
|
760
|
+
return previous;
|
|
761
|
+
}
|
|
762
|
+
return s.prefixIndex = new SessionPrefixIndex(s.messages, revision, markedStateDbGeneration(s), { toolCalls: s.tool_calls });
|
|
763
|
+
}
|
|
764
|
+
/** Existing projections, once per complete turn range, in absolute transcript coordinates. */
|
|
765
|
+
async deriveRange(s, rows, baseIndex, initialTurn, journaled, refs, calls, reviewRows) {
|
|
766
|
+
return (await this.backgroundLinked(s, hydrateAnchorActivityScenes(withToolCallOutcomes((await this.backgroundUpdates(s, withMarkerKinds(withTurnIds(withAttachmentObjects(rows), baseIndex, initialTurn), s), reviewRows)), calls, s.active_stream_id, baseIndex), s.anchor_activity_scenes, { activeTurnId: s.active_stream_id, runningScene: !journaled, clipToolResults: true, baseIndex, refCounts: refs })));
|
|
767
|
+
}
|
|
768
|
+
async limitedDetail(s, index, limit, before, pending, journaled) {
|
|
769
|
+
let prefixEnd = s.messages.length;
|
|
770
|
+
const turnId = pending?.turnId ?? journaled?.turnId;
|
|
771
|
+
if (turnId)
|
|
772
|
+
prefixEnd = Math.min(prefixEnd, index.turns.get(turnId)?.first ?? prefixEnd);
|
|
773
|
+
const prompt = pending ? index.prompts.get(pending.activeTurnToken) : undefined;
|
|
774
|
+
if (prompt !== undefined)
|
|
775
|
+
prefixEnd = Math.min(prefixEnd, prompt);
|
|
776
|
+
const dbPath = this.stateDbPath(s.profile);
|
|
777
|
+
const key = JSON.stringify([index.revision, index.length, statSignature(dbPath), statSignature(`${dbPath}-wal`), s.active_stream_id, pending, journaled, s.state_db_seen_id, s.state_db_seen_boundary, s.state_db_seen_stamp, s.updated_at, this.deps.activeProfile(), s.compression_anchor_summary, s.compression_anchor_message_key, s.compression_anchor_visible_idx, this.compactionWaiters.has(s.active_stream_id ?? '')]);
|
|
778
|
+
let cached = this.pageTails.get(index);
|
|
779
|
+
if (cached?.key !== key || cached.contextRows !== s.context_messages || cached.contextLength !== s.context_messages.length) {
|
|
780
|
+
const streaming = Boolean(s.active_stream_id && !this.compactionWaiters.has(s.active_stream_id));
|
|
781
|
+
const contextKey = JSON.stringify([index.revision, s.active_stream_id, index.length, streaming]);
|
|
782
|
+
let context = this.pageContexts.get(index);
|
|
783
|
+
if (context?.key !== contextKey || context.contextRows !== s.context_messages || context.contextLength !== s.context_messages.length) {
|
|
784
|
+
const summary = !s.context_messages.length && !s.active_stream_id ? index.model : new ApiMessageSummary(s.active_stream_id, !s.context_messages.length);
|
|
785
|
+
if (summary !== index.model && !streaming) {
|
|
786
|
+
for (const row of s.context_messages.length ? s.context_messages : s.messages) {
|
|
787
|
+
summary.add(row);
|
|
788
|
+
if (summary.enough)
|
|
789
|
+
break;
|
|
790
|
+
}
|
|
791
|
+
summary.finish();
|
|
792
|
+
}
|
|
793
|
+
context = { key: contextKey, local: summary.rows, enough: summary.enough, contextRows: s.context_messages, contextLength: s.context_messages.length, marker: summary.marker, unidentified: summary.enough ? [] : summary.unidentified };
|
|
794
|
+
this.pageContexts.set(index, context);
|
|
795
|
+
}
|
|
796
|
+
let stateRows;
|
|
797
|
+
let effectiveSeenId = null;
|
|
798
|
+
let cacheable = true;
|
|
799
|
+
let appended = null;
|
|
800
|
+
if (typeof s.state_db_seen_id === 'number' && str(s.source_tag || s.raw_source || s.session_source).trim().toLowerCase() !== 'subagent') {
|
|
801
|
+
const read = stateDbSessionTailRead(dbPath, s.session_id, s.state_db_seen_id, { lineage: s.state_db_lineage?.[0] === s.session_id ? s.state_db_lineage : null, unidentified: [...index.unidentified, ...context.unidentified] });
|
|
802
|
+
if (!read.ok)
|
|
803
|
+
cacheable = false;
|
|
804
|
+
const markerRows = read.anchor ? [read.anchor, ...read.rows] : read.rows;
|
|
805
|
+
if (read.ok && read.idCapable && currentStateDbSeenId(s, markerRows) !== null) {
|
|
806
|
+
// Covered rows consume unidentified local copies before a new duplicate. Any such match needs the full snapshot.
|
|
807
|
+
const ambiguous = read.requiresFullRead === true;
|
|
808
|
+
if (!ambiguous) {
|
|
809
|
+
const includeUids = read.rows.some((m) => stateDbIdentityKeys(m).some((id) => id.startsWith('uid:')));
|
|
810
|
+
const mode = includeUids ? 1 : 0;
|
|
811
|
+
appended = appendStateDbIdentityRows(read.rows, s.state_db_seen_id, index.identities[mode], index.unidentified[mode], index.generation, includeUids);
|
|
812
|
+
stateRows = read.rows;
|
|
813
|
+
effectiveSeenId = s.state_db_seen_id;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
if (appended === null) {
|
|
818
|
+
stateRows = this.stateDbRows(s);
|
|
819
|
+
effectiveSeenId = currentStateDbSeenId(s, stateRows);
|
|
820
|
+
appended = this.mergedTranscript(s, s.messages, stateRows).slice(s.messages.length);
|
|
821
|
+
}
|
|
822
|
+
let tail = [...s.messages.slice(prefixEnd), ...appended];
|
|
823
|
+
if (pending)
|
|
824
|
+
tail = withPendingUserTurn(tail, { ...pending, localCount: s.messages.length - prefixEnd });
|
|
825
|
+
if (journaled)
|
|
826
|
+
tail = withoutRunningTurnOutput(tail, { ...journaled, localCount: s.messages.length - prefixEnd });
|
|
827
|
+
const tailIndex = new SessionPrefixIndex(tail, index.revision, index.generation, { toolCalls: index.toolCalls(prefixEnd, tail.length), baseIndex: prefixEnd, initialTurn: prefixEnd ? index.turnId(prefixEnd - 1) : 'legacy:start', timestampFloor: index.summary(prefixEnd).maxTimestamp, todoFloor: index.summary(prefixEnd).maxTodoTimestamp, parent: { index, end: prefixEnd } });
|
|
828
|
+
const enoughContext = !streaming && (context.enough || sanitizeMessagesForApi(context.marker ? context.local : mergeSessionMessagesAppendOnly(context.local, stateRows, { truncationWatermark: s.truncation_watermark, compressedWatermark: s.truncation_watermark_compressed, stateDbSeenId: effectiveSeenId, stateDbGeneration: index.generation }).filter((m) => !s.active_stream_id || m._turn_id !== s.active_stream_id), { maxMessages: 4 }).length >= 4);
|
|
829
|
+
const owners = new Map();
|
|
830
|
+
const sources = (ids, profile) => {
|
|
831
|
+
const ownerKey = JSON.stringify([ids, profile]);
|
|
832
|
+
const existing = owners.get(ownerKey);
|
|
833
|
+
if (existing)
|
|
834
|
+
return existing;
|
|
835
|
+
const result = this.stateDbSources(ids, profile);
|
|
836
|
+
if (result)
|
|
837
|
+
owners.set(ownerKey, result);
|
|
838
|
+
return result;
|
|
839
|
+
};
|
|
840
|
+
cached = { key, tail, index: tailIndex, enoughContext, sources, contextRows: s.context_messages, contextLength: s.context_messages.length,
|
|
841
|
+
summary: { total: prefixEnd + tail.length, last: Math.max(index.summary(prefixEnd).maxTimestamp, tailIndex.summary().maxTimestamp), todo: tailIndex.summary().todo ?? index.summary(prefixEnd).todo, compression: index.compressionReference(s, prefixEnd, tailIndex) } };
|
|
842
|
+
if (cacheable)
|
|
843
|
+
this.pageTails.set(index, cached);
|
|
844
|
+
}
|
|
845
|
+
const { tail, index: tailIndex } = cached;
|
|
846
|
+
const total = prefixEnd + tail.length;
|
|
847
|
+
const end = before === null ? total : Math.max(0, Math.min(before, total));
|
|
848
|
+
const readRange = (start, stop) => start >= prefixEnd ? tail.slice(start - prefixEnd, stop - prefixEnd) : [...s.messages.slice(start, Math.min(stop, prefixEnd)), ...(stop > prefixEnd ? tail.slice(0, stop - prefixEnd) : [])];
|
|
849
|
+
const idAt = (at) => at < prefixEnd ? index.turnId(at) : tailIndex.turnId(at);
|
|
850
|
+
const turnSpan = (id) => {
|
|
851
|
+
const prefix = index.turns.get(id), suffix = tailIndex.turns.get(id);
|
|
852
|
+
return { first: Math.min(prefix && prefix.first < prefixEnd ? prefix.first : total, suffix?.first ?? total), last: Math.max(prefix && prefix.first < prefixEnd ? Math.min(prefix.last, prefixEnd - 1) : -1, suffix?.last ?? -1) + 1 };
|
|
853
|
+
};
|
|
854
|
+
const spanAt = (at) => {
|
|
855
|
+
const id = idAt(at), own = turnSpan(id);
|
|
856
|
+
const dependencies = [index.dependencies.get(id), tailIndex.dependencies.get(id)];
|
|
857
|
+
const first = Math.min(...dependencies.map((span) => span?.first ?? total));
|
|
858
|
+
const last = Math.max(...dependencies.map((span) => span ? Math.min(span.last, total - 1) : -1));
|
|
859
|
+
return { first: Math.min(own.first, first), last: Math.max(own.last, last + 1) };
|
|
860
|
+
};
|
|
861
|
+
// A notification can refer to a parent turn outside this page. Read that indexed turn, never the whole prefix.
|
|
862
|
+
const reviewTurns = new Map();
|
|
863
|
+
const reviewRows = (id) => {
|
|
864
|
+
let rows = reviewTurns.get(id);
|
|
865
|
+
if (!rows) {
|
|
866
|
+
const span = turnSpan(id);
|
|
867
|
+
rows = span.first < span.last ? withTurnIds(readRange(span.first, span.last), span.first, span.first ? idAt(span.first - 1) : 'legacy:start').filter((row) => row._turn_id === id) : [];
|
|
868
|
+
reviewTurns.set(id, rows);
|
|
869
|
+
}
|
|
870
|
+
return rows;
|
|
871
|
+
};
|
|
872
|
+
const refs = { get: (ref) => index.refCountBefore(ref, prefixEnd) + (tailIndex.refCounts.get(ref) ?? 0) };
|
|
873
|
+
if (cached.newestReply === undefined) {
|
|
874
|
+
let newestReply = null;
|
|
875
|
+
const excluded = new Set(s.active_stream_id ? [s.active_stream_id] : []);
|
|
876
|
+
selection: for (const owner of [tailIndex, index])
|
|
877
|
+
for (const candidate of owner.replyTurns(owner === index ? prefixEnd : total, excluded)) {
|
|
878
|
+
const span = turnSpan(idAt(candidate)), rows = readRange(span.first, span.last);
|
|
879
|
+
const derived = await this.deriveRange(s, rows, span.first, span.first ? idAt(span.first - 1) : 'legacy:start', Boolean(journaled), refs, index.toolCalls(span.first, rows.length), reviewRows);
|
|
880
|
+
const newest = newestSettledReply(derived, s.active_stream_id, span.first);
|
|
881
|
+
if (newest !== null) {
|
|
882
|
+
newestReply = newest;
|
|
883
|
+
break selection;
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
cached.newestReply = newestReply;
|
|
887
|
+
}
|
|
888
|
+
let start = end ? spanAt(Math.max(0, end - limit)).first : 0;
|
|
889
|
+
if (pending && before === null)
|
|
890
|
+
start = Math.min(start, prefixEnd);
|
|
891
|
+
let stop = end ? spanAt(end - 1).last : 0;
|
|
892
|
+
// Stamped ids can recur non-contiguously. Include every row of a selected turn, as the full derivation does.
|
|
893
|
+
let derived = [];
|
|
894
|
+
for (;;) {
|
|
895
|
+
for (let at = start; at < stop; at += 1) {
|
|
896
|
+
const span = spanAt(at);
|
|
897
|
+
if (span.first < start) {
|
|
898
|
+
start = span.first;
|
|
899
|
+
at = start - 1;
|
|
900
|
+
}
|
|
901
|
+
stop = Math.max(stop, span.last);
|
|
902
|
+
}
|
|
903
|
+
const rows = readRange(start, stop);
|
|
904
|
+
const calls = index.toolCalls(start, rows.length);
|
|
905
|
+
derived = (await this.deriveRange(s, rows, start, start ? idAt(start - 1) : 'legacy:start', Boolean(journaled), refs, calls, reviewRows));
|
|
906
|
+
const [page, relative] = messageWindowForDisplay(derived, limit, end - start);
|
|
907
|
+
if (relative > 0 || start === 0 || page.filter((m) => messageCountsAsRenderable(m)).length >= limit) {
|
|
908
|
+
let offset = start + relative;
|
|
909
|
+
let messages = page;
|
|
910
|
+
if (pending && before === null) {
|
|
911
|
+
const at = derived.findIndex((m) => m.role === 'user' && m._active_turn_token === pending.activeTurnToken);
|
|
912
|
+
if (at >= 0 && start + at < offset) {
|
|
913
|
+
messages = derived.slice(at, relative + messages.length);
|
|
914
|
+
offset = start + at;
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
return { messages, offset, newestReply: cached.newestReply, ...cached.summary, enoughContext: cached.enoughContext, sources: cached.sources };
|
|
918
|
+
}
|
|
919
|
+
start = spanAt(Math.max(0, start - Math.max(limit, stop - start))).first;
|
|
920
|
+
}
|
|
595
921
|
}
|
|
596
922
|
/** Python `_handle_session_get` on the WebUI sidecar (state.db merge pending). */
|
|
597
|
-
detail(sid, query) {
|
|
923
|
+
async detail(sid, query) {
|
|
598
924
|
if (!sid)
|
|
599
925
|
throw new HttpFailure(400, 'session_id is required');
|
|
926
|
+
sid = this.resolveSessionId(sid);
|
|
600
927
|
const loadMessages = (query.messages ?? '1') !== '0';
|
|
601
928
|
const msgLimit = parseMsgLimit(query.msg_limit ?? null);
|
|
602
929
|
const msgBeforeRaw = query.msg_before;
|
|
603
930
|
const msgBefore = msgBeforeRaw ? (Number.isInteger(Number(msgBeforeRaw)) ? Number(msgBeforeRaw) : null) : null;
|
|
604
931
|
let s;
|
|
605
932
|
try {
|
|
606
|
-
s = this.store.get(sid, { metadataOnly: !loadMessages });
|
|
933
|
+
s = (await this.store.get(sid, { metadataOnly: !loadMessages }));
|
|
607
934
|
}
|
|
608
935
|
catch {
|
|
609
|
-
return this.foreignSessionDetail(sid);
|
|
936
|
+
return (await this.foreignSessionDetail(sid, loadMessages, msgLimit, msgBefore));
|
|
610
937
|
}
|
|
611
|
-
const revisionBefore = this.loadRevision(s);
|
|
938
|
+
const revisionBefore = (await this.loadRevision(s));
|
|
612
939
|
if (!this.visibleToActiveProfile(s.profile)) {
|
|
613
940
|
if (s.profile)
|
|
614
941
|
throw new HttpFailure(409, 'Session belongs to a different profile', { code: 'session_profile_mismatch', session_id: sid, profile: s.profile });
|
|
615
942
|
throw new HttpFailure(404, 'Session not found');
|
|
616
943
|
}
|
|
617
|
-
this.clearStaleStreamState(s);
|
|
618
|
-
const journaled = loadMessages ? this.journaledActiveTurn(s) : null;
|
|
619
|
-
const stateRows = loadMessages ? this.stateDbRows(s) : [];
|
|
620
|
-
let transcript = loadMessages ? this.mergedTranscript(s, s.messages, stateRows) : [];
|
|
944
|
+
await this.clearStaleStreamState(s);
|
|
945
|
+
const journaled = loadMessages ? (await this.journaledActiveTurn(s)) : null;
|
|
621
946
|
const pending = loadMessages ? this.pendingTurn(s) : null;
|
|
622
|
-
|
|
947
|
+
const prefix = loadMessages && msgLimit !== null ? this.prefixIndex(s, revisionBefore) : null;
|
|
948
|
+
const limited = prefix && msgLimit !== null ? (await this.limitedDetail(s, prefix, msgLimit, msgBefore, pending, journaled)) : null;
|
|
949
|
+
const stateRows = loadMessages && !limited ? this.stateDbRows(s) : [];
|
|
950
|
+
let transcript = loadMessages && !limited ? this.mergedTranscript(s, s.messages, stateRows) : [];
|
|
951
|
+
if (pending && !limited)
|
|
623
952
|
transcript = withPendingUserTurn(transcript, pending);
|
|
624
|
-
if (journaled)
|
|
953
|
+
if (journaled && !limited)
|
|
625
954
|
transcript = withoutRunningTurnOutput(transcript, { ...journaled, localCount: s.messages.length });
|
|
626
955
|
// Turn ids, tool outcomes and scenes are computed over the full transcript, so every window reports the same values.
|
|
627
|
-
const all = loadMessages ? this.backgroundLinked(s, hydrateAnchorActivityScenes(withToolCallOutcomes(
|
|
628
|
-
let truncated = [];
|
|
629
|
-
let offset = 0;
|
|
630
|
-
let summaryCount = null;
|
|
631
|
-
let summaryLast = null;
|
|
956
|
+
const all = loadMessages && !limited ? (await this.backgroundLinked(s, hydrateAnchorActivityScenes(withToolCallOutcomes((await this.backgroundUpdates(s, withMarkerKinds(withTurnIds(withAttachmentObjects(transcript)), s))), s.tool_calls, s.active_stream_id), s.anchor_activity_scenes, { activeTurnId: s.active_stream_id, runningScene: !journaled, clipToolResults: msgLimit !== null }))) : [];
|
|
957
|
+
let truncated = limited?.messages ?? [];
|
|
958
|
+
let offset = limited?.offset ?? 0;
|
|
959
|
+
let summaryCount = limited?.total ?? null;
|
|
960
|
+
let summaryLast = limited?.last ?? null;
|
|
632
961
|
if (loadMessages) {
|
|
633
|
-
|
|
634
|
-
|
|
962
|
+
if (!limited)
|
|
963
|
+
[truncated, offset] = messageWindowForDisplay(all, msgLimit, msgBefore);
|
|
635
964
|
// TAL-368: a tail window starts no later than the running turn's prompt, so a turn whose output stays in the
|
|
636
965
|
// transcript (no journal to replay it) keeps its prompt; older pages end before it, so it is never sent twice.
|
|
637
966
|
// ponytail: the window grows with that turn's persisted rows; clip the turn instead if degraded runs get long.
|
|
@@ -641,7 +970,7 @@ export class SessionService {
|
|
|
641
970
|
offset = prompt;
|
|
642
971
|
}
|
|
643
972
|
// Per-row display fields (TAL-186 media, TAL-456 excerpts cut from that display text) only for the rows sent.
|
|
644
|
-
truncated = withBodyExcerpts(withDisplayMedia(truncated, this.mediaProjector(s)), s.active_stream_id);
|
|
973
|
+
truncated = withBodyExcerpts(withDisplayMedia(truncated, (await this.mediaProjector(s))), s.active_stream_id, limited ? limited.newestReply : newestSettledReply(all, s.active_stream_id), offset);
|
|
645
974
|
if (msgLimit !== null)
|
|
646
975
|
truncated = messagesForLimitedPayload(truncated);
|
|
647
976
|
}
|
|
@@ -649,10 +978,10 @@ export class SessionService {
|
|
|
649
978
|
summaryCount = s.metadataMessageCount ?? s.messages.length;
|
|
650
979
|
summaryLast = lastMessageTimestamp(s.messages) ?? 0;
|
|
651
980
|
}
|
|
652
|
-
const windowed = loadMessages && msgLimit !== null && (msgBefore !== null || truncated.length < all.length);
|
|
981
|
+
const windowed = loadMessages && msgLimit !== null && (msgBefore !== null || truncated.length < (limited?.total ?? all.length));
|
|
653
982
|
let toolCalls = loadMessages ? s.tool_calls : [];
|
|
654
983
|
if (windowed)
|
|
655
|
-
toolCalls = toolCallsForMessageWindow(toolCalls, offset, truncated.length);
|
|
984
|
+
toolCalls = prefix ? prefix.toolCalls(offset, truncated.length, true) : toolCallsForMessageWindow(toolCalls, offset, truncated.length);
|
|
656
985
|
const mergedCount = summaryCount ?? all.length;
|
|
657
986
|
let mergedLast = summaryLast ?? 0;
|
|
658
987
|
if (summaryLast === null && all.length) {
|
|
@@ -660,7 +989,7 @@ export class SessionService {
|
|
|
660
989
|
}
|
|
661
990
|
const activeStreamIds = this.deps.runtime.activeStreamIds;
|
|
662
991
|
const raw = {
|
|
663
|
-
...s.compact({ includeRuntime: true, activeStreamIds, contextLengthFor: this.deps.contextLengthFor, modelOptionFor: this.deps.modelOptionFor }),
|
|
992
|
+
...s.compact({ ...(prefix ? { transcriptSummary: prefix.summary() } : {}), includeRuntime: true, activeStreamIds, contextLengthFor: this.deps.contextLengthFor, modelOptionFor: this.deps.modelOptionFor }),
|
|
664
993
|
messages: truncated,
|
|
665
994
|
message_count: mergedCount,
|
|
666
995
|
tool_calls: toolCalls,
|
|
@@ -674,10 +1003,12 @@ export class SessionService {
|
|
|
674
1003
|
};
|
|
675
1004
|
if (loadMessages)
|
|
676
1005
|
raw.transcript_seq = journaled ? { stream_id: journaled.turnId, seq: 0 } : null;
|
|
677
|
-
if (
|
|
1006
|
+
if (limited)
|
|
1007
|
+
attachTodoSnapshot(raw, limited.todo, s.extra[UNSETTLED_TODO_KEY]);
|
|
1008
|
+
else if (loadMessages)
|
|
678
1009
|
attachTodoState(raw, all, s.extra[UNSETTLED_TODO_KEY]);
|
|
679
1010
|
if (loadMessages)
|
|
680
|
-
raw.compression_reference = compressionReference(s, all);
|
|
1011
|
+
raw.compression_reference = limited ? limited.compression : compressionReference(s, all);
|
|
681
1012
|
if (mergedLast) {
|
|
682
1013
|
raw.last_message_at = Math.max(Number(raw.last_message_at ?? 0) || 0, mergedLast);
|
|
683
1014
|
raw.updated_at = Math.max(Number(raw.updated_at ?? 0) || 0, mergedLast);
|
|
@@ -686,30 +1017,39 @@ export class SessionService {
|
|
|
686
1017
|
raw._messages_truncated = isTruncated;
|
|
687
1018
|
raw._messages_offset = offset;
|
|
688
1019
|
raw._msg_limit_max = MAX_MSG_LIMIT;
|
|
689
|
-
const revisionAfter = this.loadRevision(s);
|
|
1020
|
+
const revisionAfter = (await this.loadRevision(s));
|
|
690
1021
|
raw._load_revision = revisionBefore !== null && revisionBefore === revisionAfter ? hashRevision(revisionBefore) : `unstable-${randomUUID().replace(/-/g, '')}`;
|
|
691
|
-
raw
|
|
1022
|
+
Object.assign(raw, withOwnerLocks([raw], limited?.sources ?? this.stateDbSources)[0]);
|
|
692
1023
|
raw.assistant_name = this.assistantName(s);
|
|
693
|
-
raw.workspace_name = this.workspaceNames()(s);
|
|
694
|
-
raw.branched_from = this.branchedFrom(s);
|
|
1024
|
+
raw.workspace_name = (await this.workspaceNames([s]))(s);
|
|
1025
|
+
raw.branched_from = (await this.branchedFrom(s));
|
|
695
1026
|
withSessionWireFlags(raw, activeStreamIds);
|
|
696
1027
|
// A metadata-only read lacks the history the gate counts, so it carries no flag.
|
|
697
1028
|
if (loadMessages)
|
|
698
|
-
raw.can_compress = this.canCompress(s, raw.read_only === true, stateRows);
|
|
1029
|
+
raw.can_compress = limited ? raw.read_only !== true && !isClaudeCodeSessionId(s.session_id) && (!s.active_stream_id || this.compactionWaiters.has(s.active_stream_id)) && limited.enoughContext : this.canCompress(s, raw.read_only === true, stateRows);
|
|
1030
|
+
raw.confirms_deep_truncate = true;
|
|
699
1031
|
raw.pending_steers = raw.active_stream_id ? (this.deps.runtime.pendingSteers?.(str(raw.active_stream_id)) ?? []) : [];
|
|
700
|
-
|
|
1032
|
+
// TAL-1006: the compression a client shows on load: the running manual job's, else the live run's open span.
|
|
1033
|
+
const job = this.compressionJobs.get(s.session_id);
|
|
1034
|
+
raw.run_status = this.currentRunStatus(s.session_id, str(raw.active_stream_id) || null);
|
|
1035
|
+
raw.compression_started_at = job?.status === 'running' ? job.started_at : raw.active_stream_id ? (this.deps.runtime.compressionStartedAt?.(str(raw.active_stream_id)) ?? null) : null;
|
|
1036
|
+
raw.automatic_turns_held = (await this.deps.runtime.automaticTurnsHeld?.(s.session_id)) ?? false;
|
|
1037
|
+
raw.notices = this.deps.runtime.sessionNotices?.(s.session_id) ?? [];
|
|
1038
|
+
raw.goal = this.deps.runtime.sessionGoal?.(s.session_id) ?? null;
|
|
1039
|
+
const publicRow = redactSessionData(raw, this.deps.redactEnabled());
|
|
1040
|
+
return this.owner ? await this.owner.project(publicRow, msgLimit, msgBefore, loadMessages) : publicRow;
|
|
701
1041
|
}
|
|
702
1042
|
/**
|
|
703
1043
|
* TAL-454: the chat `/branch` copied, while it still loads (archived included). A compression continuation is also a
|
|
704
1044
|
* fork of its parent but not a branch, so it links nowhere.
|
|
705
1045
|
*/
|
|
706
|
-
branchedFrom(s) {
|
|
1046
|
+
async branchedFrom(s) {
|
|
707
1047
|
const parentId = s.parent_session_id;
|
|
708
1048
|
if (!parentId || str(s.session_source).trim().toLowerCase() !== 'fork' || s.compression_recovery_source_session_id)
|
|
709
1049
|
return null;
|
|
710
1050
|
let title;
|
|
711
1051
|
try {
|
|
712
|
-
const parent = this.store.get(parentId, { metadataOnly: true, promote: false, cacheOnMiss: false });
|
|
1052
|
+
const parent = (await this.store.get(parentId, { metadataOnly: true, promote: false, cacheOnMiss: false }));
|
|
713
1053
|
if (!this.visibleToActiveProfile(parent.profile))
|
|
714
1054
|
return null;
|
|
715
1055
|
title = parent.title;
|
|
@@ -719,7 +1059,7 @@ export class SessionService {
|
|
|
719
1059
|
// has transcript rows and is not a deleted chat (`claimOrSynthesizeCliSession`'s `was_webui` and `no_foreign_state`).
|
|
720
1060
|
const dbPath = this.stateDbPath(null);
|
|
721
1061
|
const row = stateDbSessionRow(dbPath, parentId);
|
|
722
|
-
if (!row || this.store.wasDeleted(parentId) || this.indexMarksWasWebui(parentId))
|
|
1062
|
+
if (!row || (await this.store.wasDeleted(parentId)) || (await this.indexMarksWasWebui(parentId)))
|
|
723
1063
|
return null;
|
|
724
1064
|
if (!stateDbSessionMessages(dbPath, parentId, { stitch: true }).length)
|
|
725
1065
|
return null;
|
|
@@ -731,12 +1071,12 @@ export class SessionService {
|
|
|
731
1071
|
* TAL-316: the active run whose journal can replay its output from the start. The detail then leaves that output to the
|
|
732
1072
|
* replay; with no active run, no identifiable prompt, or no complete journal, it returns the persisted transcript unchanged.
|
|
733
1073
|
*/
|
|
734
|
-
journaledActiveTurn(s) {
|
|
1074
|
+
async journaledActiveTurn(s) {
|
|
735
1075
|
const turnId = str(s.active_stream_id).trim();
|
|
736
1076
|
const activeTurnToken = buildActiveTurnToken(turnId, s.pending_started_at);
|
|
737
1077
|
if (!turnId || !activeTurnToken || this.deps.runtime.journalDegraded(turnId))
|
|
738
1078
|
return null;
|
|
739
|
-
const summary = this.deps.journal?.findRunSummary(turnId);
|
|
1079
|
+
const summary = (await this.deps.journal?.findRunSummary(turnId));
|
|
740
1080
|
if (summary?.session_id !== s.session_id || summary.journal_pruned)
|
|
741
1081
|
return null;
|
|
742
1082
|
return { turnId, startedAt: Number(s.pending_started_at), activeTurnToken };
|
|
@@ -752,8 +1092,9 @@ export class SessionService {
|
|
|
752
1092
|
return { turnId, startedAt, activeTurnToken, localCount: s.messages.length, prompt: pendingUserRow(text, s.pending_attachments, startedAt, s.pending_user_source || 'webui', turnId) };
|
|
753
1093
|
}
|
|
754
1094
|
/** A session without a sidecar, synthesized from state.db for this profile; 404/409 like the detail. */
|
|
755
|
-
foreignSession(sid) {
|
|
756
|
-
|
|
1095
|
+
async foreignSession(sid) {
|
|
1096
|
+
sid = this.resolveSessionId(sid);
|
|
1097
|
+
const meta = (await this.lookupCliMeta(sid));
|
|
757
1098
|
const profile = str(meta?.profile) || null;
|
|
758
1099
|
const profileAgnostic = str(meta?.source_tag || meta?.raw_source).trim().toLowerCase() === 'claude_code';
|
|
759
1100
|
if (!profileAgnostic && !this.visibleToActiveProfile(profile)) {
|
|
@@ -761,16 +1102,19 @@ export class SessionService {
|
|
|
761
1102
|
throw new HttpFailure(409, 'Session belongs to a different profile', { code: 'session_profile_mismatch', session_id: sid, profile });
|
|
762
1103
|
throw new HttpFailure(404, 'Session not found');
|
|
763
1104
|
}
|
|
764
|
-
const { session: synth, reason } = this.claimOrSynthesizeCliSession(sid, meta);
|
|
1105
|
+
const { session: synth, reason } = (await this.claimOrSynthesizeCliSession(sid, meta));
|
|
765
1106
|
if (!synth || reason === 'was_webui')
|
|
766
1107
|
throw new HttpFailure(404, 'Session not found');
|
|
767
1108
|
return { synth, meta };
|
|
768
1109
|
}
|
|
769
1110
|
/** Python `_handle_session_get` without a sidecar: the state.db transcript as a (read-only or claimable) foreign stub. */
|
|
770
|
-
|
|
771
|
-
|
|
1111
|
+
foreignMessages(messages, session) {
|
|
1112
|
+
return hydrateAnchorActivityScenes(withToolCallOutcomes(withMarkerKinds(withTurnIds(messages), session), [], null), {});
|
|
1113
|
+
}
|
|
1114
|
+
async foreignSessionDetail(sid, loadMessages, msgLimit, msgBefore) {
|
|
1115
|
+
const { synth, meta } = (await this.foreignSession(sid));
|
|
772
1116
|
// The same turn projection as a WebUI session: turn ids, tool outcomes, then each completed turn's scene.
|
|
773
|
-
const msgs =
|
|
1117
|
+
const msgs = this.foreignMessages(synth.messages, synth);
|
|
774
1118
|
const lastTs = Number(msgs[msgs.length - 1]?.timestamp ?? 0) || 0;
|
|
775
1119
|
const sess = {
|
|
776
1120
|
session_id: synth.session_id, title: synth.title, workspace: synth.workspace, model: synth.model, message_count: msgs.length,
|
|
@@ -778,14 +1122,15 @@ export class SessionService {
|
|
|
778
1122
|
pinned: synth.pinned, archived: synth.archived, project_id: synth.project_id ?? null, profile: synth.profile,
|
|
779
1123
|
is_cli_session: synth.is_cli_session, source_tag: synth.source_tag, raw_source: synth.raw_source, session_source: synth.session_source,
|
|
780
1124
|
source_label: synth.source_label, read_only: synth.read_only, can_duplicate: false, messages: msgs, tool_calls: [], transcript_seq: null,
|
|
781
|
-
assistant_name: this.assistantName(synth), workspace_name: this.workspaceNames()(synth),
|
|
1125
|
+
assistant_name: this.assistantName(synth), workspace_name: (await this.workspaceNames([synth]))(synth), confirms_deep_truncate: true,
|
|
782
1126
|
};
|
|
783
1127
|
attachTodoState(sess, msgs);
|
|
784
|
-
const merged = withSessionWireFlags(meta ? mergeCliSidebarMetadata(sess, meta) : sess, this.deps.runtime.activeStreamIds);
|
|
785
|
-
|
|
1128
|
+
const merged = withSessionWireFlags(withOwnerLocks([meta ? mergeCliSidebarMetadata(sess, meta) : sess], this.stateDbSources)[0], this.deps.runtime.activeStreamIds);
|
|
1129
|
+
const publicRow = redactSessionData(merged, this.deps.redactEnabled());
|
|
1130
|
+
return this.owner ? await this.owner.project(publicRow, msgLimit, msgBefore, loadMessages) : publicRow;
|
|
786
1131
|
}
|
|
787
1132
|
/** Clear persisted streaming flags when no live stream backs them (Python `_clear_stale_stream_state`). */
|
|
788
|
-
clearStaleStreamState(session) {
|
|
1133
|
+
async clearStaleStreamState(session) {
|
|
789
1134
|
const streamId = session.active_stream_id;
|
|
790
1135
|
if (!streamId)
|
|
791
1136
|
return false;
|
|
@@ -796,9 +1141,12 @@ export class SessionService {
|
|
|
796
1141
|
const pendingAge = session.pending_started_at ? this.deps.now() - session.pending_started_at : null;
|
|
797
1142
|
if (session.hasPendingPrompt && pendingAge !== null && pendingAge < 30)
|
|
798
1143
|
return false;
|
|
1144
|
+
// Reading a display-only session must not settle the previous Talaria worker's transcript.
|
|
1145
|
+
if ((await this.isReadOnly(session)))
|
|
1146
|
+
return false;
|
|
799
1147
|
let target = session;
|
|
800
1148
|
if (session.loadedMetadataOnly) {
|
|
801
|
-
const full = this.store.load(session.session_id);
|
|
1149
|
+
const full = (await this.store.load(session.session_id));
|
|
802
1150
|
if (!full)
|
|
803
1151
|
return false;
|
|
804
1152
|
target = full;
|
|
@@ -816,7 +1164,7 @@ export class SessionService {
|
|
|
816
1164
|
// run that never finished with an answer, so a dead stream never silently drops what the user sent or what streamed.
|
|
817
1165
|
const turnId = str(target.active_stream_id);
|
|
818
1166
|
// ponytail: one bounded tail read (Python's recovery window); a journal past it recovers its latest output only.
|
|
819
|
-
const events = this.deps.journal?.readRunEventTail(target.session_id, turnId, RECOVERY_JOURNAL_MAX_BYTES, RECOVERY_JOURNAL_MAX_ROWS)
|
|
1167
|
+
const events = (await this.deps.journal?.readRunEventTail(target.session_id, turnId, RECOVERY_JOURNAL_MAX_BYTES, RECOVERY_JOURNAL_MAX_ROWS))?.events ?? [];
|
|
820
1168
|
const { rows: output, answered } = journalOutputRows(events, turnId);
|
|
821
1169
|
const pendingText = str(target.pending_user_message);
|
|
822
1170
|
const attachments = [...target.pending_attachments];
|
|
@@ -843,7 +1191,7 @@ export class SessionService {
|
|
|
843
1191
|
if (target.hasPendingPrompt || output.length) {
|
|
844
1192
|
target.messages.push(...output);
|
|
845
1193
|
if (!answered)
|
|
846
|
-
target.messages.push({ role: 'assistant', content: '**Interrupted:** The reply was interrupted before it could be saved.', timestamp: Math.trunc(this.deps.now()), _error: true, _turn_id: turnId });
|
|
1194
|
+
target.messages.push({ role: 'assistant', content: '**Interrupted:** The reply was interrupted before it could be saved.', _terminal_state: 'interrupted', timestamp: Math.trunc(this.deps.now()), _error: true, _turn_id: turnId });
|
|
847
1195
|
// Settled like a turn: the Agent's own rows are covered by the recovered turn; without them, every row past the run's start is.
|
|
848
1196
|
this.settleStateDb(target, { turnId, previousContext, agentRows: ownRows, startId: stateDbSeenId(before) ?? (read.idCapable ? 0 : null), read });
|
|
849
1197
|
}
|
|
@@ -853,7 +1201,7 @@ export class SessionService {
|
|
|
853
1201
|
target.pending_started_at = null;
|
|
854
1202
|
target.pending_user_source = null;
|
|
855
1203
|
try {
|
|
856
|
-
this.store.save(target, { touchUpdatedAt: false });
|
|
1204
|
+
(await this.store.save(target, { touchUpdatedAt: false }));
|
|
857
1205
|
}
|
|
858
1206
|
catch {
|
|
859
1207
|
return false;
|
|
@@ -864,33 +1212,52 @@ export class SessionService {
|
|
|
864
1212
|
session.pending_attachments = [];
|
|
865
1213
|
session.pending_started_at = null;
|
|
866
1214
|
session.pending_user_source = null;
|
|
867
|
-
this.store.touch(target);
|
|
1215
|
+
await this.store.touch(target);
|
|
868
1216
|
}
|
|
869
1217
|
return true;
|
|
870
1218
|
}
|
|
871
1219
|
// ── list / search ────────────────────────────────────────────────────────
|
|
1220
|
+
/** Expand saved aliases in one worker query independently of the bounded native sidebar window. */
|
|
1221
|
+
async persistedAliasMetadata(rows, existing = []) {
|
|
1222
|
+
const profile = this.deps.activeProfile();
|
|
1223
|
+
const represented = new Set(existing.flatMap((row) => [...sessionLineageIds(row)]));
|
|
1224
|
+
const ids = rows.filter((row) => this.deps.profilesMatch(str(row.profile) || null, profile) && !isClaudeCodeSessionId(str(row.session_id)) && !represented.has(str(row.session_id))).map((row) => str(row.session_id));
|
|
1225
|
+
if (!ids.length)
|
|
1226
|
+
return { rows: existing };
|
|
1227
|
+
const extra = await this.deps.cliSessions(profile, { sourceFilter: null, sessionIds: ids });
|
|
1228
|
+
return { rows: [...new Map([...existing, ...extra.rows].map((row) => [str(row.session_id), row])).values()], storage: extra.storage };
|
|
1229
|
+
}
|
|
872
1230
|
async list(params) {
|
|
873
1231
|
const activeProfile = this.deps.activeProfile();
|
|
1232
|
+
const webuiRows = (await allSessions(this.store, { sidebarMetadataOnly: true }));
|
|
874
1233
|
const wantState = params.showCliSessions || params.showCronSessions || params.showWebhookSessions || params.showKanbanSessions;
|
|
875
1234
|
// Python reads every profile's state.db under all_profiles; this port projects the active profile only.
|
|
876
1235
|
const cliRead = wantState ? await this.deps.cliSessions(activeProfile, { sourceFilter: params.sourceFilter ?? null }) : undefined;
|
|
877
|
-
const claudeCodeRows = params.showCliSessions && params.showClaudeCodeSessions ? this.claudeCodeRows() : [];
|
|
878
|
-
|
|
1236
|
+
const claudeCodeRows = params.showCliSessions && params.showClaudeCodeSessions ? (await this.claudeCodeRows()) : [];
|
|
1237
|
+
let cliRows = cliRead ? [...cliRead.rows, ...claudeCodeRows] : undefined;
|
|
879
1238
|
const gatewayIdentity = loadGatewaySessionIdentityMap(join(this.deps.profileHome(activeProfile), 'sessions', 'sessions.json'));
|
|
880
1239
|
const truncatedSources = cliRead?.truncated;
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
1240
|
+
let agentStorage = cliRead?.storage;
|
|
1241
|
+
if (cliRows) {
|
|
1242
|
+
const expanded = await this.persistedAliasMetadata(webuiRows, cliRows);
|
|
1243
|
+
cliRows = expanded.rows;
|
|
1244
|
+
if (expanded.storage && (expanded.storage.state === 'corrupt' || agentStorage?.state === 'ok'))
|
|
1245
|
+
agentStorage = expanded.storage;
|
|
1246
|
+
}
|
|
1247
|
+
const lookups = await this.listStateDbLookups(webuiRows, (cliRows ?? []).map((r) => str(r.session_id)));
|
|
1248
|
+
const payload = (await buildSessionListPayload(this.store, { ...params, ...(cliRows ? { cliRows } : {}), ...(truncatedSources ? { truncatedSources } : {}), ...(agentStorage ? { agentStorage } : {}), gatewayIdentity, ...lookups, webuiRows, activeProfile, isolatedProfileMode: this.deps.isolatedProfileMode(), profilesMatch: this.deps.profilesMatch }));
|
|
1249
|
+
return sessionListResponse(payload, this.deps.runtime, this.deps.redactEnabled(), this.deps.now(), await this.workspaceNames([...payload.sessions, ...payload.sidebar_reference_sessions]));
|
|
1250
|
+
}
|
|
1251
|
+
async search(q, opts) {
|
|
887
1252
|
const activeProfile = this.deps.activeProfile();
|
|
888
|
-
let sessions = allSessions(this.store);
|
|
1253
|
+
let sessions = (await allSessions(this.store));
|
|
889
1254
|
if (!opts.allProfiles)
|
|
890
1255
|
sessions = sessions.filter((r) => this.deps.profilesMatch(str(r.profile) || null, activeProfile));
|
|
1256
|
+
const metadata = await this.persistedAliasMetadata(sessions);
|
|
1257
|
+
sessions = reconcileLocalSessionRows(sessions, metadata.rows, this.deps.profilesMatch);
|
|
891
1258
|
sessions = withOwnerLocks(sessions, this.stateDbSources);
|
|
892
1259
|
const redact = this.deps.redactEnabled();
|
|
893
|
-
const workspaceName = this.workspaceNames();
|
|
1260
|
+
const workspaceName = await this.workspaceNames(sessions);
|
|
894
1261
|
const redactRow = (item) => {
|
|
895
1262
|
withSessionWireFlags(item, this.deps.runtime.activeStreamIds);
|
|
896
1263
|
item.workspace_name = workspaceName(item);
|
|
@@ -905,17 +1272,16 @@ export class SessionService {
|
|
|
905
1272
|
if (!query)
|
|
906
1273
|
return { sessions: sessions.map((s) => redactRow({ ...s })), all_profiles: opts.allProfiles, active_profile: activeProfile };
|
|
907
1274
|
const terms = sessionSearchTerms(query);
|
|
1275
|
+
const matches = opts.content ? await this.store.searchMessages(sessions.filter((s) => !sessionSearchMatches(str(s.title), terms)).map((s) => str(s.session_id)), terms, opts.depth) : new Map();
|
|
908
1276
|
const results = [];
|
|
909
1277
|
for (const s of sessions) {
|
|
910
1278
|
if (sessionSearchMatches(str(s.title), terms)) {
|
|
911
1279
|
results.push(redactRow({ ...s, match_type: 'title' }));
|
|
912
1280
|
continue;
|
|
913
1281
|
}
|
|
914
|
-
|
|
915
|
-
continue;
|
|
916
|
-
const hit = this.contentMatch(str(s.session_id), terms, opts.depth);
|
|
1282
|
+
const hit = matches.get(str(s.session_id));
|
|
917
1283
|
if (hit)
|
|
918
|
-
results.push(redactRow({ ...s, ...hit }));
|
|
1284
|
+
results.push(redactRow({ ...s, ...this.contentMatch(hit.text, terms) }));
|
|
919
1285
|
}
|
|
920
1286
|
return { sessions: results, query, count: results.length, all_profiles: opts.allProfiles, active_profile: activeProfile };
|
|
921
1287
|
}
|
|
@@ -935,46 +1301,35 @@ export class SessionService {
|
|
|
935
1301
|
if (!query)
|
|
936
1302
|
return { ...base, sessions: rows };
|
|
937
1303
|
const terms = sessionSearchTerms(query);
|
|
1304
|
+
const metadataMatch = (row) => ['workspace', 'workspace_name', 'model', 'model_provider', 'profile', 'source_label'].some((k) => sessionSearchMatches(str(row[k]), terms));
|
|
1305
|
+
const matches = opts.content ? await this.store.searchMessages(rows.filter((row) => !sessionSearchMatches(str(row.title), terms) && !metadataMatch(row)).map((row) => str(row.session_id)), terms, opts.depth) : new Map();
|
|
938
1306
|
const results = [];
|
|
939
1307
|
for (const row of rows) {
|
|
940
1308
|
if (sessionSearchMatches(str(row.title), terms))
|
|
941
1309
|
results.push({ ...row, match_type: 'title' });
|
|
942
|
-
else if (
|
|
1310
|
+
else if (metadataMatch(row))
|
|
943
1311
|
results.push({ ...row, match_type: 'metadata' });
|
|
944
|
-
else
|
|
945
|
-
const hit =
|
|
1312
|
+
else {
|
|
1313
|
+
const hit = matches.get(str(row.session_id));
|
|
946
1314
|
if (hit)
|
|
947
|
-
results.push({ ...row, ...hit });
|
|
1315
|
+
results.push({ ...row, ...this.contentMatch(hit.text, terms) });
|
|
948
1316
|
}
|
|
949
1317
|
}
|
|
950
1318
|
return { ...base, sessions: results, query, count: results.length };
|
|
951
1319
|
}
|
|
952
1320
|
/** The first of a stored session's first `depth` messages (0: all) containing every term, with its redacted excerpt. */
|
|
953
|
-
contentMatch(
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
return null;
|
|
960
|
-
}
|
|
961
|
-
for (const m of depth ? sess.messages.slice(0, depth) : sess.messages) {
|
|
962
|
-
const c = sessionSearchMessageText(m);
|
|
963
|
-
if (!sessionSearchMatches(c, terms))
|
|
964
|
-
continue;
|
|
965
|
-
const item = { match_type: 'content' };
|
|
966
|
-
const preview = sessionSearchPreview(c, terms);
|
|
967
|
-
if (preview)
|
|
968
|
-
item.match_preview = redactText(preview, this.deps.redactEnabled());
|
|
969
|
-
return item;
|
|
970
|
-
}
|
|
971
|
-
return null;
|
|
1321
|
+
contentMatch(text, terms) {
|
|
1322
|
+
const item = { match_type: 'content' };
|
|
1323
|
+
const preview = sessionSearchPreview(text, terms);
|
|
1324
|
+
if (preview)
|
|
1325
|
+
item.match_preview = redactText(preview, this.deps.redactEnabled());
|
|
1326
|
+
return item;
|
|
972
1327
|
}
|
|
973
1328
|
// ── status / usage ───────────────────────────────────────────────────────
|
|
974
|
-
status(sid) {
|
|
975
|
-
const s = this.get404(sid, { metadataOnly: true });
|
|
976
|
-
this.clearStaleStreamState(s);
|
|
977
|
-
const full = this.get404(sid);
|
|
1329
|
+
async status(sid) {
|
|
1330
|
+
const s = (await this.get404(sid, { metadataOnly: true }));
|
|
1331
|
+
await this.clearStaleStreamState(s);
|
|
1332
|
+
const full = (await this.get404(sid));
|
|
978
1333
|
const inp = Math.trunc(full.input_tokens) || 0;
|
|
979
1334
|
const out = Math.trunc(full.output_tokens) || 0;
|
|
980
1335
|
const profile = full.profile || 'default';
|
|
@@ -985,37 +1340,37 @@ export class SessionService {
|
|
|
985
1340
|
catch {
|
|
986
1341
|
hermesHome = '';
|
|
987
1342
|
}
|
|
988
|
-
const { is_streaming: streaming, active_stream_id: live, read_only: readOnly, active_turn_origin: origin } = this.wireRow(full);
|
|
1343
|
+
const { is_streaming: streaming, active_stream_id: live, read_only: readOnly, read_only_reason: readOnlyReason, active_turn_origin: origin } = (await this.wireRow(full));
|
|
989
1344
|
return {
|
|
990
1345
|
session_id: full.session_id, title: full.title, model: full.model, profile, hermes_home: hermesHome, workspace: full.workspace, personality: full.personality,
|
|
991
|
-
message_count: full.messages.length, created_at: full.created_at, updated_at: full.updated_at, agent_running: streaming, is_streaming: streaming, active_stream_id: live, active_turn_origin: origin, read_only: readOnly,
|
|
1346
|
+
message_count: full.messages.length, created_at: full.created_at, updated_at: full.updated_at, agent_running: streaming, is_streaming: streaming, active_stream_id: live, active_turn_origin: origin, read_only: readOnly, read_only_reason: readOnlyReason,
|
|
992
1347
|
input_tokens: inp, output_tokens: out, total_tokens: inp + out, estimated_cost: full.estimated_cost,
|
|
993
1348
|
};
|
|
994
1349
|
}
|
|
995
|
-
usage(sid) {
|
|
996
|
-
const s = this.get404(sid);
|
|
1350
|
+
async usage(sid) {
|
|
1351
|
+
const s = (await this.get404(sid));
|
|
997
1352
|
const inp = Math.trunc(s.input_tokens) || 0;
|
|
998
1353
|
const out = Math.trunc(s.output_tokens) || 0;
|
|
999
1354
|
return { input_tokens: inp, output_tokens: out, total_tokens: inp + out, estimated_cost: s.estimated_cost, model: s.model };
|
|
1000
1355
|
}
|
|
1001
1356
|
// ── creation ─────────────────────────────────────────────────────────────
|
|
1002
|
-
resolveNewSessionWorkspace(body, visiblePrevSessionId, profile) {
|
|
1357
|
+
async resolveNewSessionWorkspace(body, visiblePrevSessionId, profile) {
|
|
1003
1358
|
const candidate = body.workspace;
|
|
1004
1359
|
if (!candidate)
|
|
1005
1360
|
return null;
|
|
1006
1361
|
const value = str(candidate);
|
|
1007
1362
|
if (body.workspace_inherited_from_prev_session !== true || !visiblePrevSessionId)
|
|
1008
|
-
return this.deps.workspaces.resolveTrusted(value, profile);
|
|
1363
|
+
return (await this.deps.workspaces.resolveTrusted(value, profile));
|
|
1009
1364
|
let previous;
|
|
1010
1365
|
try {
|
|
1011
|
-
previous = this.store.get(visiblePrevSessionId, { metadataOnly: true });
|
|
1366
|
+
previous = (await this.store.get(visiblePrevSessionId, { metadataOnly: true }));
|
|
1012
1367
|
}
|
|
1013
1368
|
catch {
|
|
1014
|
-
return this.deps.workspaces.resolveTrusted(value, profile);
|
|
1369
|
+
return (await this.deps.workspaces.resolveTrusted(value, profile));
|
|
1015
1370
|
}
|
|
1016
1371
|
if (previous.workspace !== value)
|
|
1017
|
-
return this.deps.workspaces.resolveTrusted(value, profile);
|
|
1018
|
-
const [workspace] = this.deps.workspaces.resolveImplicitWithRecovery(value, (p) => this.deps.workspaces.profileDefaultWorkspaceForBoot(p), profile);
|
|
1372
|
+
return (await this.deps.workspaces.resolveTrusted(value, profile));
|
|
1373
|
+
const [workspace] = (await this.deps.workspaces.resolveImplicitWithRecovery(value, async (p) => (await this.deps.workspaces.profileDefaultWorkspaceForBoot(p)), profile));
|
|
1019
1374
|
return workspace;
|
|
1020
1375
|
}
|
|
1021
1376
|
/** The server owns the names' normalization (TAL-631): trimmed, blanks dropped, and nothing left is the profile's defaults. */
|
|
@@ -1029,25 +1384,25 @@ export class SessionService {
|
|
|
1029
1384
|
const names = toolsets.map((t) => t.trim()).filter(Boolean);
|
|
1030
1385
|
return names.length ? names : null;
|
|
1031
1386
|
}
|
|
1032
|
-
create(body, opts = {}) {
|
|
1387
|
+
async create(body, opts = {}) {
|
|
1033
1388
|
// Python `new_session`: a body without a profile takes the request's active profile (cookie first).
|
|
1034
1389
|
const profile = (typeof body.profile === 'string' && body.profile) || this.deps.activeProfile();
|
|
1035
1390
|
let prevSessionId = typeof body.prev_session_id === 'string' && body.prev_session_id ? body.prev_session_id : null;
|
|
1036
|
-
if (prevSessionId && !this.sessionIdVisible(prevSessionId))
|
|
1391
|
+
if (prevSessionId && !(await this.sessionIdVisible(prevSessionId)))
|
|
1037
1392
|
prevSessionId = null;
|
|
1038
1393
|
// Python: leaving a session for a new one flushes the previous session's memory in the background (W6).
|
|
1039
1394
|
if (prevSessionId)
|
|
1040
|
-
this.deps.commitSessionMemory?.(prevSessionId);
|
|
1395
|
+
await this.deps.commitSessionMemory?.(prevSessionId);
|
|
1041
1396
|
let workspace;
|
|
1042
1397
|
try {
|
|
1043
|
-
workspace = this.resolveNewSessionWorkspace(body, prevSessionId, profile);
|
|
1398
|
+
workspace = (await this.resolveNewSessionWorkspace(body, prevSessionId, profile));
|
|
1044
1399
|
}
|
|
1045
1400
|
catch (error) {
|
|
1046
1401
|
throw new HttpFailure(400, error.message);
|
|
1047
1402
|
}
|
|
1048
1403
|
const [model, provider] = this.deps.modelStateFromRequest(body.model, body.model_provider, null);
|
|
1049
1404
|
const toolsets = this.validateToolsetsShape(body.enabled_toolsets);
|
|
1050
|
-
const s = this.store.newSession({ workspace, model, modelProvider: provider, profile, projectId: typeof body.project_id === 'string' && body.project_id ? body.project_id : null, worktree: opts.worktree ?? null, enabledToolsets: toolsets });
|
|
1405
|
+
const s = (await this.store.newSession({ workspace, model, modelProvider: provider, profile, projectId: typeof body.project_id === 'string' && body.project_id ? body.project_id : null, worktree: opts.worktree ?? null, enabledToolsets: toolsets }));
|
|
1051
1406
|
if (opts.worktree)
|
|
1052
1407
|
this.publish('session_new', s.profile, s.session_id);
|
|
1053
1408
|
return s;
|
|
@@ -1058,17 +1413,17 @@ export class SessionService {
|
|
|
1058
1413
|
const title = nextTitle.trim().slice(0, 80) || 'Untitled';
|
|
1059
1414
|
let current;
|
|
1060
1415
|
try {
|
|
1061
|
-
current = this.store.get(sid);
|
|
1416
|
+
current = (await this.store.get(sid));
|
|
1062
1417
|
}
|
|
1063
1418
|
catch {
|
|
1064
1419
|
throw new HttpFailure(404, 'Session not found');
|
|
1065
1420
|
}
|
|
1066
|
-
if (this.isReadOnly(current))
|
|
1421
|
+
if ((await this.isReadOnly(current)))
|
|
1067
1422
|
throw new HttpFailure(403, `Session ${sid} is read-only`);
|
|
1068
|
-
await this.
|
|
1423
|
+
await this.withMutationLock(sid, async () => {
|
|
1069
1424
|
current.title = title;
|
|
1070
1425
|
markSessionTitleGenerated(current);
|
|
1071
|
-
this.store.save(current, { touchUpdatedAt: false });
|
|
1426
|
+
await this.store.save(current, { touchUpdatedAt: false });
|
|
1072
1427
|
});
|
|
1073
1428
|
// An explicit regenerate is user-requested, so state.db takes it over a manual name.
|
|
1074
1429
|
await this.deps.syncTitle(current, true);
|
|
@@ -1076,29 +1431,29 @@ export class SessionService {
|
|
|
1076
1431
|
return current;
|
|
1077
1432
|
}
|
|
1078
1433
|
async rename(sid, rawTitle) {
|
|
1079
|
-
const s = this.mutationTarget(sid, 'renamed');
|
|
1080
|
-
await this.
|
|
1434
|
+
const s = (await this.mutationTarget(sid, 'renamed'));
|
|
1435
|
+
await this.withMutationLock(sid, async () => {
|
|
1081
1436
|
applySessionTitleRename(s, rawTitle);
|
|
1082
|
-
this.store.save(s);
|
|
1437
|
+
await this.store.save(s);
|
|
1083
1438
|
});
|
|
1084
1439
|
await this.deps.syncTitle(s, true);
|
|
1085
1440
|
this.publish('session_rename', s.profile, s.session_id);
|
|
1086
|
-
return { session: this.wireRow(s) };
|
|
1441
|
+
return { session: (await this.wireRow(s)) };
|
|
1087
1442
|
}
|
|
1088
1443
|
// The pin quota is counted across sessions, so the count-and-save transaction runs on one process-wide chain;
|
|
1089
1444
|
// per-session locks alone would let two pins of different sessions both see the last free slot.
|
|
1090
1445
|
pinChain = Promise.resolve();
|
|
1091
1446
|
pin(sid, pinRequested) {
|
|
1092
|
-
const run = this.pinChain.then(() => this.pinUnserialized(sid, pinRequested));
|
|
1447
|
+
const run = this.pinChain.then(async () => (await this.pinUnserialized(sid, pinRequested)));
|
|
1093
1448
|
this.pinChain = run.catch(() => undefined);
|
|
1094
1449
|
return run;
|
|
1095
1450
|
}
|
|
1096
1451
|
async pinUnserialized(sid, pinRequested) {
|
|
1097
|
-
this.
|
|
1098
|
-
let s = this.get404(sid);
|
|
1099
|
-
s = this.store.ensureFull(sid, s);
|
|
1452
|
+
await this.rejectViewOnly(sid, 'modified');
|
|
1453
|
+
let s = (await this.get404(sid));
|
|
1454
|
+
s = (await this.store.ensureFull(sid, s));
|
|
1100
1455
|
if (pinRequested && !s.pinned) {
|
|
1101
|
-
const rows = allSessions(this.store);
|
|
1456
|
+
const rows = (await allSessions(this.store));
|
|
1102
1457
|
const byId = new Map(rows.filter((r) => r.session_id).map((r) => [str(r.session_id), r]));
|
|
1103
1458
|
byId.set(s.session_id, s.compact());
|
|
1104
1459
|
const targetLineage = lineageRootId(s.compact(), byId);
|
|
@@ -1108,38 +1463,38 @@ export class SessionService {
|
|
|
1108
1463
|
if (pinnedLineages.size >= limit)
|
|
1109
1464
|
throw new HttpFailure(400, `Up to ${limit} sessions can be pinned. Unpin one before pinning another.`);
|
|
1110
1465
|
}
|
|
1111
|
-
await this.
|
|
1466
|
+
await this.withMutationLock(sid, async () => {
|
|
1112
1467
|
s.pinned = pinRequested;
|
|
1113
|
-
this.store.save(s);
|
|
1114
|
-
});
|
|
1468
|
+
await this.store.save(s);
|
|
1469
|
+
}, { allowReadOnly: true });
|
|
1115
1470
|
this.publish('session_pin', s.profile, s.session_id);
|
|
1116
|
-
return { ok: true, session: this.wireRow(s) };
|
|
1471
|
+
return { ok: true, session: (await this.wireRow(s)) };
|
|
1117
1472
|
}
|
|
1118
1473
|
async archive(sid, archived) {
|
|
1119
|
-
this.
|
|
1120
|
-
let s = this.get404(sid);
|
|
1121
|
-
s = this.store.ensureFull(sid, s);
|
|
1122
|
-
await this.
|
|
1474
|
+
await this.rejectViewOnly(sid, 'archived');
|
|
1475
|
+
let s = (await this.get404(sid));
|
|
1476
|
+
s = (await this.store.ensureFull(sid, s));
|
|
1477
|
+
await this.withMutationLock(sid, async () => {
|
|
1123
1478
|
s.archived = archived;
|
|
1124
1479
|
if (archived)
|
|
1125
1480
|
s.pinned = false;
|
|
1126
|
-
this.store.save(s, { touchUpdatedAt: false });
|
|
1127
|
-
});
|
|
1481
|
+
await this.store.save(s, { touchUpdatedAt: false });
|
|
1482
|
+
}, { allowReadOnly: true });
|
|
1128
1483
|
this.publish('session_archive', s.profile, s.session_id);
|
|
1129
|
-
return { ok: true, session: this.wireRow(s), ...worktreeRetainedPayload(s) };
|
|
1484
|
+
return { ok: true, session: (await this.wireRow(s)), ...worktreeRetainedPayload(s) };
|
|
1130
1485
|
}
|
|
1131
1486
|
async move(sid, targetProjectId) {
|
|
1132
|
-
const s = this.mutationTarget(sid, 'moved');
|
|
1487
|
+
const s = (await this.mutationTarget(sid, 'moved'));
|
|
1133
1488
|
if (targetProjectId) {
|
|
1134
1489
|
const sessionProfile = s.profile || this.deps.activeProfile();
|
|
1135
|
-
const target = this.deps.projects.load().find((p) => p.project_id === targetProjectId);
|
|
1490
|
+
const target = (await this.deps.projects.load()).find((p) => p.project_id === targetProjectId);
|
|
1136
1491
|
if (!target || !this.deps.profilesMatch(target.profile ?? null, sessionProfile))
|
|
1137
1492
|
throw new HttpFailure(404, 'Project not found');
|
|
1138
1493
|
}
|
|
1139
1494
|
try {
|
|
1140
|
-
await this.
|
|
1495
|
+
await this.withMutationLock(sid, async () => {
|
|
1141
1496
|
s.project_id = targetProjectId;
|
|
1142
|
-
this.store.save(s);
|
|
1497
|
+
await this.store.save(s);
|
|
1143
1498
|
}, { timeoutMs: 5000 });
|
|
1144
1499
|
}
|
|
1145
1500
|
catch (error) {
|
|
@@ -1148,67 +1503,108 @@ export class SessionService {
|
|
|
1148
1503
|
throw error;
|
|
1149
1504
|
}
|
|
1150
1505
|
this.publish('session_move', s.profile, s.session_id);
|
|
1151
|
-
return { ok: true, session: this.wireRow(s) };
|
|
1152
|
-
}
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
const
|
|
1156
|
-
const
|
|
1157
|
-
const
|
|
1158
|
-
|
|
1506
|
+
return { ok: true, session: (await this.wireRow(s)) };
|
|
1507
|
+
}
|
|
1508
|
+
modelConfirmationKey = randomUUID();
|
|
1509
|
+
async confirmModelSwitch(s, model, provider, token, retry = false) {
|
|
1510
|
+
const profile = s.profile ?? this.deps.activeProfile();
|
|
1511
|
+
const home = this.deps.profileHome(profile);
|
|
1512
|
+
const context = contextUsage(s).context_used_tokens;
|
|
1513
|
+
const messages = this.modelContext(s);
|
|
1514
|
+
const before = JSON.stringify([s.model, s.model_provider, context, s.context_length, messages]);
|
|
1515
|
+
let decision;
|
|
1159
1516
|
try {
|
|
1160
|
-
|
|
1517
|
+
const sidecar = this.deps.sidecar?.();
|
|
1518
|
+
if (!sidecar)
|
|
1519
|
+
throw new Error('Agent sidecar is unavailable');
|
|
1520
|
+
decision = await sidecar.call('models.selection_warnings', { profile_home: home, session_id: s.session_id, model, provider, current_model: s.model, current_provider: s.model_provider, context_tokens: context, messages });
|
|
1161
1521
|
}
|
|
1162
1522
|
catch (error) {
|
|
1163
|
-
throw new HttpFailure(
|
|
1523
|
+
throw new HttpFailure(503, `Cannot check model selection warnings: ${error.message}`);
|
|
1164
1524
|
}
|
|
1165
|
-
|
|
1525
|
+
if (before !== JSON.stringify([s.model, s.model_provider, contextUsage(s).context_used_tokens, s.context_length, this.modelContext(s)])) {
|
|
1526
|
+
if (retry)
|
|
1527
|
+
throw new HttpFailure(503, 'The session changed while checking model warnings. Try the selection again.');
|
|
1528
|
+
return this.confirmModelSwitch(s, model, provider, null, true);
|
|
1529
|
+
}
|
|
1530
|
+
if (!decision.warnings.length)
|
|
1531
|
+
return;
|
|
1532
|
+
const signature = JSON.stringify([profile, home, s.session_id, s.model, s.model_provider, model, provider, context, s.context_length, messages, decision]);
|
|
1533
|
+
const confirmToken = createHmac('sha256', this.modelConfirmationKey).update(signature).digest('hex');
|
|
1534
|
+
if (token !== confirmToken)
|
|
1535
|
+
throw new HttpFailure(409, decision.warnings.map((w) => w.message).join('\n\n'), { type: 'model_confirmation_required', warnings: decision.warnings, confirm_token: confirmToken });
|
|
1536
|
+
}
|
|
1537
|
+
async update(sid, body) {
|
|
1538
|
+
let s = (await this.mutationTarget(sid, 'updated'));
|
|
1539
|
+
let oldWs = s.workspace;
|
|
1540
|
+
let newWs = oldWs;
|
|
1541
|
+
await this.withMutationLock(sid, async () => {
|
|
1542
|
+
s = await this.mutationTarget(sid, 'updated');
|
|
1543
|
+
oldWs = s.workspace;
|
|
1544
|
+
try {
|
|
1545
|
+
newWs = await this.deps.workspaces.resolveTrusted(typeof body.workspace === 'string' ? body.workspace : s.workspace, s.profile);
|
|
1546
|
+
}
|
|
1547
|
+
catch (error) {
|
|
1548
|
+
throw new HttpFailure(400, error.message);
|
|
1549
|
+
}
|
|
1550
|
+
const oldModel = s.model;
|
|
1551
|
+
const oldProvider = s.model_provider;
|
|
1552
|
+
const hasModel = 'model' in body || 'model_provider' in body;
|
|
1553
|
+
const [model, provider] = hasModel ? this.deps.modelStateFromRequest('model' in body ? body.model : s.model, 'model_provider' in body ? body.model_provider : undefined, s.model_provider) : [s.model, s.model_provider];
|
|
1554
|
+
const nextModel = model ?? s.model;
|
|
1555
|
+
const changed = hasModel && (str(oldModel) !== str(nextModel) || str(oldProvider) !== str(provider));
|
|
1556
|
+
const contextLength = changed ? (this.deps.resolveContextLength ? await this.deps.resolveContextLength(nextModel, provider, s.profile) : this.deps.contextLengthFor(nextModel, provider)) : s.context_length;
|
|
1557
|
+
if (changed)
|
|
1558
|
+
await this.confirmModelSwitch(s, nextModel ?? '', provider, body.confirm_token);
|
|
1559
|
+
this.assertWritable(s);
|
|
1166
1560
|
s.workspace = newWs;
|
|
1167
|
-
if (
|
|
1168
|
-
|
|
1169
|
-
if (model !== null)
|
|
1170
|
-
s.model = model;
|
|
1561
|
+
if (hasModel) {
|
|
1562
|
+
s.model = nextModel;
|
|
1171
1563
|
s.model_provider = provider;
|
|
1172
|
-
if (
|
|
1173
|
-
s.context_length =
|
|
1174
|
-
s
|
|
1175
|
-
|
|
1176
|
-
this.deps.runtime.evictAgent(sid);
|
|
1564
|
+
if (changed) {
|
|
1565
|
+
s.context_length = contextLength;
|
|
1566
|
+
forgetContextFigures(s);
|
|
1567
|
+
await this.deps.runtime.evictAgent(sid);
|
|
1177
1568
|
}
|
|
1178
1569
|
}
|
|
1179
|
-
this.store.save(s);
|
|
1570
|
+
await this.store.save(s);
|
|
1180
1571
|
});
|
|
1181
1572
|
if (str(oldWs) !== str(newWs))
|
|
1182
1573
|
this.deps.runtime.closeTerminal(sid);
|
|
1183
|
-
this.deps.workspaces.setLastWorkspace(newWs, s.profile);
|
|
1184
|
-
return { session: this.publicSession(s) };
|
|
1574
|
+
await this.deps.workspaces.setLastWorkspace(newWs, s.profile);
|
|
1575
|
+
return { session: (await this.publicSession(s)) };
|
|
1185
1576
|
}
|
|
1186
1577
|
async setToolsets(sid, toolsets) {
|
|
1187
|
-
this.
|
|
1578
|
+
await this.rejectViewOnly(sid, 'modified');
|
|
1188
1579
|
const cleaned = this.validateToolsetsShape(toolsets);
|
|
1189
|
-
const s = this.mutationTarget(sid, 'modified');
|
|
1190
|
-
await this.
|
|
1580
|
+
const s = (await this.mutationTarget(sid, 'modified'));
|
|
1581
|
+
await this.withMutationLock(sid, async () => {
|
|
1191
1582
|
s.enabled_toolsets = cleaned;
|
|
1192
|
-
this.store.save(s);
|
|
1583
|
+
await this.store.save(s);
|
|
1193
1584
|
});
|
|
1194
1585
|
return { ok: true, enabled_toolsets: s.enabled_toolsets };
|
|
1195
1586
|
}
|
|
1196
1587
|
/** Python `/api/personality/set` persistence: the name only; the prompt is resolved by the caller. */
|
|
1197
1588
|
async setPersonality(sid, name) {
|
|
1198
|
-
this.
|
|
1199
|
-
const s = this.mutationTarget(sid, 'modified');
|
|
1200
|
-
await this.
|
|
1589
|
+
await this.rejectViewOnly(sid, 'modified');
|
|
1590
|
+
const s = (await this.mutationTarget(sid, 'modified'));
|
|
1591
|
+
await this.withMutationLock(sid, async () => {
|
|
1201
1592
|
s.personality = name;
|
|
1202
|
-
this.store.save(s);
|
|
1593
|
+
await this.store.save(s);
|
|
1203
1594
|
});
|
|
1204
1595
|
return typeof s.personality === 'string' ? s.personality : null;
|
|
1205
1596
|
}
|
|
1206
1597
|
// ── transcript mutations ─────────────────────────────────────────────────
|
|
1207
|
-
|
|
1208
|
-
|
|
1598
|
+
/**
|
|
1599
|
+
* TAL-929: under a `hold`, a cut that discards more than one user turn changes nothing and answers with its counts,
|
|
1600
|
+
* unless `confirmToken` is the token of the rows it would remove now: a transcript that changed since asks again.
|
|
1601
|
+
* Without one (a released App, which asked on the device) the cut proceeds as before.
|
|
1602
|
+
*/
|
|
1603
|
+
async truncate(sid, keepRaw, hold = null) {
|
|
1604
|
+
await this.rejectViewOnly(sid, 'modified');
|
|
1209
1605
|
if (keepRaw === null || keepRaw === undefined)
|
|
1210
1606
|
throw new HttpFailure(400, 'Missing required field(s): keep_count');
|
|
1211
|
-
const s = this.mutationTarget(sid, 'modified');
|
|
1607
|
+
const s = (await this.mutationTarget(sid, 'modified'));
|
|
1212
1608
|
// Python `int(body["keep_count"])`: a float truncates, a non-integer string is rejected.
|
|
1213
1609
|
let keep;
|
|
1214
1610
|
if (typeof keepRaw === 'number')
|
|
@@ -1221,26 +1617,61 @@ export class SessionService {
|
|
|
1221
1617
|
throw new HttpFailure(400, 'keep_count must be an integer');
|
|
1222
1618
|
if (keep < 0)
|
|
1223
1619
|
throw new HttpFailure(400, 'keep_count must be non-negative');
|
|
1224
|
-
await this.
|
|
1620
|
+
const refused = await this.withMutationLock(sid, async () => {
|
|
1621
|
+
// One state.db read decides and marks the boundary, so a row committed meanwhile is neither counted nor hidden.
|
|
1622
|
+
const read = this.stateDbRead(s);
|
|
1623
|
+
if (hold) {
|
|
1624
|
+
// A failed read cannot say whether a CLI continuation would go too: fail closed. The Agent keeps a state.db for every
|
|
1625
|
+
// profile it has run in, so a missing one is never evidence of a session without state.db rows.
|
|
1626
|
+
if (!read.ok)
|
|
1627
|
+
throw new HttpFailure(503, read.reason ?? 'The session history could not be read. Please try again in a moment.');
|
|
1628
|
+
const held = (await this.heldCut(s, keep, read.rows));
|
|
1629
|
+
if (held && held.confirm_token !== hold.confirmToken)
|
|
1630
|
+
return held;
|
|
1631
|
+
}
|
|
1632
|
+
// A cut inside the state.db continuation keeps the continuation's rows before it: the boundary below hides every
|
|
1633
|
+
// row this read saw, so those rows join the stored transcript and model history first.
|
|
1634
|
+
if (keep > s.messages.length) {
|
|
1635
|
+
if (s.context_messages.length)
|
|
1636
|
+
s.context_messages = this.modelContext(s, read.rows);
|
|
1637
|
+
s.messages = this.mergedTranscript(s, s.messages, read.rows);
|
|
1638
|
+
}
|
|
1225
1639
|
truncateSessionAtKeep(s, keep);
|
|
1226
|
-
this.markStateDbSeen(s,
|
|
1227
|
-
this.store.save(s);
|
|
1640
|
+
this.markStateDbSeen(s, read, { boundary: true });
|
|
1641
|
+
await this.store.save(s);
|
|
1642
|
+
return null;
|
|
1228
1643
|
});
|
|
1229
|
-
|
|
1230
|
-
|
|
1644
|
+
if (refused)
|
|
1645
|
+
return refused;
|
|
1646
|
+
await this.deps.runtime.evictAgent(sid);
|
|
1647
|
+
return { ok: true, session: (await this.publicSession(s)) };
|
|
1648
|
+
}
|
|
1649
|
+
/**
|
|
1650
|
+
* TAL-929: the confirmation a cut at `keep` needs, or null for one that removes at most one user turn. Counted on the rows
|
|
1651
|
+
* the detail shows, state.db continuation included, since `keep` indexes them: the user turns the cut removes, and the
|
|
1652
|
+
* messages after the row it starts at.
|
|
1653
|
+
*/
|
|
1654
|
+
async heldCut(s, keep, stateRows) {
|
|
1655
|
+
const transcript = this.mergedTranscript(s, s.messages, stateRows);
|
|
1656
|
+
const cut = ((await this.publicSession(s, true, transcript)).messages ?? []).map((m, i) => ({ m, i })).filter(({ m, i }) => i >= keep && m._display === 'row');
|
|
1657
|
+
const userTurns = cut.filter(({ m }) => m.role === 'user').length;
|
|
1658
|
+
if (userTurns <= 1)
|
|
1659
|
+
return null;
|
|
1660
|
+
const token = createHash('sha256').update(JSON.stringify(transcript.slice(keep))).digest('hex').slice(0, 32);
|
|
1661
|
+
return { ok: false, confirm_required: true, discard_user_turns: userTurns, discard_messages: cut.filter(({ i }) => i > keep).length, confirm_token: token };
|
|
1231
1662
|
}
|
|
1232
1663
|
// ── manual compression (TAL-255) ────────────────────────────────────────
|
|
1233
1664
|
/**
|
|
1234
1665
|
* Python `_handle_session_compress_start`: validate, then join the session's running job or start a fresh one (a
|
|
1235
1666
|
* finished job is replaced). The stale-runtime refusal comes before a job exists; a running job is joined without it.
|
|
1236
1667
|
*/
|
|
1237
|
-
async startCompression(sid, focusRaw) {
|
|
1668
|
+
async startCompression(sid, focusRaw, opts = {}) {
|
|
1238
1669
|
const running = () => { const job = this.compressionJobs.get(sid); return job?.status === 'running' ? job : undefined; };
|
|
1239
1670
|
// A running job is joined first: once it has installed its short context, the guards below would refuse a new one.
|
|
1240
1671
|
const existing = running();
|
|
1241
1672
|
if (existing)
|
|
1242
1673
|
return existing;
|
|
1243
|
-
const { s } = this.compressionTarget(sid);
|
|
1674
|
+
const { s } = (await this.compressionTarget(sid));
|
|
1244
1675
|
const focusTopic = str(focusRaw).trim().slice(0, 500) || null;
|
|
1245
1676
|
await ensureAgentRuntimeCurrent(this.deps.sidecar?.() ?? null);
|
|
1246
1677
|
// Another start may have admitted a job while the runtime check awaited.
|
|
@@ -1253,8 +1684,8 @@ export class SessionService {
|
|
|
1253
1684
|
throw new HttpFailure(409, `Profile '${str(profile)}' is being deleted.`);
|
|
1254
1685
|
const release = this.deps.profileActivity?.(profile);
|
|
1255
1686
|
const now = this.deps.now();
|
|
1256
|
-
const job = { session_id: sid, focus_topic: focusTopic, status: 'running', started_at: now, updated_at: now, done: Promise.resolve() };
|
|
1257
|
-
job.done = this.compressSession(
|
|
1687
|
+
const job = { session_id: sid, focus_topic: focusTopic, status: 'running', started_at: now, updated_at: now, turns: new Set(), automatic: opts.automatic === true, done: Promise.resolve() };
|
|
1688
|
+
job.done = this.compressSession(job).finally(() => { release?.(); this.compressionJobs.expireLater(job); }).then((result) => { Object.assign(job, { status: 'done', result, updated_at: this.deps.now() }); }, (error) => {
|
|
1258
1689
|
const known = error instanceof HttpFailure;
|
|
1259
1690
|
if (!known)
|
|
1260
1691
|
this.deps.log(`[webui] Manual compression worker failed for session ${sid}: ${error.message}`);
|
|
@@ -1263,28 +1694,121 @@ export class SessionService {
|
|
|
1263
1694
|
this.compressionJobs.set(job);
|
|
1264
1695
|
return job;
|
|
1265
1696
|
}
|
|
1697
|
+
/**
|
|
1698
|
+
* TAL-1005: compact the session in its joinable compression job before the next turn reads its history. A running job
|
|
1699
|
+
* is always joined; a new one starts when the session is `due` (`compactionDue`, read from the store when omitted).
|
|
1700
|
+
* `turn` is the waiting turn's stream: the job leaves its rows alone. `onWait` runs once a job is being waited on; an abort of `signal` (the turn's Stop) ends the wait but not the
|
|
1701
|
+
* job. True when the job installed a compressed context. Never throws: a failed job leaves the Agent's own preflight.
|
|
1702
|
+
*/
|
|
1703
|
+
async ensureCompacted(sid, opts = {}) {
|
|
1704
|
+
const { turn, signal } = opts;
|
|
1705
|
+
if (turn)
|
|
1706
|
+
this.compactionWaiters.add(turn);
|
|
1707
|
+
try {
|
|
1708
|
+
let job = this.compressionJobs.get(sid);
|
|
1709
|
+
if (job?.status !== 'running')
|
|
1710
|
+
job = (opts.due ?? (await this.storedCompactionDue(sid))) ? await this.startCompression(sid, null, { automatic: true }) : undefined;
|
|
1711
|
+
if (!job || signal?.aborted)
|
|
1712
|
+
return false;
|
|
1713
|
+
if (turn)
|
|
1714
|
+
job.turns.add(turn);
|
|
1715
|
+
await opts.onWait?.();
|
|
1716
|
+
const aborted = new Promise((resolve) => { signal?.addEventListener('abort', () => { resolve(); }, { once: true }); });
|
|
1717
|
+
await Promise.race([job.done, aborted]);
|
|
1718
|
+
return !signal?.aborted && job.status === 'done';
|
|
1719
|
+
}
|
|
1720
|
+
catch (error) {
|
|
1721
|
+
this.deps.log(`[webui] compaction before the next turn of ${sid} did not run: ${error.message}`);
|
|
1722
|
+
return false;
|
|
1723
|
+
}
|
|
1724
|
+
finally {
|
|
1725
|
+
if (turn)
|
|
1726
|
+
this.compactionWaiters.delete(turn);
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
/**
|
|
1730
|
+
* TAL-1005: measure the model context again when it holds a post-compression estimate the rows after it may have
|
|
1731
|
+
* outgrown (a waiting turn's prompt, a stopped or failed turn's work). Skipped while a turn owns the session, which
|
|
1732
|
+
* settles its own figures, or once the estimate changed meanwhile. True when it stored a new estimate.
|
|
1733
|
+
*/
|
|
1734
|
+
async remeasureContext(sid) {
|
|
1735
|
+
const sidecar = this.deps.sidecar?.();
|
|
1736
|
+
let s;
|
|
1737
|
+
try {
|
|
1738
|
+
s = (await this.store.get(sid));
|
|
1739
|
+
}
|
|
1740
|
+
catch {
|
|
1741
|
+
return false;
|
|
1742
|
+
}
|
|
1743
|
+
const before = s.post_compression_context_tokens_estimate;
|
|
1744
|
+
if (before === null || s.active_stream_id || !sidecar)
|
|
1745
|
+
return false;
|
|
1746
|
+
let tokens;
|
|
1747
|
+
try {
|
|
1748
|
+
tokens = (await sidecar.call('models.estimate_tokens', { messages: this.modelContext(s) })).tokens;
|
|
1749
|
+
}
|
|
1750
|
+
catch {
|
|
1751
|
+
return false;
|
|
1752
|
+
}
|
|
1753
|
+
return this.store.withLock(sid, async () => {
|
|
1754
|
+
let live;
|
|
1755
|
+
try {
|
|
1756
|
+
live = (await this.store.get(sid));
|
|
1757
|
+
}
|
|
1758
|
+
catch {
|
|
1759
|
+
return false;
|
|
1760
|
+
}
|
|
1761
|
+
if (live.active_stream_id || live.post_compression_context_tokens_estimate !== before || (await this.isReadOnly(live)))
|
|
1762
|
+
return false;
|
|
1763
|
+
live.post_compression_context_tokens_estimate = Math.max(1, tokens);
|
|
1764
|
+
await this.store.save(live, { touchUpdatedAt: false });
|
|
1765
|
+
return true;
|
|
1766
|
+
});
|
|
1767
|
+
}
|
|
1768
|
+
async storedCompactionDue(sid) {
|
|
1769
|
+
try {
|
|
1770
|
+
return this.compactionDue((await this.store.get(sid, { metadataOnly: true })));
|
|
1771
|
+
}
|
|
1772
|
+
catch {
|
|
1773
|
+
return false;
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
/** Whether the session's context (the post-compression estimate, else the last prompt) reached the next turn's threshold. */
|
|
1777
|
+
compactionDue(s) {
|
|
1778
|
+
const positive = (v) => { const n = Math.trunc(Number(v ?? 0)); return Number.isFinite(n) && n > 0 ? n : null; };
|
|
1779
|
+
const used = positive(s.post_compression_context_tokens_estimate) ?? positive(s.last_prompt_tokens);
|
|
1780
|
+
const threshold = positive(s.next_turn_threshold_tokens) ?? positive(s.threshold_tokens);
|
|
1781
|
+
return used !== null && threshold !== null && used >= threshold;
|
|
1782
|
+
}
|
|
1266
1783
|
/** The session a compression may run on now, and the model history it compresses: the context a turn would send, so a
|
|
1267
|
-
* repeat compression builds on the previous summary instead of re-reading the whole display transcript.
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1784
|
+
* repeat compression builds on the previous summary instead of re-reading the whole display transcript. `context` is
|
|
1785
|
+
* that history unsanitized and `stateRows` the state.db read it came from. */
|
|
1786
|
+
async compressionTarget(sid) {
|
|
1787
|
+
await this.rejectViewOnly(sid, 'compressed');
|
|
1788
|
+
const s = (await this.mutationTarget(sid, 'compressed'));
|
|
1789
|
+
const stateRows = this.stateDbRows(s);
|
|
1790
|
+
const context = this.compressionContext(s, stateRows);
|
|
1791
|
+
return { s, history: sanitizeMessagesForApi(context), context, stateRows };
|
|
1792
|
+
}
|
|
1793
|
+
/**
|
|
1794
|
+
* The loaded session's own compression guards: no running turn and enough model history. TAL-1005: a turn waiting on
|
|
1795
|
+
* the compression has not read its history yet, so it does not block; the context leaves out its rows, as it does.
|
|
1796
|
+
*/
|
|
1797
|
+
compressionContext(s, stateRows) {
|
|
1798
|
+
const turn = s.active_stream_id;
|
|
1799
|
+
if (turn && !this.compactionWaiters.has(turn))
|
|
1276
1800
|
throw new HttpFailure(409, 'Session is still streaming; wait for the current turn to finish.');
|
|
1277
|
-
const
|
|
1278
|
-
if (
|
|
1801
|
+
const context = this.modelContext(s, stateRows).filter((m) => !turn || m._turn_id !== turn);
|
|
1802
|
+
if (sanitizeMessagesForApi(context).length < 4)
|
|
1279
1803
|
throw new HttpFailure(400, 'Not enough conversation to compress (need at least 4 messages).');
|
|
1280
|
-
return
|
|
1804
|
+
return context;
|
|
1281
1805
|
}
|
|
1282
1806
|
/** TAL-614: whether a manual compression would start now: `compressionTarget`'s gates, with `readOnly` the wire flag. */
|
|
1283
1807
|
canCompress(s, readOnly, stateRows) {
|
|
1284
1808
|
if (readOnly || isClaudeCodeSessionId(s.session_id))
|
|
1285
1809
|
return false;
|
|
1286
1810
|
try {
|
|
1287
|
-
this.
|
|
1811
|
+
this.compressionContext(s, stateRows);
|
|
1288
1812
|
return true;
|
|
1289
1813
|
}
|
|
1290
1814
|
catch (error) {
|
|
@@ -1294,18 +1818,19 @@ export class SessionService {
|
|
|
1294
1818
|
}
|
|
1295
1819
|
}
|
|
1296
1820
|
/** TAL-258: the session's WebUI record (absent for a CLI or gateway session) and the profile whose state.db holds its rows. */
|
|
1297
|
-
handoffTarget(sid) {
|
|
1821
|
+
async handoffTarget(sid) {
|
|
1822
|
+
sid = sid.trim();
|
|
1298
1823
|
let local = null;
|
|
1299
1824
|
try {
|
|
1300
|
-
local = this.store.get(sid);
|
|
1825
|
+
local = (await this.store.get(sid));
|
|
1301
1826
|
}
|
|
1302
1827
|
catch { /* a foreign session without a WebUI record */ }
|
|
1303
1828
|
const profile = local?.profile ?? this.deps.activeProfile();
|
|
1304
1829
|
return { local, profile, dbPath: this.stateDbPath(profile) };
|
|
1305
1830
|
}
|
|
1306
1831
|
/** Python `_handle_conversation_rounds` (TAL-258): the session's rounds and whether the handoff dock is due. */
|
|
1307
|
-
conversationRounds(sid, since) {
|
|
1308
|
-
const rounds = countConversationRounds(this.handoffTarget(sid).dbPath, sid, since);
|
|
1832
|
+
async conversationRounds(sid, since) {
|
|
1833
|
+
const rounds = countConversationRounds((await this.handoffTarget(sid)).dbPath, sid, since);
|
|
1309
1834
|
return { ok: true, rounds, threshold: CONVERSATION_ROUND_THRESHOLD, should_show: rounds >= CONVERSATION_ROUND_THRESHOLD };
|
|
1310
1835
|
}
|
|
1311
1836
|
/**
|
|
@@ -1314,8 +1839,8 @@ export class SessionService {
|
|
|
1314
1839
|
* back to a deterministic local summary rather than an error.
|
|
1315
1840
|
*/
|
|
1316
1841
|
async handoffSummary(sid, since) {
|
|
1317
|
-
this.
|
|
1318
|
-
const { local, profile, dbPath } = this.handoffTarget(sid);
|
|
1842
|
+
sid = (await this.rejectViewOnly(sid, 'summarized'));
|
|
1843
|
+
const { local, profile, dbPath } = (await this.handoffTarget(sid));
|
|
1319
1844
|
const rounds = countConversationRounds(dbPath, sid, since);
|
|
1320
1845
|
if (rounds < CONVERSATION_ROUND_THRESHOLD)
|
|
1321
1846
|
throw new HttpFailure(400, 'Not enough conversation rounds to generate a summary.');
|
|
@@ -1328,7 +1853,7 @@ export class SessionService {
|
|
|
1328
1853
|
throw new HttpFailure(400, 'Not enough messages to summarize.');
|
|
1329
1854
|
const sidecar = this.deps.sidecar?.() ?? null;
|
|
1330
1855
|
await ensureAgentRuntimeCurrent(sidecar);
|
|
1331
|
-
const meta = local ? null : this.lookupCliMeta(sid);
|
|
1856
|
+
const meta = local ? null : (await this.lookupCliMeta(sid));
|
|
1332
1857
|
const generated = await this.generateHandoffSummary(sidecar, profile, local, meta, messages);
|
|
1333
1858
|
const pick = (row, ...keys) => { for (const key of keys) {
|
|
1334
1859
|
const v = str(row?.[key]).trim();
|
|
@@ -1336,7 +1861,7 @@ export class SessionService {
|
|
|
1336
1861
|
return v;
|
|
1337
1862
|
} return ''; };
|
|
1338
1863
|
// Python `get_cli_sessions` never listed WebUI's own state.db rows, so they name no channel.
|
|
1339
|
-
const cliRow = meta ?? this.lookupCliMeta(sid);
|
|
1864
|
+
const cliRow = meta ?? (await this.lookupCliMeta(sid));
|
|
1340
1865
|
const channel = pick(local, 'source_label', 'raw_source', 'source_tag', 'session_source') || (str(cliRow?.raw_source || cliRow?.source).trim().toLowerCase() === 'webui' ? '' : pick(cliRow, 'source_label', 'raw_source', 'source_tag', 'source')) || null;
|
|
1341
1866
|
await this.persistHandoffMarker(sid, profile, handoffMarker(sid, generated.summary, channel, rounds, generated.fallback, this.deps.now()));
|
|
1342
1867
|
return { ok: true, summary: generated.summary, message_count: messages.length, rounds, fallback: generated.fallback, ...(generated.warning ? { warning: generated.warning } : {}) };
|
|
@@ -1372,6 +1897,7 @@ export class SessionService {
|
|
|
1372
1897
|
async persistHandoffMarker(sid, profile, marker) {
|
|
1373
1898
|
const card = messageHandoffPayload(marker);
|
|
1374
1899
|
const toStateDb = async () => {
|
|
1900
|
+
await this.rejectViewOnly(sid, 'summarized');
|
|
1375
1901
|
if (sameHandoff(handoffPayload(stateDbTailToolContent(this.stateDbPath(profile), sid)), card))
|
|
1376
1902
|
return true;
|
|
1377
1903
|
const sidecar = this.deps.sidecar?.();
|
|
@@ -1384,35 +1910,41 @@ export class SessionService {
|
|
|
1384
1910
|
return false;
|
|
1385
1911
|
}
|
|
1386
1912
|
};
|
|
1387
|
-
await this.
|
|
1913
|
+
await this.withMutationLock(sid, async () => {
|
|
1388
1914
|
let live = null;
|
|
1389
1915
|
try {
|
|
1390
|
-
live = this.store.get(sid);
|
|
1916
|
+
live = (await this.store.get(sid));
|
|
1391
1917
|
}
|
|
1392
1918
|
catch { /* no WebUI record */ }
|
|
1393
1919
|
// A turn that started while the summary was generated would drop the card when it settles.
|
|
1394
1920
|
if (live?.active_stream_id)
|
|
1395
1921
|
throw new HttpFailure(409, 'Session is still streaming; wait for the current turn to finish.');
|
|
1396
|
-
const toLocal = () => {
|
|
1922
|
+
const toLocal = async () => {
|
|
1923
|
+
await this.rejectViewOnly(sid, 'summarized');
|
|
1397
1924
|
if (!live)
|
|
1398
1925
|
return false;
|
|
1399
1926
|
if (sameHandoff(messageHandoffPayload(live.messages.at(-1)), card))
|
|
1400
1927
|
return true;
|
|
1401
1928
|
live.messages.push(marker);
|
|
1402
|
-
this.store.save(live);
|
|
1929
|
+
await this.store.save(live);
|
|
1403
1930
|
return true;
|
|
1404
1931
|
};
|
|
1405
|
-
const messaging = this.isMessagingSession(sid);
|
|
1406
|
-
const saved = messaging ? [await toStateDb(), toLocal()].some(Boolean) : toLocal() || await toStateDb();
|
|
1932
|
+
const messaging = (await this.isMessagingSession(sid));
|
|
1933
|
+
const saved = messaging ? [await toStateDb(), (await toLocal())].some(Boolean) : (await toLocal()) || await toStateDb();
|
|
1407
1934
|
// The caller may switch away on success, so a card saved nowhere is an error, not a summary.
|
|
1408
1935
|
if (!saved)
|
|
1409
1936
|
throw new HttpFailure(503, 'The handoff summary could not be saved; please retry.');
|
|
1410
|
-
});
|
|
1937
|
+
}, { allowReadOnly: true });
|
|
1938
|
+
}
|
|
1939
|
+
/** The same live presentation for session detail and journal snapshot recovery. */
|
|
1940
|
+
currentRunStatus(sid, streamId) {
|
|
1941
|
+
const job = this.compressionJobs.get(sid);
|
|
1942
|
+
return job?.status === 'running' ? { phase: 'compressing' } : streamId ? (this.deps.runtime.runStatus?.(streamId) ?? null) : null;
|
|
1411
1943
|
}
|
|
1412
1944
|
/** The session's compression job; a deleted session's finished result is dropped rather than served. */
|
|
1413
|
-
compressionJob(sid) {
|
|
1945
|
+
async compressionJob(sid) {
|
|
1414
1946
|
try {
|
|
1415
|
-
this.store.get(sid, { metadataOnly: true });
|
|
1947
|
+
(await this.store.get(sid, { metadataOnly: true }));
|
|
1416
1948
|
}
|
|
1417
1949
|
catch {
|
|
1418
1950
|
this.compressionJobs.delete(sid);
|
|
@@ -1422,18 +1954,22 @@ export class SessionService {
|
|
|
1422
1954
|
}
|
|
1423
1955
|
/**
|
|
1424
1956
|
* Python `_handle_session_compress`: compress the sanitized transcript in the sidecar outside the lock, then under the
|
|
1425
|
-
* session lock refuse a result the session moved past (stream
|
|
1957
|
+
* session lock refuse a result the session moved past (another stream, or a transcript changed) and install it as the
|
|
1426
1958
|
* model context with the manual anchor and the #4836 boundary that keeps state.db from replaying compressed rows.
|
|
1427
|
-
* The guards run again first: a stream may have started while the caller awaited the runtime check.
|
|
1959
|
+
* The guards run again first: a stream may have started while the caller awaited the runtime check. TAL-1005: rows
|
|
1960
|
+
* the job's waiting turns added meanwhile (a prompt, a Stop) stay after the compressed context.
|
|
1428
1961
|
*/
|
|
1429
|
-
async compressSession(
|
|
1430
|
-
const {
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1962
|
+
async compressSession(job) {
|
|
1963
|
+
const { session_id: sid, focus_topic: focusTopic } = job;
|
|
1964
|
+
const { s, history, context, stateRows: seenRows } = (await this.compressionTarget(sid));
|
|
1965
|
+
const ownRows = (m) => !s.active_stream_id || m._turn_id !== s.active_stream_id;
|
|
1966
|
+
// The display transcript and the model context must both still start where the compression found them, followed
|
|
1967
|
+
// only by the waiting turns' rows; the context is the exact history sent, so a state.db row that lands after this
|
|
1968
|
+
// read fails the commit instead of being dropped.
|
|
1969
|
+
// A turn still waiting to join (it started its own start meanwhile) joins this job, so it counts as one of its turns.
|
|
1970
|
+
const waited = (id) => job.turns.has(str(id)) || this.compactionWaiters.has(str(id));
|
|
1971
|
+
const sameRows = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
1972
|
+
const turnTail = (rows, base) => (sameRows(rows.slice(0, base.length), base) && rows.slice(base.length).every((m) => waited(m._turn_id)) ? rows.slice(base.length) : null);
|
|
1437
1973
|
const sidecar = this.deps.sidecar?.();
|
|
1438
1974
|
if (!sidecar)
|
|
1439
1975
|
throw new HttpFailure(400, 'Compression failed: the Agent sidecar is not running');
|
|
@@ -1457,44 +1993,51 @@ export class SessionService {
|
|
|
1457
1993
|
throw new HttpFailure(result.status === 'lock_skipped' ? 409 : 400, result.message ?? 'Nothing to compress yet.');
|
|
1458
1994
|
const summary = result.summary ?? {};
|
|
1459
1995
|
let committed = false;
|
|
1996
|
+
let keptTurnRows = false;
|
|
1460
1997
|
try {
|
|
1461
|
-
const current = await this.
|
|
1998
|
+
const current = await this.withMutationLock(sid, async () => {
|
|
1462
1999
|
let live;
|
|
1463
2000
|
try {
|
|
1464
|
-
live = this.store.get(sid);
|
|
2001
|
+
live = (await this.store.get(sid));
|
|
1465
2002
|
}
|
|
1466
2003
|
catch {
|
|
1467
2004
|
throw new HttpFailure(404, 'Session not found');
|
|
1468
2005
|
}
|
|
1469
|
-
|
|
2006
|
+
const turn = live.active_stream_id;
|
|
2007
|
+
if (turn && !waited(turn))
|
|
1470
2008
|
throw new HttpFailure(409, 'Session stream state changed during compression; please retry.');
|
|
1471
2009
|
// One state.db read serves the check, the kept display rows, and the boundary.
|
|
1472
2010
|
const read = this.stateDbRead(live);
|
|
1473
2011
|
const stateRows = read.rows;
|
|
1474
|
-
|
|
2012
|
+
const tail = turnTail(this.modelContext(live, stateRows).filter((m) => !turn || m._turn_id !== turn), context);
|
|
2013
|
+
if (!tail || !turnTail(live.messages, s.messages))
|
|
1475
2014
|
throw new HttpFailure(409, 'Session was modified during compression; please retry.');
|
|
1476
2015
|
// Rows the sidecar returns without a timestamp take the newest one this compression saw, never the clock: the
|
|
1477
2016
|
// boundary then hides no state.db row the append-only merge would still show (it already drops rows at or
|
|
1478
2017
|
// before the newest local row), so a CLI row committed after the read follows the compressed context.
|
|
1479
|
-
const seen = [...
|
|
2018
|
+
const seen = [...s.messages, ...s.context_messages, ...seenRows].filter(ownRows).map((m) => Number(m.timestamp)).filter(Number.isFinite);
|
|
1480
2019
|
const stamp = seen.length ? Math.max(...seen) : this.deps.now();
|
|
1481
2020
|
const compressed = copyJson(result.messages);
|
|
1482
2021
|
for (const m of compressed)
|
|
1483
2022
|
m.timestamp ??= stamp;
|
|
1484
|
-
live.context_messages = compressed;
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
2023
|
+
live.context_messages = [...compressed, ...copyJson(tail)];
|
|
2024
|
+
keptTurnRows = tail.length > 0;
|
|
2025
|
+
if (!turn) {
|
|
2026
|
+
live.pending_user_message = null;
|
|
2027
|
+
live.pending_attachments = [];
|
|
2028
|
+
live.pending_started_at = null;
|
|
2029
|
+
live.pending_user_source = null;
|
|
2030
|
+
}
|
|
1490
2031
|
// The compressed history included any state.db-only continuation; the new boundary would hide those rows from
|
|
1491
2032
|
// the display, so the transcript keeps them before it is applied (and the anchor counts them).
|
|
1492
2033
|
const display = this.mergedTranscript(live, live.messages, stateRows);
|
|
1493
2034
|
if (display.length > live.messages.length)
|
|
1494
2035
|
live.messages = copyJson(display);
|
|
2036
|
+
// The anchor is the newest row the compression covered: never a waiting turn's own.
|
|
1495
2037
|
const visible = visibleMessagesForAnchor(live.messages);
|
|
1496
|
-
|
|
1497
|
-
live.
|
|
2038
|
+
const anchor = visible.findLast((m) => !waited(m._turn_id));
|
|
2039
|
+
live.compression_anchor_visible_idx = anchor ? visible.lastIndexOf(anchor) : null;
|
|
2040
|
+
live.compression_anchor_message_key = anchorMessageKey(anchor);
|
|
1498
2041
|
live.compression_anchor_summary = anchorSummary(summary, compressed);
|
|
1499
2042
|
live.compression_anchor_mode = 'manual';
|
|
1500
2043
|
// #4836: an intentional-shrink boundary, so append-only state.db reconciliation does not replay compressed rows.
|
|
@@ -1504,15 +2047,22 @@ export class SessionService {
|
|
|
1504
2047
|
this.markStateDbSeen(live, read, { boundary: true });
|
|
1505
2048
|
live.last_prompt_tokens = result.after_tokens;
|
|
1506
2049
|
live.post_compression_context_tokens_estimate = result.after_tokens;
|
|
1507
|
-
|
|
2050
|
+
// An automatic job is maintenance, not activity: it leaves the session's place in the list alone.
|
|
2051
|
+
await this.store.save(live, { touchUpdatedAt: !job.automatic });
|
|
1508
2052
|
// A backup from before the compression would restore the uncompressed context.
|
|
1509
2053
|
try {
|
|
1510
|
-
|
|
2054
|
+
await this.store.shrinkBackup.discard(sid);
|
|
1511
2055
|
}
|
|
1512
2056
|
catch { /* ignore */ }
|
|
1513
2057
|
return live;
|
|
1514
2058
|
});
|
|
1515
2059
|
committed = true;
|
|
2060
|
+
// The estimate covers the compressed rows only; a stopped turn's kept prompt is measured with them (a waiting turn
|
|
2061
|
+
// settles its own figures instead).
|
|
2062
|
+
if (keptTurnRows)
|
|
2063
|
+
await this.remeasureContext(sid);
|
|
2064
|
+
// Open chats refetch the session for its new context figures; the list order stays (no recency change).
|
|
2065
|
+
this.publish('session_compress', current.profile, sid);
|
|
1516
2066
|
// The cached turn agent still carries the uncompressed state; the next turn builds a fresh one (gateway parity).
|
|
1517
2067
|
// Awaited, so a turn sent right after the reply cannot reuse the old agent.
|
|
1518
2068
|
try {
|
|
@@ -1521,31 +2071,57 @@ export class SessionService {
|
|
|
1521
2071
|
catch (error) {
|
|
1522
2072
|
this.deps.log(`[webui] agent eviction after compression of ${sid} failed: ${error.message}`);
|
|
1523
2073
|
}
|
|
1524
|
-
return { ok: true, session: this.publicSession(current), summary, focus_topic: focusTopic };
|
|
2074
|
+
return { ok: true, session: (await this.publicSession(current)), summary, focus_topic: focusTopic };
|
|
1525
2075
|
}
|
|
1526
2076
|
finally {
|
|
1527
2077
|
// Second phase: the Agent's context-engine notification fires only for a result the session now holds.
|
|
1528
2078
|
if (result.commit_token) {
|
|
2079
|
+
const readOnly = committed ? (await this.readOnlyReason(s)) : null;
|
|
2080
|
+
if (readOnly)
|
|
2081
|
+
committed = false;
|
|
1529
2082
|
try {
|
|
1530
2083
|
await sidecar.call('chat.compress_finalize', { commit_token: result.commit_token, committed });
|
|
1531
2084
|
}
|
|
1532
2085
|
catch (error) {
|
|
1533
2086
|
this.deps.log(`[webui] compression finalize for ${sid} failed: ${error.message}`);
|
|
1534
2087
|
}
|
|
2088
|
+
if (readOnly)
|
|
2089
|
+
throw new HttpFailure(403, readOnly);
|
|
1535
2090
|
}
|
|
1536
2091
|
}
|
|
1537
2092
|
}
|
|
2093
|
+
/** Clear and delete share the profile-scoped, best-effort Agent session boundary. */
|
|
2094
|
+
async finalizeSession(sid, profile, reason) {
|
|
2095
|
+
const sidecar = this.deps.sidecar?.();
|
|
2096
|
+
if (!sidecar || this.deps.profileDeleting?.(profile))
|
|
2097
|
+
return [];
|
|
2098
|
+
const release = this.deps.profileActivity?.(profile);
|
|
2099
|
+
try {
|
|
2100
|
+
return (await sidecar.call('chat.finalize_session', {
|
|
2101
|
+
session_id: sid, profile_home: this.deps.profileHome(profile ?? this.deps.activeProfile()), reason,
|
|
2102
|
+
}, { timeoutMs: 12_000 })).messages;
|
|
2103
|
+
}
|
|
2104
|
+
catch (error) {
|
|
2105
|
+
this.deps.log(`[webui] session finalize (${reason}) failed: ${error.message}`);
|
|
2106
|
+
return [];
|
|
2107
|
+
}
|
|
2108
|
+
finally {
|
|
2109
|
+
release?.();
|
|
2110
|
+
}
|
|
2111
|
+
}
|
|
1538
2112
|
async clear(sid) {
|
|
1539
|
-
this.
|
|
1540
|
-
const s = this.mutationTarget(sid, 'modified');
|
|
1541
|
-
await this.
|
|
2113
|
+
await this.rejectViewOnly(sid, 'modified');
|
|
2114
|
+
const s = (await this.mutationTarget(sid, 'modified'));
|
|
2115
|
+
await this.withMutationLock(sid, async () => {
|
|
2116
|
+
const messages = await this.finalizeSession(sid, s.profile, 'clear');
|
|
1542
2117
|
const hadMessages = s.messages.length > 0;
|
|
1543
2118
|
truncateSessionAtKeep(s, 0);
|
|
2119
|
+
s.messages = messages.map((content) => ({ role: 'local_notice', content, _notice_kind: 'session_finalize', timestamp: this.deps.now() }));
|
|
1544
2120
|
s.tool_calls = [];
|
|
1545
2121
|
if (s.parent_session_id) {
|
|
1546
2122
|
let parentIsSnapshot = false;
|
|
1547
2123
|
try {
|
|
1548
|
-
parentIsSnapshot = this.store.get(s.parent_session_id, { metadataOnly: true }).pre_compression_snapshot;
|
|
2124
|
+
parentIsSnapshot = (await this.store.get(s.parent_session_id, { metadataOnly: true })).pre_compression_snapshot;
|
|
1549
2125
|
}
|
|
1550
2126
|
catch {
|
|
1551
2127
|
parentIsSnapshot = false;
|
|
@@ -1564,21 +2140,21 @@ export class SessionService {
|
|
|
1564
2140
|
s.clear_generation = hadMessages ? randomUUID().replace(/-/g, '') : null;
|
|
1565
2141
|
applySessionTitleRename(s, 'Untitled');
|
|
1566
2142
|
this.markStateDbSeen(s, undefined, { boundary: true });
|
|
1567
|
-
this.store.save(s);
|
|
2143
|
+
await this.store.save(s);
|
|
1568
2144
|
if (hadMessages) {
|
|
1569
2145
|
try {
|
|
1570
|
-
|
|
2146
|
+
await this.store.shrinkBackup.discard(sid);
|
|
1571
2147
|
}
|
|
1572
2148
|
catch { /* ignore */ }
|
|
1573
2149
|
}
|
|
1574
2150
|
});
|
|
1575
|
-
this.deps.runtime.evictAgent(sid, true);
|
|
1576
|
-
return { ok: true, session: this.wireRow(s) };
|
|
2151
|
+
await this.deps.runtime.evictAgent(sid, true);
|
|
2152
|
+
return { ok: true, session: (await this.wireRow(s)) };
|
|
1577
2153
|
}
|
|
1578
2154
|
async retry(sid) {
|
|
1579
|
-
this.
|
|
1580
|
-
return this.
|
|
1581
|
-
const s = this.mutationTarget(sid, 'modified');
|
|
2155
|
+
await this.rejectViewOnly(sid, 'modified');
|
|
2156
|
+
return this.withMutationLock(sid, async () => {
|
|
2157
|
+
const s = (await this.mutationTarget(sid, 'modified'));
|
|
1582
2158
|
const history = s.messages;
|
|
1583
2159
|
const lastUser = findLastUserIndex(history);
|
|
1584
2160
|
if (lastUser === null)
|
|
@@ -1595,14 +2171,14 @@ export class SessionService {
|
|
|
1595
2171
|
const removed = history.length - lastUser;
|
|
1596
2172
|
shrinkTo(s, lastUser);
|
|
1597
2173
|
this.markStateDbSeen(s, undefined, { boundary: true });
|
|
1598
|
-
this.store.save(s);
|
|
2174
|
+
await this.store.save(s);
|
|
1599
2175
|
return { ok: true, last_user_text: lastUserText, last_user_prompt: lastUserPrompt, last_user_attachments: lastUserAttachments, removed_count: removed };
|
|
1600
2176
|
});
|
|
1601
2177
|
}
|
|
1602
2178
|
async undo(sid) {
|
|
1603
|
-
this.
|
|
1604
|
-
return this.
|
|
1605
|
-
const s = this.mutationTarget(sid, 'modified');
|
|
2179
|
+
await this.rejectViewOnly(sid, 'modified');
|
|
2180
|
+
return this.withMutationLock(sid, async () => {
|
|
2181
|
+
const s = (await this.mutationTarget(sid, 'modified'));
|
|
1606
2182
|
const history = s.messages;
|
|
1607
2183
|
const lastUser = findLastUserIndex(history);
|
|
1608
2184
|
if (lastUser === null)
|
|
@@ -1611,14 +2187,14 @@ export class SessionService {
|
|
|
1611
2187
|
const removed = history.length - lastUser;
|
|
1612
2188
|
shrinkTo(s, lastUser);
|
|
1613
2189
|
this.markStateDbSeen(s, undefined, { boundary: true });
|
|
1614
|
-
this.store.save(s);
|
|
2190
|
+
await this.store.save(s);
|
|
1615
2191
|
const preview = removedText.length > 40 ? `${removedText.slice(0, 40)}...` : removedText;
|
|
1616
2192
|
return { ok: true, removed_count: removed, removed_preview: preview };
|
|
1617
2193
|
});
|
|
1618
2194
|
}
|
|
1619
|
-
duplicate(sid) {
|
|
1620
|
-
this.
|
|
1621
|
-
const session = this.store.load(sid);
|
|
2195
|
+
async duplicate(sid) {
|
|
2196
|
+
await this.rejectViewOnly(sid, 'duplicated');
|
|
2197
|
+
const session = (await this.store.load(sid));
|
|
1622
2198
|
if (!session)
|
|
1623
2199
|
throw new HttpFailure(404, 'Session not found');
|
|
1624
2200
|
const now = this.deps.now();
|
|
@@ -1626,7 +2202,7 @@ export class SessionService {
|
|
|
1626
2202
|
const watermark = session.truncation_watermark_compressed ? { truncation_watermark: session.truncation_watermark, truncation_boundary: session.truncation_boundary, truncation_watermark_compressed: true } : {};
|
|
1627
2203
|
const copied = new Session({
|
|
1628
2204
|
title: `${session.title || 'Untitled'} (copy)`, workspace: session.workspace, model: session.model, model_provider: session.model_provider,
|
|
1629
|
-
messages: copyJson(session.messages), tool_calls: copyJson(session.tool_calls), pinned: false, archived: false, project_id: session.project_id, profile: session.profile,
|
|
2205
|
+
messages: withMirrorSources(copyJson(session.messages), session), tool_calls: copyJson(session.tool_calls), pinned: false, archived: false, project_id: session.project_id, profile: session.profile,
|
|
1630
2206
|
input_tokens: session.input_tokens, output_tokens: session.output_tokens, estimated_cost: session.estimated_cost, cache_read_tokens: session.cache_read_tokens, cache_write_tokens: session.cache_write_tokens,
|
|
1631
2207
|
personality: session.personality, enabled_toolsets: session.enabled_toolsets, context_length: session.context_length, threshold_tokens: session.threshold_tokens,
|
|
1632
2208
|
...watermark, context_messages: copyJson(session.context_messages),
|
|
@@ -1634,16 +2210,15 @@ export class SessionService {
|
|
|
1634
2210
|
manual_title: session.manual_title, composer_draft: copyJson(session.composer_draft), context_engine: session.context_engine, context_engine_state: copyJson(session.context_engine_state),
|
|
1635
2211
|
created_at: now, updated_at: now,
|
|
1636
2212
|
}, { workspace: session.workspace, model: session.model });
|
|
1637
|
-
this.store.touch(copied);
|
|
1638
|
-
this.store.save(copied);
|
|
2213
|
+
await this.store.touch(copied);
|
|
2214
|
+
await this.store.save(copied);
|
|
1639
2215
|
this.publish('session_duplicate', copied.profile, copied.session_id);
|
|
1640
|
-
return { session: this.publicSession(copied) };
|
|
2216
|
+
return { session: (await this.publicSession(copied)) };
|
|
1641
2217
|
}
|
|
1642
|
-
branch(sid, body) {
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
if (this.isReadOnly(source)) {
|
|
2218
|
+
async branch(sid, body) {
|
|
2219
|
+
await this.rejectViewOnly(sid, 'branched');
|
|
2220
|
+
const source = (await this.get404(sid));
|
|
2221
|
+
if ((await this.isReadOnly(source))) {
|
|
1647
2222
|
if (str(source.source_tag || source.raw_source).trim().toLowerCase() !== 'cron')
|
|
1648
2223
|
throw new HttpFailure(403, 'Read-only sessions cannot be branched from WebUI');
|
|
1649
2224
|
source.branchSourceReadonly = true;
|
|
@@ -1659,7 +2234,7 @@ export class SessionService {
|
|
|
1659
2234
|
const customTitle = body.title ? str(body.title).trim().slice(0, 80) || null : null;
|
|
1660
2235
|
if (!source.branchSourceReadonly) {
|
|
1661
2236
|
try {
|
|
1662
|
-
this.store.save(source);
|
|
2237
|
+
(await this.store.save(source));
|
|
1663
2238
|
}
|
|
1664
2239
|
catch { /* ignore */ }
|
|
1665
2240
|
}
|
|
@@ -1674,14 +2249,14 @@ export class SessionService {
|
|
|
1674
2249
|
const sourceContext = stateRows.length ? this.mergedTranscript(source, source.context_messages.length ? source.context_messages : source.messages) : source.context_messages;
|
|
1675
2250
|
const forkedContext = copyJson(truncateContextForDisplayKeep(sourceContext, sourceMessages, forkKeep));
|
|
1676
2251
|
const branch = new Session({
|
|
1677
|
-
workspace: source.workspace, model: source.model, model_provider: source.model_provider, profile: source.profile, title, messages: forked, project_id: source.project_id,
|
|
2252
|
+
workspace: source.workspace, model: source.model, model_provider: source.model_provider, profile: source.profile, title, messages: withMirrorSources(forked, source), project_id: source.project_id,
|
|
1678
2253
|
personality: source.personality, enabled_toolsets: source.enabled_toolsets, context_length: source.context_length, threshold_tokens: source.threshold_tokens,
|
|
1679
2254
|
context_messages: forkedContext, gateway_routing: copyJson(source.gateway_routing), context_engine: source.context_engine, context_engine_state: copyJson(source.context_engine_state),
|
|
1680
2255
|
parent_session_id: source.session_id, session_source: 'fork',
|
|
1681
2256
|
}, { workspace: source.workspace, model: source.model });
|
|
1682
|
-
this.store.touch(branch);
|
|
2257
|
+
await this.store.touch(branch);
|
|
1683
2258
|
if (forked.length) {
|
|
1684
|
-
this.store.save(branch);
|
|
2259
|
+
await this.store.save(branch);
|
|
1685
2260
|
this.publish('session_branch', branch.profile, branch.session_id);
|
|
1686
2261
|
}
|
|
1687
2262
|
return { session_id: branch.session_id, title, parent_session_id: source.session_id };
|
|
@@ -1691,14 +2266,14 @@ export class SessionService {
|
|
|
1691
2266
|
return stateDbLineageReport(this.stateDbPath(null), sid);
|
|
1692
2267
|
}
|
|
1693
2268
|
/** TAL-259: recovery reads the active profile's state.db, and a recovered sidecar joins that profile. */
|
|
1694
|
-
recoveryDeps() {
|
|
1695
|
-
return { store: this.store, stateDbPath: this.stateDbPath(null), profile: this.deps.activeProfile(), stampingSince: this.deps.recoveryStampingSince(), log: this.deps.log };
|
|
2269
|
+
async recoveryDeps() {
|
|
2270
|
+
return { store: this.store, stateDbPath: this.stateDbPath(null), profile: this.deps.activeProfile(), stampingSince: await this.deps.recoveryStampingSince(), isReadOnly: (sid, profile) => Boolean(withOwnerLocks([{ session_id: sid, profile }], this.stateDbSources)[0]?.read_only), log: this.deps.log };
|
|
1696
2271
|
}
|
|
1697
|
-
recoveryAudit() {
|
|
1698
|
-
return auditSessionRecovery(this.recoveryDeps());
|
|
2272
|
+
async recoveryAudit() {
|
|
2273
|
+
return (await auditSessionRecovery(await this.recoveryDeps()));
|
|
1699
2274
|
}
|
|
1700
|
-
recoveryRepairSafe() {
|
|
1701
|
-
return repairSafeSessionRecovery(this.recoveryDeps());
|
|
2275
|
+
async recoveryRepairSafe() {
|
|
2276
|
+
return repairSafeSessionRecovery(await this.recoveryDeps());
|
|
1702
2277
|
}
|
|
1703
2278
|
/**
|
|
1704
2279
|
* Python `_handle_session_compression_recovery_start`: open a compression-exhausted session's focused continuation, an
|
|
@@ -1706,18 +2281,18 @@ export class SessionService {
|
|
|
1706
2281
|
* already started from it in the same profile; the source's lock makes concurrent starts converge on one child.
|
|
1707
2282
|
*/
|
|
1708
2283
|
async compressionRecoveryStart(sid) {
|
|
1709
|
-
if (this.isSubagentViewOnly(sid))
|
|
2284
|
+
if ((await this.isSubagentViewOnly(sid)))
|
|
1710
2285
|
throw new HttpFailure(400, 'Subagent sessions are view-only and cannot start compression recovery from WebUI');
|
|
1711
2286
|
const action = COMPRESSION_RECOVERY_ACTION_START_FOCUSED;
|
|
1712
2287
|
let created = false;
|
|
1713
|
-
const child = await this.
|
|
2288
|
+
const child = await this.withMutationLock(sid, async () => {
|
|
1714
2289
|
// Validated under the lock: a delete that won it leaves no source to fork.
|
|
1715
|
-
const source = this.store.get(sid);
|
|
2290
|
+
const source = (await this.store.get(sid));
|
|
1716
2291
|
if (!this.visibleToActiveProfile(source.profile))
|
|
1717
2292
|
throw new HttpFailure(404, 'Session not found');
|
|
1718
2293
|
if (!compressionRecoveryPayload(source))
|
|
1719
2294
|
throw new HttpFailure(409, 'Session does not have a compression recovery action.');
|
|
1720
|
-
const existing = this.compressionRecoveryChild(sid, action, source.profile);
|
|
2295
|
+
const existing = (await this.compressionRecoveryChild(sid, action, source.profile));
|
|
1721
2296
|
if (existing)
|
|
1722
2297
|
return existing;
|
|
1723
2298
|
const base = (source.title || 'Untitled').trim() || 'Untitled';
|
|
@@ -1731,49 +2306,49 @@ export class SessionService {
|
|
|
1731
2306
|
messages: [], context_messages: [], composer_draft: { text: '', files: [] },
|
|
1732
2307
|
}, { workspace: source.workspace, model: source.model });
|
|
1733
2308
|
try {
|
|
1734
|
-
this.store.save(fresh);
|
|
2309
|
+
await this.store.save(fresh);
|
|
1735
2310
|
}
|
|
1736
2311
|
catch (error) {
|
|
1737
2312
|
throw new HttpFailure(500, `Failed to start compression recovery: ${sanitizePaths(error)}`);
|
|
1738
2313
|
}
|
|
1739
|
-
this.store.touch(fresh);
|
|
2314
|
+
await this.store.touch(fresh);
|
|
1740
2315
|
created = true;
|
|
1741
2316
|
return fresh;
|
|
1742
|
-
});
|
|
2317
|
+
}, { allowReadOnly: true });
|
|
1743
2318
|
if (created)
|
|
1744
2319
|
this.publish('session_compression_recovery', child.profile, child.session_id);
|
|
1745
2320
|
return {
|
|
1746
|
-
ok: true, session: this.publicSession(child), source_session_id: sid, recommended_recovery_action: action,
|
|
2321
|
+
ok: true, session: (await this.publicSession(child)), source_session_id: sid, recommended_recovery_action: action,
|
|
1747
2322
|
message: created ? 'Started a focused continuation. Describe the next narrow task to continue.' : 'Opened the existing focused continuation for this exhausted session.',
|
|
1748
2323
|
};
|
|
1749
2324
|
}
|
|
1750
2325
|
/** Python `find_compression_recovery_session`: the oldest same-profile continuation started from `sid`, cached or persisted. */
|
|
1751
|
-
compressionRecoveryChild(sid, action, profile) {
|
|
2326
|
+
async compressionRecoveryChild(sid, action, profile) {
|
|
1752
2327
|
const matches = (s) => s.compression_recovery_source_session_id === sid && s.compression_recovery_action === action && this.deps.profilesMatch(s.profile, profile);
|
|
1753
2328
|
const found = new Map();
|
|
1754
2329
|
for (const s of this.store.sessions.values())
|
|
1755
2330
|
if (matches(s))
|
|
1756
2331
|
found.set(s.session_id, s);
|
|
1757
|
-
for (const id of this.store.persistedIds()) {
|
|
2332
|
+
for (const id of (await this.store.persistedIds())) {
|
|
1758
2333
|
if (found.has(id) || this.store.sessions.has(id))
|
|
1759
2334
|
continue;
|
|
1760
2335
|
try {
|
|
1761
|
-
const meta = this.store.get(id, { metadataOnly: true, promote: false, cacheOnMiss: false });
|
|
2336
|
+
const meta = (await this.store.get(id, { metadataOnly: true, promote: false, cacheOnMiss: false }));
|
|
1762
2337
|
if (matches(meta))
|
|
1763
2338
|
found.set(id, meta);
|
|
1764
2339
|
}
|
|
1765
2340
|
catch { /* unreadable: not a match */ }
|
|
1766
2341
|
}
|
|
1767
2342
|
const [oldest] = [...found.values()].sort((a, b) => (a.created_at || 0) - (b.created_at || 0) || (a.updated_at || 0) - (b.updated_at || 0) || (a.session_id < b.session_id ? -1 : 1));
|
|
1768
|
-
return oldest ? this.store.get(oldest.session_id) : null;
|
|
2343
|
+
return oldest ? (await this.store.get(oldest.session_id)) : null;
|
|
1769
2344
|
}
|
|
1770
2345
|
/** The run that keeps `delete` answering 409, if any; stream status reports it so a draining client can wait on it (TAL-622). */
|
|
1771
|
-
activeRunBlocking(sid) {
|
|
2346
|
+
async activeRunBlocking(sid) {
|
|
1772
2347
|
const live = this.deps.runtime.activeRunStream(sid);
|
|
1773
2348
|
if (live)
|
|
1774
2349
|
return live;
|
|
1775
2350
|
try {
|
|
1776
|
-
const candidate = str(this.store.get(sid, { metadataOnly: true, promote: false, cacheOnMiss: false }).active_stream_id).trim();
|
|
2351
|
+
const candidate = str((await this.store.get(sid, { metadataOnly: true, promote: false, cacheOnMiss: false })).active_stream_id).trim();
|
|
1777
2352
|
if (candidate && this.deps.runtime.activeStreamIds.has(candidate))
|
|
1778
2353
|
return candidate;
|
|
1779
2354
|
}
|
|
@@ -1785,116 +2360,141 @@ export class SessionService {
|
|
|
1785
2360
|
throw new HttpFailure(400, 'session_id is required');
|
|
1786
2361
|
if (!isSafeSessionId(sid))
|
|
1787
2362
|
throw new HttpFailure(400, 'Invalid session_id');
|
|
2363
|
+
await this.rejectViewOnly(sid, 'deleted');
|
|
1788
2364
|
// Python: a read-only import (persisted sidecar flag, sidebar metadata, or a foreign state.db owner such as a
|
|
1789
2365
|
// Claude Code session) is never deleted — that would erase the owner's authoritative transcript. An orphan has none.
|
|
1790
|
-
const orphaned = this.isOrphanedImport(sid);
|
|
1791
|
-
if (!orphaned && this.isReadOnlyImport(sid))
|
|
2366
|
+
const orphaned = (await this.isOrphanedImport(sid));
|
|
2367
|
+
if (!orphaned && (await this.isReadOnlyImport(sid)))
|
|
1792
2368
|
throw new HttpFailure(400, 'Read-only imported sessions cannot be deleted from WebUI');
|
|
1793
|
-
if (this.isSubagentViewOnly(sid))
|
|
2369
|
+
if ((await this.isSubagentViewOnly(sid)))
|
|
1794
2370
|
throw new HttpFailure(400, 'Subagent sessions are view-only and cannot be deleted from WebUI');
|
|
1795
|
-
const retained = (() => { try {
|
|
1796
|
-
return worktreeRetainedPayload(this.store.get(sid, { metadataOnly: true }));
|
|
2371
|
+
const retained = (await (async () => { try {
|
|
2372
|
+
return worktreeRetainedPayload((await this.store.get(sid, { metadataOnly: true })));
|
|
1797
2373
|
}
|
|
1798
2374
|
catch {
|
|
1799
2375
|
return {};
|
|
1800
|
-
} })();
|
|
2376
|
+
} })());
|
|
1801
2377
|
let eventProfile = null;
|
|
1802
|
-
let hadSidecar = true;
|
|
1803
2378
|
try {
|
|
1804
|
-
eventProfile = this.store.get(sid, { metadataOnly: true }).profile;
|
|
2379
|
+
eventProfile = (await this.store.get(sid, { metadataOnly: true })).profile;
|
|
1805
2380
|
}
|
|
1806
2381
|
catch {
|
|
1807
2382
|
eventProfile = null;
|
|
1808
|
-
hadSidecar = false;
|
|
1809
2383
|
}
|
|
1810
2384
|
// Python `_is_messaging_session_id`: decided before the JSON is gone, from WebUI metadata or the Agent's row.
|
|
1811
|
-
const isMessaging = this.isMessagingSession(sid);
|
|
1812
|
-
|
|
1813
|
-
|
|
2385
|
+
const isMessaging = (await this.isMessagingSession(sid));
|
|
2386
|
+
const knownIds = !isMessaging && !orphaned ? stateDbCompressionLineage(this.stateDbPath(eventProfile), sid) : [sid];
|
|
2387
|
+
const assertIdle = async () => { for (const id of knownIds)
|
|
2388
|
+
if (await this.activeRunBlocking(id))
|
|
2389
|
+
throw new HttpFailure(409, 'Session has an active run; stop it before deleting'); };
|
|
2390
|
+
await assertIdle();
|
|
2391
|
+
let stateDbCleanupFailed = false;
|
|
2392
|
+
let removedIds = [sid];
|
|
1814
2393
|
try {
|
|
1815
|
-
await this.
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
//
|
|
1819
|
-
|
|
1820
|
-
|
|
2394
|
+
await this.withMutationLock(sid, async () => {
|
|
2395
|
+
await assertIdle();
|
|
2396
|
+
// TAL-792: state.db goes first, so another Agent process running or compressing the session refuses the delete
|
|
2397
|
+
// before anything of Talaria's is removed. Python: the Agent/CLI transcript goes too (else a claimable CLI row
|
|
2398
|
+
// resurfaces in the sidebar), but a messaging channel's memory is never erased from the WebUI.
|
|
2399
|
+
if (!isMessaging && !orphaned) {
|
|
2400
|
+
const result = await this.deleteStateDbRows(eventProfile, sid);
|
|
2401
|
+
stateDbCleanupFailed = !result.ok;
|
|
2402
|
+
removedIds = [...new Set([sid, ...(result.deleted_ids ?? (result.ok ? knownIds : []))])];
|
|
1821
2403
|
}
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
2404
|
+
if (removedIds.some((id) => !isSafeSessionId(id)))
|
|
2405
|
+
throw new HttpFailure(503, 'Session cleanup could not finish because the Agent returned invalid session IDs.');
|
|
2406
|
+
removedIds = (await filterAsync(removedIds, async (id) => {
|
|
2407
|
+
try {
|
|
2408
|
+
return this.deps.profilesMatch((await this.store.get(id, { metadataOnly: true })).profile, eventProfile ?? this.deps.activeProfile());
|
|
2409
|
+
}
|
|
2410
|
+
catch {
|
|
2411
|
+
return true;
|
|
2412
|
+
}
|
|
2413
|
+
}));
|
|
2414
|
+
for (const id of removedIds) {
|
|
2415
|
+
// Revoke before removing each persisted alias; UI cleanup follows the Agent's committed target set.
|
|
2416
|
+
try {
|
|
2417
|
+
const session = (await this.store.get(id, { metadataOnly: true }));
|
|
2418
|
+
if (!Object.keys(retained).length)
|
|
2419
|
+
Object.assign(retained, worktreeRetainedPayload(session));
|
|
2420
|
+
await this.deps.shares.revoke(session);
|
|
2421
|
+
await this.deps.clearRelayCompletions?.(id, session.profile);
|
|
2422
|
+
}
|
|
2423
|
+
catch (error) {
|
|
2424
|
+
if (!(error instanceof SessionNotFound))
|
|
2425
|
+
throw error;
|
|
2426
|
+
}
|
|
2427
|
+
const finalize = id === sid && !(await this.store.wasDeleted(id));
|
|
2428
|
+
if (!(await this.store.delete(id)))
|
|
2429
|
+
throw new HttpFailure(500, 'Failed to delete session data');
|
|
2430
|
+
if (finalize)
|
|
2431
|
+
await this.finalizeSession(sid, eventProfile, 'delete');
|
|
1825
2432
|
}
|
|
1826
|
-
|
|
1827
|
-
throw new HttpFailure(500, 'Failed to delete session data');
|
|
1828
|
-
}, { timeoutMs: 5000 });
|
|
2433
|
+
}, { timeoutMs: 5000, allowReadOnly: orphaned });
|
|
1829
2434
|
}
|
|
1830
2435
|
catch (error) {
|
|
1831
2436
|
if (error instanceof SessionBusy)
|
|
1832
2437
|
throw new HttpFailure(503, 'Session busy, try again');
|
|
1833
2438
|
throw error;
|
|
1834
2439
|
}
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
rmSync(this.deps.attachmentDir(sid), { recursive: true, force: true });
|
|
1838
|
-
}
|
|
1839
|
-
catch { /* ignore */ }
|
|
1840
|
-
// Python `delete_run_journal` (#3802): a deleted session leaves no replayable run journal behind.
|
|
1841
|
-
try {
|
|
1842
|
-
this.deps.journal?.deleteSession(sid);
|
|
1843
|
-
}
|
|
1844
|
-
catch { /* ignore */ }
|
|
1845
|
-
this.deps.runtime.closeTerminal(sid);
|
|
1846
|
-
// Python: the Agent/CLI transcript in state.db goes too (else a claimable CLI row resurfaces in the sidebar), but a
|
|
1847
|
-
// messaging channel's memory is never erased from the WebUI; the actual outcome is reported.
|
|
1848
|
-
let stateDbCleanupFailed = false;
|
|
1849
|
-
if (!isMessaging && !orphaned) {
|
|
1850
|
-
// TAL-529: the compression continuations go too, tip first; the Agent's delete keeps and detaches them otherwise.
|
|
1851
|
-
let lineage = [];
|
|
2440
|
+
for (const id of removedIds) {
|
|
2441
|
+
await this.deps.runtime.evictAgent(id, true);
|
|
1852
2442
|
try {
|
|
1853
|
-
|
|
1854
|
-
}
|
|
1855
|
-
catch {
|
|
1856
|
-
stateDbCleanupFailed = true;
|
|
2443
|
+
rmSync(this.deps.attachmentDir(id), { recursive: true, force: true });
|
|
1857
2444
|
}
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
}
|
|
1863
|
-
catch {
|
|
1864
|
-
stateDbCleanupFailed = true;
|
|
1865
|
-
}
|
|
2445
|
+
catch { /* ignore */ }
|
|
2446
|
+
// Python `delete_run_journal` (#3802): a deleted session leaves no replayable run journal behind.
|
|
2447
|
+
try {
|
|
2448
|
+
(await this.deps.journal?.deleteSession(id));
|
|
1866
2449
|
}
|
|
2450
|
+
catch { /* ignore */ }
|
|
2451
|
+
this.deps.runtime.closeTerminal(id);
|
|
1867
2452
|
}
|
|
1868
2453
|
this.publish('session_delete', eventProfile);
|
|
1869
|
-
// Only a sidecar session is ever published to the relay.
|
|
1870
|
-
if (hadSidecar)
|
|
1871
|
-
this.deps.clearRelayCompletions?.(sid, eventProfile);
|
|
1872
2454
|
return { ok: true, state_db_cleanup_failed: stateDbCleanupFailed, ...retained };
|
|
1873
2455
|
}
|
|
1874
|
-
cleanup
|
|
2456
|
+
/** The committed Agent deletion targets and cleanup outcome; throws 409 `refused` while an Agent process holds a row. */
|
|
2457
|
+
async deleteStateDbRows(profile, sid) {
|
|
2458
|
+
// TAL-529: the compression continuations go too; the Agent's delete keeps and detaches them otherwise. TAL-792: the
|
|
2459
|
+
// sidecar resolves them inside its delete transaction, so a guard on any of them refuses before anything is removed.
|
|
2460
|
+
let result;
|
|
2461
|
+
try {
|
|
2462
|
+
result = await this.deps.runtime.deleteCliSession(profile, sid);
|
|
2463
|
+
}
|
|
2464
|
+
catch {
|
|
2465
|
+
return { ok: false };
|
|
2466
|
+
}
|
|
2467
|
+
if (result.refused) {
|
|
2468
|
+
const message = result.refused === 'runtime_history_unsupported'
|
|
2469
|
+
? 'This Agent cannot retire the session\'s retained runtime history. Use an Agent that supports retiring this history, or explicitly restore a pre-upgrade backup before deleting it.'
|
|
2470
|
+
: 'In use by another Agent process. Try again later.';
|
|
2471
|
+
throw new HttpFailure(409, message, { refused: result.refused });
|
|
2472
|
+
}
|
|
2473
|
+
return result;
|
|
2474
|
+
}
|
|
2475
|
+
async cleanup(zeroOnly) {
|
|
1875
2476
|
let cleaned = 0;
|
|
1876
2477
|
const removed = new Set();
|
|
1877
|
-
for (const s of this.store.scanAll()) {
|
|
2478
|
+
for (const s of (await this.store.scanAll())) {
|
|
1878
2479
|
const shouldDelete = zeroOnly ? s.messages.length === 0 : s.title === 'Untitled' && s.messages.length === 0;
|
|
1879
|
-
if (!shouldDelete)
|
|
2480
|
+
if (!shouldDelete || (await this.isReadOnly(s)))
|
|
1880
2481
|
continue;
|
|
1881
2482
|
// A cleared session keeps its share; keep the session (and its revoke route) if the share cannot be revoked.
|
|
1882
2483
|
try {
|
|
1883
|
-
this.deps.shares.revoke(s);
|
|
2484
|
+
(await this.deps.shares.revoke(s));
|
|
1884
2485
|
}
|
|
1885
2486
|
catch {
|
|
1886
2487
|
continue;
|
|
1887
2488
|
}
|
|
1888
2489
|
this.store.sessions.delete(s.session_id);
|
|
1889
|
-
|
|
2490
|
+
await this.store.delete(s.session_id, { tombstone: false });
|
|
1890
2491
|
cleaned += 1;
|
|
1891
2492
|
removed.add(s.session_id);
|
|
1892
2493
|
}
|
|
1893
|
-
this.store.invalidatePersistedIds();
|
|
1894
2494
|
let rewroteIndex = false;
|
|
1895
2495
|
try {
|
|
1896
|
-
const rows = this.store.readIndexEntries();
|
|
1897
|
-
const live = this.store.persistedIds();
|
|
2496
|
+
const rows = (await this.store.readIndexEntries());
|
|
2497
|
+
const live = (await this.store.persistedIds());
|
|
1898
2498
|
const survivors = rows.filter((entry) => {
|
|
1899
2499
|
const id = str(entry.session_id);
|
|
1900
2500
|
if (!id || live.has(id) || this.store.sessions.has(id))
|
|
@@ -1905,20 +2505,20 @@ export class SessionService {
|
|
|
1905
2505
|
return false;
|
|
1906
2506
|
});
|
|
1907
2507
|
if (survivors.length < rows.length) {
|
|
1908
|
-
this.store.writeIndex();
|
|
2508
|
+
await this.store.writeIndex();
|
|
1909
2509
|
rewroteIndex = true;
|
|
1910
2510
|
}
|
|
1911
2511
|
}
|
|
1912
2512
|
catch { /* corrupt index is rebuilt below */ }
|
|
1913
2513
|
if (removed.size && !rewroteIndex) {
|
|
1914
2514
|
try {
|
|
1915
|
-
this.store.writeIndex();
|
|
2515
|
+
(await this.store.writeIndex());
|
|
1916
2516
|
}
|
|
1917
2517
|
catch { /* ignore */ }
|
|
1918
2518
|
}
|
|
1919
2519
|
return { ok: true, cleaned };
|
|
1920
2520
|
}
|
|
1921
|
-
import(body) {
|
|
2521
|
+
async import(body) {
|
|
1922
2522
|
const messages = stripPublicInternalFields(body.messages, { messageRecords: true });
|
|
1923
2523
|
if (!Array.isArray(messages))
|
|
1924
2524
|
throw new HttpFailure(400, 'JSON must contain a "messages" array');
|
|
@@ -1927,48 +2527,48 @@ export class SessionService {
|
|
|
1927
2527
|
throw new HttpFailure(400, 'JSON "tool_calls" must be an array');
|
|
1928
2528
|
let workspace;
|
|
1929
2529
|
try {
|
|
1930
|
-
workspace = this.deps.workspaces.resolveTrusted(typeof body.workspace === 'string' ? body.workspace : null);
|
|
2530
|
+
workspace = (await this.deps.workspaces.resolveTrusted(typeof body.workspace === 'string' ? body.workspace : null));
|
|
1931
2531
|
}
|
|
1932
2532
|
catch (error) {
|
|
1933
2533
|
throw new HttpFailure(400, error.message);
|
|
1934
2534
|
}
|
|
1935
|
-
const defaults = this.store.deps.defaults(this.deps.activeProfile());
|
|
2535
|
+
const defaults = (await this.store.deps.defaults(this.deps.activeProfile()));
|
|
1936
2536
|
const s = new Session({ title: typeof body.title === 'string' ? body.title : 'Imported session', workspace, model: typeof body.model === 'string' ? body.model : defaults.model, messages: messages, tool_calls: stripPublicInternalFields(rawToolCalls), profile: this.deps.activeProfile() }, defaults);
|
|
1937
2537
|
s.pinned = Boolean(body.pinned);
|
|
1938
|
-
this.store.touch(s);
|
|
1939
|
-
this.store.save(s);
|
|
2538
|
+
await this.store.touch(s);
|
|
2539
|
+
await this.store.save(s);
|
|
1940
2540
|
this.publish('session_import');
|
|
1941
|
-
return { ok: true, session: this.publicSession(s) };
|
|
2541
|
+
return { ok: true, session: (await this.publicSession(s)) };
|
|
1942
2542
|
}
|
|
1943
2543
|
// ── drafts ───────────────────────────────────────────────────────────────
|
|
1944
|
-
readDraft(sid) {
|
|
2544
|
+
async readDraft(sid) {
|
|
1945
2545
|
if (!sid)
|
|
1946
2546
|
throw new HttpFailure(400, 'session_id is required');
|
|
1947
|
-
let hasSidecar;
|
|
1948
2547
|
try {
|
|
1949
|
-
|
|
2548
|
+
this.deps.drafts.validate(sid);
|
|
1950
2549
|
}
|
|
1951
2550
|
catch {
|
|
1952
2551
|
throw new HttpFailure(400, 'Invalid session_id');
|
|
1953
2552
|
}
|
|
2553
|
+
const hasSidecar = await this.deps.drafts.exists(sid);
|
|
1954
2554
|
if (hasSidecar) {
|
|
1955
2555
|
const cached = this.store.sessions.get(sid);
|
|
1956
|
-
if (!cached && !existsSessionFile(this.store, sid))
|
|
2556
|
+
if (!cached && !(await existsSessionFile(this.store, sid)))
|
|
1957
2557
|
throw new HttpFailure(404, 'Session not found');
|
|
1958
|
-
const [draft, version] = this.deps.drafts.readState(sid);
|
|
2558
|
+
const [draft, version] = (await this.deps.drafts.readState(sid));
|
|
1959
2559
|
if (cached)
|
|
1960
2560
|
cached.composer_draft = draft;
|
|
1961
2561
|
return { draft, draft_version: version };
|
|
1962
2562
|
}
|
|
1963
|
-
const s = this.get404(sid, { metadataOnly: true });
|
|
1964
|
-
const [draft, version] = this.deps.drafts.readState(sid, s.composer_draft);
|
|
2563
|
+
const s = (await this.get404(sid, { metadataOnly: true }));
|
|
2564
|
+
const [draft, version] = (await this.deps.drafts.readState(sid, s.composer_draft));
|
|
1965
2565
|
return { draft, draft_version: version };
|
|
1966
2566
|
}
|
|
1967
2567
|
async writeDraft(body) {
|
|
1968
2568
|
const sid = str(body.session_id);
|
|
1969
2569
|
if (!sid)
|
|
1970
2570
|
throw new HttpFailure(400, 'Missing required field(s): session_id');
|
|
1971
|
-
if (this.isSubagentViewOnly(sid))
|
|
2571
|
+
if ((await this.isSubagentViewOnly(sid)))
|
|
1972
2572
|
throw new HttpFailure(400, 'Subagent sessions are view-only and cannot store a draft from WebUI');
|
|
1973
2573
|
let text = body.text;
|
|
1974
2574
|
let files = body.files;
|
|
@@ -1981,7 +2581,7 @@ export class SessionService {
|
|
|
1981
2581
|
if (Array.isArray(files) && files.length > 50)
|
|
1982
2582
|
files = files.slice(0, 50);
|
|
1983
2583
|
try {
|
|
1984
|
-
this.deps.drafts.
|
|
2584
|
+
this.deps.drafts.validate(sid);
|
|
1985
2585
|
}
|
|
1986
2586
|
catch {
|
|
1987
2587
|
throw new HttpFailure(400, 'Invalid session_id');
|
|
@@ -1998,18 +2598,18 @@ export class SessionService {
|
|
|
1998
2598
|
if (BigInt(draftVersion) > maxVersion)
|
|
1999
2599
|
throw new HttpFailure(400, 'draft_version is too far in the future');
|
|
2000
2600
|
}
|
|
2001
|
-
return this.store.withLock(sid, () => {
|
|
2601
|
+
return this.store.withLock(sid, async () => {
|
|
2002
2602
|
let s = this.store.sessions.get(sid) ?? null;
|
|
2003
2603
|
if (s && s.session_id !== sid)
|
|
2004
2604
|
s = null;
|
|
2005
|
-
const sessionExists = existsSessionFile(this.store, sid);
|
|
2605
|
+
const sessionExists = (await existsSessionFile(this.store, sid));
|
|
2006
2606
|
if (!s) {
|
|
2007
2607
|
if (!sessionExists)
|
|
2008
2608
|
throw new HttpFailure(404, 'Session not found');
|
|
2009
|
-
if (!this.deps.drafts.exists(sid))
|
|
2010
|
-
s = this.get404(sid, { metadataOnly: true });
|
|
2609
|
+
if (!(await this.deps.drafts.exists(sid)))
|
|
2610
|
+
s = (await this.get404(sid, { metadataOnly: true }));
|
|
2011
2611
|
}
|
|
2012
|
-
const [currentDraft, currentVersion] = this.deps.drafts.readState(sid, s?.composer_draft);
|
|
2612
|
+
const [currentDraft, currentVersion] = (await this.deps.drafts.readState(sid, s?.composer_draft));
|
|
2013
2613
|
const next = { ...currentDraft };
|
|
2014
2614
|
if (text !== null && text !== undefined)
|
|
2015
2615
|
next.text = text;
|
|
@@ -2032,10 +2632,10 @@ export class SessionService {
|
|
|
2032
2632
|
throw new HttpFailure(404, 'Session not found');
|
|
2033
2633
|
if (!sessionExists && s) {
|
|
2034
2634
|
s.composer_draft = next;
|
|
2035
|
-
this.store.save(s, { touchUpdatedAt: false });
|
|
2635
|
+
await this.store.save(s, { touchUpdatedAt: false });
|
|
2036
2636
|
}
|
|
2037
2637
|
try {
|
|
2038
|
-
saved = this.deps.drafts.write(sid, next, draftVersion);
|
|
2638
|
+
saved = (await this.deps.drafts.write(sid, next, draftVersion));
|
|
2039
2639
|
}
|
|
2040
2640
|
catch (error) {
|
|
2041
2641
|
if (error instanceof DraftVersionConflict)
|
|
@@ -2060,7 +2660,7 @@ export class SessionService {
|
|
|
2060
2660
|
throw new HttpFailure(400, 'Missing required field(s): scene');
|
|
2061
2661
|
let s;
|
|
2062
2662
|
try {
|
|
2063
|
-
s = this.getForMutation(sid);
|
|
2663
|
+
s = (await this.getForMutation(sid));
|
|
2064
2664
|
}
|
|
2065
2665
|
catch (error) {
|
|
2066
2666
|
if (error instanceof SessionNotFound)
|
|
@@ -2071,7 +2671,7 @@ export class SessionService {
|
|
|
2071
2671
|
}
|
|
2072
2672
|
if (!this.visibleToActiveProfile(s.profile))
|
|
2073
2673
|
throw new HttpFailure(404, 'Session not found');
|
|
2074
|
-
return this.
|
|
2674
|
+
return this.withMutationLock(sid, async () => {
|
|
2075
2675
|
let placement;
|
|
2076
2676
|
try {
|
|
2077
2677
|
placement = storeAnchorScene(s, body, this.deps.now());
|
|
@@ -2081,16 +2681,16 @@ export class SessionService {
|
|
|
2081
2681
|
}
|
|
2082
2682
|
if (!placement)
|
|
2083
2683
|
throw new HttpFailure(404, 'Assistant message not found');
|
|
2084
|
-
this.store.save(s, { touchUpdatedAt: false, skipIndex: true });
|
|
2684
|
+
await this.store.save(s, { touchUpdatedAt: false, skipIndex: true });
|
|
2085
2685
|
return { ok: true, ...placement };
|
|
2086
2686
|
});
|
|
2087
2687
|
}
|
|
2088
2688
|
/** The session and the full transcript its detail is built from; a state.db-only session gives its synthesized one. */
|
|
2089
|
-
fullTranscript(sid) {
|
|
2689
|
+
async fullTranscript(sid) {
|
|
2090
2690
|
try {
|
|
2091
|
-
let session = this.store.get(sid);
|
|
2691
|
+
let session = (await this.store.get(sid));
|
|
2092
2692
|
if (session.loadedMetadataOnly)
|
|
2093
|
-
session = this.store.load(sid) ?? session;
|
|
2693
|
+
session = (await this.store.load(sid)) ?? session;
|
|
2094
2694
|
if (!this.visibleToActiveProfile(session.profile))
|
|
2095
2695
|
throw new HttpFailure(404, 'Session not found');
|
|
2096
2696
|
return { session, transcript: this.mergedTranscript(session), stored: true };
|
|
@@ -2098,17 +2698,17 @@ export class SessionService {
|
|
|
2098
2698
|
catch (error) {
|
|
2099
2699
|
if (error instanceof HttpFailure)
|
|
2100
2700
|
throw error;
|
|
2101
|
-
const session = this.foreignSession(sid).synth;
|
|
2701
|
+
const session = (await this.foreignSession(sid)).synth;
|
|
2102
2702
|
return { session, transcript: session.messages, stored: false };
|
|
2103
2703
|
}
|
|
2104
2704
|
}
|
|
2105
|
-
readAnchorScene(query) {
|
|
2705
|
+
async readAnchorScene(query) {
|
|
2106
2706
|
const sid = str(query.session_id).trim();
|
|
2107
2707
|
const messageRef = normalizeAnchorSceneMessageRef(query.message_ref);
|
|
2108
2708
|
const messageIndex = anchorSceneIntOrNull(query.message_index);
|
|
2109
2709
|
if (!sid || (!messageRef && messageIndex === null))
|
|
2110
2710
|
throw new HttpFailure(400, 'session_id and message_ref or message_index are required');
|
|
2111
|
-
const { session, transcript, stored } = this.fullTranscript(sid);
|
|
2711
|
+
const { session, transcript, stored } = (await this.fullTranscript(sid));
|
|
2112
2712
|
const result = readAnchorSceneRows(session, { messageRef, messageIndex, before: anchorSceneIntOrNull(query.before), limit: anchorSceneIntOrNull(query.limit) }, withToolCallOutcomes(withTurnIds(transcript), session.tool_calls, session.active_stream_id));
|
|
2113
2713
|
if (!result)
|
|
2114
2714
|
throw new HttpFailure(404, 'Anchor activity scene not found');
|
|
@@ -2117,26 +2717,26 @@ export class SessionService {
|
|
|
2117
2717
|
const redacted = redactValue(result, enabled);
|
|
2118
2718
|
const rows = withSceneToolDisplay(result.rows, redacted.rows, enabled);
|
|
2119
2719
|
// `/api/media` serves stored sessions only, so a state.db-only session's rows stay as written, as its detail does.
|
|
2120
|
-
return { ...redacted, rows: stored ? withSceneRowMedia(rows, this.mediaProjector(session)) : rows };
|
|
2720
|
+
return { ...redacted, rows: stored ? withSceneRowMedia(rows, (await this.mediaProjector(session))) : rows };
|
|
2121
2721
|
}
|
|
2122
2722
|
/** TAL-331: one tool call's whole result, which a limited response clipped (`result_truncated`), redacted like the detail. */
|
|
2123
|
-
readToolResult(query) {
|
|
2723
|
+
async readToolResult(query) {
|
|
2124
2724
|
const sid = query.session_id.trim();
|
|
2125
2725
|
const id = query.tool_call_id.trim();
|
|
2126
2726
|
if (!sid || !id)
|
|
2127
2727
|
throw new HttpFailure(400, 'session_id and tool_call_id are required');
|
|
2128
|
-
const full = fullToolResult(this.fullTranscript(sid).transcript, id);
|
|
2728
|
+
const full = fullToolResult((await this.fullTranscript(sid)).transcript, id);
|
|
2129
2729
|
if (full === null)
|
|
2130
2730
|
throw new HttpFailure(404, 'Tool result not found');
|
|
2131
2731
|
return { tool_call_id: id, ...redactValue(full, this.deps.redactEnabled()) };
|
|
2132
2732
|
}
|
|
2133
2733
|
// ── shares ───────────────────────────────────────────────────────────────
|
|
2134
|
-
createShare(sid) {
|
|
2734
|
+
async createShare(sid) {
|
|
2135
2735
|
this.rejectClaudeCode(sid, 'shared');
|
|
2136
|
-
let s = this.get404(sid);
|
|
2736
|
+
let s = (await this.get404(sid));
|
|
2137
2737
|
if (!this.visibleToActiveProfile(s.profile))
|
|
2138
2738
|
throw new HttpFailure(404, 'Session not found');
|
|
2139
|
-
s = this.store.ensureFull(sid, s);
|
|
2739
|
+
s = (await this.store.ensureFull(sid, s));
|
|
2140
2740
|
let snapshot;
|
|
2141
2741
|
try {
|
|
2142
2742
|
snapshot = buildShareSnapshot(s, s.messages, { hermesHome: this.deps.hermesHome, attachmentRoot: this.deps.attachmentDir('').replace(/\/[^/]*$/, ''), home: this.deps.home });
|
|
@@ -2144,31 +2744,31 @@ export class SessionService {
|
|
|
2144
2744
|
catch (error) {
|
|
2145
2745
|
throw new HttpFailure(400, error.message);
|
|
2146
2746
|
}
|
|
2147
|
-
const meta = this.deps.shares.createOrRefresh(s, snapshot);
|
|
2747
|
+
const meta = (await this.deps.shares.createOrRefresh(s, snapshot));
|
|
2148
2748
|
s.share_token = meta.share_token;
|
|
2149
2749
|
s.share_created_at = meta.share_created_at;
|
|
2150
|
-
this.store.save(s, { touchUpdatedAt: false });
|
|
2750
|
+
await this.store.save(s, { touchUpdatedAt: false });
|
|
2151
2751
|
this.publish('session_share_create', s.profile, sid);
|
|
2152
2752
|
return {
|
|
2153
2753
|
ok: true,
|
|
2154
2754
|
share: { token: meta.share_token, url: sharePath(meta.share_token) ?? '', title: meta.share_title, message_count: meta.share_message_count, created_at: meta.share_created_at, updated_at: meta.share_updated_at },
|
|
2155
|
-
session: this.publicSession(s),
|
|
2755
|
+
session: (await this.publicSession(s)),
|
|
2156
2756
|
};
|
|
2157
2757
|
}
|
|
2158
|
-
revokeShare(sid) {
|
|
2159
|
-
let s = this.get404(sid);
|
|
2758
|
+
async revokeShare(sid) {
|
|
2759
|
+
let s = (await this.get404(sid));
|
|
2160
2760
|
if (!this.visibleToActiveProfile(s.profile))
|
|
2161
2761
|
throw new HttpFailure(404, 'Session not found');
|
|
2162
|
-
s = this.store.ensureFull(sid, s);
|
|
2163
|
-
this.deps.shares.revoke(s);
|
|
2762
|
+
s = (await this.store.ensureFull(sid, s));
|
|
2763
|
+
await this.deps.shares.revoke(s);
|
|
2164
2764
|
s.share_token = null;
|
|
2165
2765
|
s.share_created_at = null;
|
|
2166
|
-
this.store.save(s, { touchUpdatedAt: false });
|
|
2766
|
+
await this.store.save(s, { touchUpdatedAt: false });
|
|
2167
2767
|
this.publish('session_share_revoke', s.profile, sid);
|
|
2168
|
-
return { ok: true, session: this.publicSession(s) };
|
|
2768
|
+
return { ok: true, session: (await this.publicSession(s)) };
|
|
2169
2769
|
}
|
|
2170
|
-
loadShare(token) {
|
|
2171
|
-
const share = this.deps.shares.load(token);
|
|
2770
|
+
async loadShare(token) {
|
|
2771
|
+
const share = (await this.deps.shares.load(token));
|
|
2172
2772
|
if (!share)
|
|
2173
2773
|
throw new HttpFailure(404, 'Shared conversation not found');
|
|
2174
2774
|
return { share };
|
|
@@ -2179,15 +2779,16 @@ export class SessionService {
|
|
|
2179
2779
|
throw new HttpFailure(400, 'Missing session_id');
|
|
2180
2780
|
return { yolo_enabled: this.deps.yolo.isEnabled(sid) };
|
|
2181
2781
|
}
|
|
2182
|
-
setYolo(sid, enabled) {
|
|
2782
|
+
async setYolo(sid, enabled) {
|
|
2183
2783
|
if (!sid)
|
|
2184
2784
|
throw new HttpFailure(400, 'Missing required field(s): session_id');
|
|
2785
|
+
await this.assertWritable(sid);
|
|
2185
2786
|
this.deps.yolo.set(sid, enabled);
|
|
2186
2787
|
return { ok: true, yolo_enabled: this.deps.yolo.isEnabled(sid) };
|
|
2187
2788
|
}
|
|
2188
2789
|
}
|
|
2189
|
-
function existsSessionFile(store, sid) {
|
|
2190
|
-
return store.persistedIds().has(sid);
|
|
2790
|
+
async function existsSessionFile(store, sid) {
|
|
2791
|
+
return (await store.persistedIds()).has(sid);
|
|
2191
2792
|
}
|
|
2192
2793
|
import { createHash } from 'node:crypto';
|
|
2193
2794
|
function hashRevision(signature) {
|
|
@@ -2231,13 +2832,24 @@ function runStateDbRows(rows, prompt) {
|
|
|
2231
2832
|
function settledIdentity(m) {
|
|
2232
2833
|
return messageIdentity(m, Infinity) ?? JSON.stringify([m.role ?? null, m.content ?? null, reasoningFieldsText(m)]);
|
|
2233
2834
|
}
|
|
2234
|
-
/** `rows` minus one occurrence of each row
|
|
2835
|
+
/** `rows` minus one occurrence of each base row, retaining its numeric provenance on the Agent's history copy. */
|
|
2235
2836
|
function withoutRows(rows, base) {
|
|
2236
2837
|
const left = new Map();
|
|
2237
|
-
for (const m of base)
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2838
|
+
for (const m of base) {
|
|
2839
|
+
const key = settledIdentity(m);
|
|
2840
|
+
const entry = left.get(key) ?? { rows: [], at: 0 };
|
|
2841
|
+
entry.rows.push(m);
|
|
2842
|
+
left.set(key, entry);
|
|
2843
|
+
}
|
|
2844
|
+
return rows.filter((m) => {
|
|
2845
|
+
const entry = left.get(settledIdentity(m));
|
|
2846
|
+
if (!entry || entry.at === entry.rows.length)
|
|
2847
|
+
return true;
|
|
2848
|
+
const original = entry.rows[entry.at++];
|
|
2849
|
+
if (!('_state_db_generation' in m) && '_state_db_generation' in original)
|
|
2850
|
+
m._state_db_generation = original._state_db_generation;
|
|
2851
|
+
return false;
|
|
2852
|
+
});
|
|
2241
2853
|
}
|
|
2242
2854
|
/**
|
|
2243
2855
|
* TAL-493: what the state.db marker was recorded under: the boundary fields every boundary writer sets (an older release
|