@maudecode/talaria-web 1.18.0 → 1.20.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 +93 -38
- package/dist/api/automation-raw.js.map +1 -1
- package/dist/api/automation-router.d.ts +99 -17
- package/dist/api/automation-router.js +22 -14
- package/dist/api/automation-router.js.map +1 -1
- package/dist/api/chat-router.d.ts +1433 -37
- package/dist/api/chat-router.js +205 -90
- package/dist/api/chat-router.js.map +1 -1
- package/dist/api/git-router.d.ts +6 -2
- package/dist/api/git-router.js +30 -30
- package/dist/api/git-router.js.map +1 -1
- package/dist/api/raw-routes.d.ts +3 -3
- package/dist/api/raw-routes.js +76 -62
- package/dist/api/raw-routes.js.map +1 -1
- package/dist/api/router.d.ts +5 -5
- 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 +2635 -151
- package/dist/api/sessions-router.js +193 -177
- package/dist/api/sessions-router.js.map +1 -1
- package/dist/api/settings-router.d.ts +450 -11
- package/dist/api/settings-router.js +102 -49
- package/dist/api/settings-router.js.map +1 -1
- package/dist/api/sse-routes.d.ts +2 -2
- package/dist/api/sse-routes.js +102 -80
- 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 +963 -109
- package/dist/api/tools-router.js +426 -72
- 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 +80 -57
- package/dist/app.js.map +1 -1
- package/dist/auth/crypto.d.ts +3 -0
- package/dist/auth/crypto.js +18 -0
- package/dist/auth/crypto.js.map +1 -1
- package/dist/auth/gate.js +14 -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 +8 -2
- 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 +90 -0
- package/dist/cli/main.js.map +1 -0
- package/dist/cli/supervise.d.ts +24 -0
- package/dist/cli/supervise.js +147 -6
- package/dist/cli/supervise.js.map +1 -1
- package/dist/config/agent-config.d.ts +37 -11
- package/dist/config/agent-config.js +146 -64
- 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 +4 -0
- package/dist/fs/atomic.js +19 -2
- package/dist/fs/atomic.js.map +1 -1
- package/dist/fs/stat.d.ts +7 -0
- package/dist/fs/stat.js +27 -0
- package/dist/fs/stat.js.map +1 -0
- package/dist/http/context.d.ts +28 -9
- package/dist/http/context.js +77 -25
- 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 +12 -4
- package/dist/profiles/profiles.js +78 -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 +85 -14
- package/dist/providers/catalog.js +502 -146
- 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 +56 -23
- package/dist/redact.js.map +1 -1
- package/dist/release.d.ts +7 -0
- package/dist/release.js +19 -3
- package/dist/release.js.map +1 -1
- package/dist/runtime.d.ts +5 -1
- package/dist/runtime.js +303 -96
- package/dist/runtime.js.map +1 -1
- package/dist/server.js +31 -18
- package/dist/server.js.map +1 -1
- package/dist/sessions/anchor.d.ts +11 -4
- package/dist/sessions/anchor.js +33 -37
- 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 +49 -23
- package/dist/sessions/background-tasks.js +161 -89
- 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 +23 -6
- package/dist/sessions/cli-sessions.js +84 -84
- package/dist/sessions/cli-sessions.js.map +1 -1
- package/dist/sessions/completions.d.ts +38 -2
- package/dist/sessions/completions.js +348 -77
- 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/export.d.ts +2 -0
- package/dist/sessions/export.js +6 -2
- package/dist/sessions/export.js.map +1 -1
- package/dist/sessions/gateway-sessions.d.ts +86 -0
- package/dist/sessions/gateway-sessions.js +914 -0
- package/dist/sessions/gateway-sessions.js.map +1 -0
- package/dist/sessions/gateway-watcher.d.ts +10 -11
- package/dist/sessions/gateway-watcher.js +25 -21
- package/dist/sessions/gateway-watcher.js.map +1 -1
- 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 +34 -18
- package/dist/sessions/list.js +161 -147
- package/dist/sessions/list.js.map +1 -1
- package/dist/sessions/merge.d.ts +77 -13
- package/dist/sessions/merge.js +315 -65
- 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 +85 -0
- package/dist/sessions/prefix-index.js +243 -0
- package/dist/sessions/prefix-index.js.map +1 -0
- package/dist/sessions/recovery.d.ts +4 -7
- package/dist/sessions/recovery.js +100 -283
- 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 +251 -78
- package/dist/sessions/service.js +1510 -564
- package/dist/sessions/service.js.map +1 -1
- package/dist/sessions/session.d.ts +20 -2
- package/dist/sessions/session.js +33 -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 +24 -5
- package/dist/sessions/state-db-worker.js.map +1 -1
- package/dist/sessions/state-db.d.ts +157 -21
- package/dist/sessions/state-db.js +1145 -204
- package/dist/sessions/state-db.js.map +1 -1
- package/dist/sessions/state-saved.d.ts +1 -1
- package/dist/sessions/state-saved.js +35 -11
- package/dist/sessions/state-saved.js.map +1 -1
- package/dist/sessions/storage/backend.d.ts +171 -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 +25 -0
- package/dist/sessions/storage/json-documents.js +277 -0
- package/dist/sessions/storage/json-documents.js.map +1 -0
- package/dist/sessions/storage/json-files.d.ts +23 -0
- package/dist/sessions/storage/json-files.js +244 -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 +113 -0
- package/dist/sessions/storage/json-journal.js +756 -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 +82 -0
- package/dist/sessions/storage/json.js +350 -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 +67 -73
- package/dist/sessions/store.js +341 -607
- package/dist/sessions/store.js.map +1 -1
- package/dist/sessions/streams.d.ts +12 -7
- package/dist/sessions/streams.js +58 -15
- 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 +8 -5
- package/dist/sessions/tool-display.js +28 -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 +13 -6
- package/dist/sessions/turn-context.js.map +1 -1
- package/dist/sessions/turn.d.ts +110 -22
- package/dist/sessions/turn.js +918 -380
- 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 +41 -4
- package/dist/sidecar/client.js +192 -42
- 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.d.ts +6 -2
- package/dist/sidecar/fake.js +34 -4
- package/dist/sidecar/fake.js.map +1 -1
- package/dist/spa.d.ts +9 -4
- package/dist/spa.js +36 -12
- package/dist/spa.js.map +1 -1
- package/dist/startup-timing.d.ts +66 -0
- package/dist/startup-timing.js +129 -0
- package/dist/startup-timing.js.map +1 -0
- 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/fs-handles.d.ts +11 -0
- package/dist/test/fs-handles.js +31 -0
- package/dist/test/fs-handles.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 +46 -5
- package/dist/tools/crons.js +100 -26
- package/dist/tools/crons.js.map +1 -1
- package/dist/tools/dashboard-plugins.d.ts +34 -5
- package/dist/tools/dashboard-plugins.js +146 -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 +82 -0
- package/dist/tools/gateway-restart.js +222 -0
- package/dist/tools/gateway-restart.js.map +1 -0
- package/dist/tools/health.d.ts +28 -4
- package/dist/tools/health.js +171 -50
- 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/insights.d.ts +1 -1
- package/dist/tools/insights.js +2 -2
- package/dist/tools/insights.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/mcp-health.js +74 -24
- package/dist/tools/mcp-health.js.map +1 -1
- package/dist/tools/mcp.js +19 -1
- package/dist/tools/mcp.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-oauth.d.ts +14 -0
- package/dist/tools/plugin-oauth.js +53 -0
- package/dist/tools/plugin-oauth.js.map +1 -0
- 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/prune-advice.d.ts +50 -0
- package/dist/tools/prune-advice.js +114 -0
- package/dist/tools/prune-advice.js.map +1 -0
- package/dist/tools/skills.d.ts +13 -5
- package/dist/tools/skills.js +53 -19
- package/dist/tools/skills.js.map +1 -1
- package/dist/tools/update-notifications.d.ts +102 -17
- package/dist/tools/update-notifications.js +400 -82
- 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 +100 -33
- package/dist/tools/updates.js +1085 -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 +98 -38
- package/dist/workspace/media.js.map +1 -1
- package/dist/workspace/preview.js +4 -14
- package/dist/workspace/preview.js.map +1 -1
- package/dist/workspace/rollback.d.ts +8 -4
- package/dist/workspace/rollback.js +22 -12
- 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 +6 -4
- package/sidecar/agent_dependency.json +4 -4
- package/sidecar/agent_support.json +29 -0
- package/sidecar/scripts/record-fixtures.py +12 -3
- package/sidecar/scripts/replay_sidecar.py +8 -0
- package/sidecar/scripts/suite_runner.py +458 -0
- package/sidecar/scripts/test.sh +11 -5
- package/sidecar/talaria_sidecar/__init__.py +6 -1
- package/sidecar/talaria_sidecar/__main__.py +13 -4
- package/sidecar/talaria_sidecar/agent_mutation.py +1111 -0
- package/sidecar/talaria_sidecar/agent_services.py +1116 -0
- package/sidecar/talaria_sidecar/context_detail.py +59 -0
- package/sidecar/talaria_sidecar/dependency_sync.py +85 -0
- package/sidecar/talaria_sidecar/home.py +133 -14
- package/sidecar/talaria_sidecar/methods/__init__.py +2 -2
- package/sidecar/talaria_sidecar/methods/aux.py +211 -41
- package/sidecar/talaria_sidecar/methods/chat.py +1378 -200
- package/sidecar/talaria_sidecar/methods/checkpoints.py +29 -4
- package/sidecar/talaria_sidecar/methods/commands.py +483 -13
- package/sidecar/talaria_sidecar/methods/config.py +195 -6
- package/sidecar/talaria_sidecar/methods/connectors.py +375 -0
- package/sidecar/talaria_sidecar/methods/cron.py +374 -36
- package/sidecar/talaria_sidecar/methods/cron_execution.py +284 -0
- package/sidecar/talaria_sidecar/methods/dashboard_plugins.py +301 -0
- package/sidecar/talaria_sidecar/methods/gateway.py +215 -55
- package/sidecar/talaria_sidecar/methods/gateway_sessions.py +418 -0
- package/sidecar/talaria_sidecar/methods/goals.py +81 -41
- package/sidecar/talaria_sidecar/methods/kanban.py +82 -30
- package/sidecar/talaria_sidecar/methods/memory.py +84 -0
- package/sidecar/talaria_sidecar/methods/oauth.py +144 -13
- package/sidecar/talaria_sidecar/methods/plugin_catalog.py +198 -0
- package/sidecar/talaria_sidecar/methods/plugin_oauth.py +234 -0
- package/sidecar/talaria_sidecar/methods/process.py +229 -16
- package/sidecar/talaria_sidecar/methods/profiles.py +48 -9
- package/sidecar/talaria_sidecar/methods/providers.py +196 -26
- package/sidecar/talaria_sidecar/methods/runtime.py +71 -1
- package/sidecar/talaria_sidecar/methods/skills.py +83 -5
- package/sidecar/talaria_sidecar/methods/state_db.py +405 -112
- 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 +51 -0
- package/sidecar/talaria_sidecar/methods/usage.py +113 -14
- package/sidecar/talaria_sidecar/methods/worktree.py +145 -19
- package/sidecar/talaria_sidecar/methods/write_approval.py +181 -0
- package/sidecar/talaria_sidecar/rpc.py +70 -7
- package/sidecar/talaria_sidecar/runtime.py +122 -14
- package/sidecar/talaria_sidecar/speed.py +47 -0
- package/sidecar/talaria_sidecar/startup_timing.py +119 -0
- package/static/dist/FILES.txt +242 -215
- package/static/dist/THIRD_PARTY_LICENSES.md +6 -22
- package/static/dist/assets/AppShell-3isgvoro.js +8 -0
- package/static/dist/assets/Button-f4n6dapf.js +1 -0
- package/static/dist/assets/ChatPage-k7b6xfax.js +4 -0
- package/static/dist/assets/ConnectTargets-juqvt5lm.js +1 -0
- package/static/dist/assets/Dialog-hta5utr3.js +1 -0
- package/static/dist/assets/ExtensionRoute-zrhjn0wm.js +1 -0
- package/static/dist/assets/Field-b6ez48oz.js +1 -0
- package/static/dist/assets/HubRoute-iwvso2tz.js +2 -0
- package/static/dist/assets/LinkCheckContext-ix6zqqkk.js +1 -0
- package/static/dist/assets/Markdown-jdeth7zq.js +2 -0
- package/static/dist/assets/MarkdownRenderer-fbswsgld.js +302 -0
- package/static/dist/assets/MarkdownRenderer-i9o6nr38.css +1 -0
- package/static/dist/assets/OAuthSignIn-bpd8hom3.js +1 -0
- package/static/dist/assets/ResourceSwitch-odi6dy2o.js +1 -0
- package/static/dist/assets/RightPanel-eu6enlo6.js +1 -0
- package/static/dist/assets/Select-kr07928w.js +1 -0
- package/static/dist/assets/SessionListPanel-etb2i54l.js +1 -0
- package/static/dist/assets/SettingsLayout-eu3xxp23.js +1 -0
- package/static/dist/assets/States-icab19yg.js +1 -0
- package/static/dist/assets/TerminalPanel-hngmsr8b.js +16 -0
- package/static/dist/assets/Toaster-nb2uzjw1.js +1 -0
- package/static/dist/assets/_app-ga1moszl.js +1 -0
- package/static/dist/assets/_app.ext._extensionId-na62087t.js +1 -0
- package/static/dist/assets/_app.index-jypgdqoi.js +1 -0
- package/static/dist/assets/_app.insights-kabejdtm.js +1 -0
- package/static/dist/assets/_app.kanban-m19kfcmx.js +1 -0
- package/static/dist/assets/_app.logs-iw4m96sz.js +1 -0
- package/static/dist/assets/_app.memory-jq5frwcg.js +1 -0
- package/static/dist/assets/_app.profiles-kwa3m7rp.js +1 -0
- package/static/dist/assets/_app.session._sessionId-o5lyeipj.js +1 -0
- package/static/dist/assets/_app.settings-ckl8hdll.js +1 -0
- package/static/dist/assets/_app.settings._section-dtdjk98a.js +3 -0
- package/static/dist/assets/_app.skills-n016rihd.js +1 -0
- package/static/dist/assets/_app.tasks-n8zuqsqe.js +1 -0
- package/static/dist/assets/_app.todos-btew5v2s.js +1 -0
- package/static/dist/assets/_app.workspaces-lnfk95ch.js +1 -0
- package/static/dist/assets/abnfDiagram-VCTEODGH-dttheegb.js +1 -0
- package/static/dist/assets/{angular-html-nu6l31w3.js → angular-html-l28bd5fo.js} +1 -1
- package/static/dist/assets/{angular-ts-fbx4f8mm.js → angular-ts-ffzx1vj0.js} +1 -1
- package/static/dist/assets/{apl-bx2xr7dh.js → apl-b7h7mmy4.js} +1 -1
- package/static/dist/assets/{arc-7popfhbk.js → arc-fk1b21lm.js} +1 -1
- package/static/dist/assets/architecture-7GRP2DOG-ic435919.js +1 -0
- package/static/dist/assets/architectureDiagram-5GKGNRK7-8r3glgiq.js +36 -0
- package/static/dist/assets/archive-il7c6053.js +1 -0
- package/static/dist/assets/{astro-g77i97cb.js → astro-k117fy0t.js} +1 -1
- package/static/dist/assets/back-gu3nu279.js +1 -0
- package/static/dist/assets/{blade-boo0u92y.js → blade-npefsmn8.js} +1 -1
- package/static/dist/assets/{blockDiagram-I7D4REHJ-jzthp1wa.js → blockDiagram-I7D4REHJ-jenyos6c.js} +4 -4
- package/static/dist/assets/{c-cat0w3sc.js → c-2umg6kbn.js} +1 -1
- package/static/dist/assets/{c4Diagram-7LVT6UL2-mwr67mt8.js → c4Diagram-7LVT6UL2-nzlx7yc4.js} +1 -1
- package/static/dist/assets/cancel-bsnfo36u.js +1 -0
- package/static/dist/assets/channel-dsbtk01y.js +1 -0
- package/static/dist/assets/{chunk-4HAMMTFA-gpq1y228.js → chunk-4HAMMTFA-g0cru63b.js} +20 -20
- package/static/dist/assets/{chunk-75Z2AOVW-ktje58c4.js → chunk-75Z2AOVW-7rkswkjw.js} +1 -1
- package/static/dist/assets/chunk-DU6HZSFF-fn73jzdo.js +128 -0
- package/static/dist/assets/{chunk-F27PBJKO-cmev85ti.js → chunk-F27PBJKO-oey8e72m.js} +1 -1
- package/static/dist/assets/{chunk-FOHPRMQF-opuflp73.js → chunk-FOHPRMQF-ipewusgj.js} +8 -8
- package/static/dist/assets/{chunk-GMAD6QVW-htjgt22y.js → chunk-GMAD6QVW-l2a32wiy.js} +6 -6
- package/static/dist/assets/{chunk-GVQU2GXP-qndvoxl7.js → chunk-GVQU2GXP-o9dwzh8h.js} +1 -1
- package/static/dist/assets/{chunk-IMKFNOWR-e5bkd5q6.js → chunk-IMKFNOWR-eblwj20b.js} +5 -5
- package/static/dist/assets/{chunk-L3NEJ4N5-mj1155r6.js → chunk-L3NEJ4N5-o2mufspb.js} +1 -1
- package/static/dist/assets/chunk-OSK3NFVY-ijp9pw29.js +10 -0
- package/static/dist/assets/{chunk-P2QGCYS3-f7t54xfm.js → chunk-P2QGCYS3-hs9hqwl4.js} +1 -1
- package/static/dist/assets/{chunk-POPQ4Y6H-i927v9oa.js → chunk-POPQ4Y6H-bp9rsqez.js} +1 -1
- package/static/dist/assets/{chunk-PWAF6VOD-m84lmnyt.js → chunk-PWAF6VOD-brkprov6.js} +1 -1
- package/static/dist/assets/{chunk-SHT3W25Y-mi0tlcu9.js → chunk-SHT3W25Y-gmpv0pxz.js} +13 -13
- package/static/dist/assets/{chunk-SVP7TREG-dnau702j.js → chunk-SVP7TREG-oldm08fu.js} +4 -4
- package/static/dist/assets/{chunk-TICWLB2K-edem1gkg.js → chunk-TICWLB2K-ooqilfv7.js} +6 -6
- package/static/dist/assets/{chunk-XXDRQBXY-n40pf9vy.js → chunk-XXDRQBXY-jkmnqu65.js} +1 -1
- package/static/dist/assets/classDiagram-ZZMXUADV-fvgcv24g.js +1 -0
- package/static/dist/assets/classDiagram-v2-VYDZK3BY-fvgcv24g.js +1 -0
- package/static/dist/assets/{cobol-go622aep.js → cobol-ssf9hkh7.js} +1 -1
- package/static/dist/assets/{coffee-jwo1xjhq.js → coffee-lsi9fwae.js} +1 -1
- package/static/dist/assets/copied-bz3gkazh.js +1 -0
- package/static/dist/assets/copy-jo006coq.js +1 -0
- package/static/dist/assets/cose-bilkent-JH36ORCC-layy2r27.js +1 -0
- package/static/dist/assets/{cpp-nre7j0kq.js → cpp-lfrh4y55.js} +1 -1
- package/static/dist/assets/{crystal-lviso2je.js → crystal-efh4pte7.js} +1 -1
- package/static/dist/assets/{css-ywz9j4hb.js → css-i82qdo89.js} +1 -1
- package/static/dist/assets/cynefin-OW5HDTMX-d47op7qs.js +1 -0
- package/static/dist/assets/cynefinDiagram-5FMLGOSQ-doa7594y.js +62 -0
- package/static/dist/assets/cytoscape.esm-fjn2algp.js +321 -0
- package/static/dist/assets/dagre-GXQ25YYZ-os768dp4.js +4 -0
- package/static/dist/assets/dagre-o5jmjz46.js +1 -0
- package/static/dist/assets/{diagram-S7CK7UJ4-l8abxaes.js → diagram-S7CK7UJ4-fyi81nxd.js} +4 -4
- package/static/dist/assets/diagram-UQ7AKVKN-j3exggaq.js +41 -0
- package/static/dist/assets/{diagram-VSXAHHWV-fnky6lvc.js → diagram-VSXAHHWV-omo1en2l.js} +1 -1
- package/static/dist/assets/diagram-VX7I27RA-oro2cyu0.js +24 -0
- package/static/dist/assets/diagram-Z3DM3KII-byt9vdb7.js +24 -0
- package/static/dist/assets/dist-crc46di9.js +7 -0
- package/static/dist/assets/dist-etpoyscq.js +44 -0
- package/static/dist/assets/{dist-c4c2m3kv.js → dist-nb670eu6.js} +1 -1
- package/static/dist/assets/ebnfDiagram-PWID7BFC-ee3haugh.js +1 -0
- package/static/dist/assets/{edge-dvv35byi.js → edge-lrob635n.js} +1 -1
- package/static/dist/assets/{elixir-i2f1ki1g.js → elixir-kdpxeq93.js} +1 -1
- package/static/dist/assets/{elm-cpepyjm4.js → elm-c8irj9uh.js} +1 -1
- package/static/dist/assets/erDiagram-RLTQ6QDP-d1e93yff.js +99 -0
- package/static/dist/assets/{erb-idlyfheg.js → erb-flwsv146.js} +1 -1
- package/static/dist/assets/eventmodeling-NTZA5JFV-i1ihb40c.js +1 -0
- package/static/dist/assets/flowDiagram-HODETNUW-jahfpiod.js +1 -0
- package/static/dist/assets/folder-plus-ivoglq98.js +1 -0
- package/static/dist/assets/{ganttDiagram-EL5Y4UJY-ngbsyp37.js → ganttDiagram-EL5Y4UJY-m7g0jud4.js} +1 -1
- package/static/dist/assets/{git-rebase-d9lpfpbn.js → git-rebase-bli19aoq.js} +1 -1
- package/static/dist/assets/gitGraph-4MIJSDKK-hzdj07yy.js +1 -0
- package/static/dist/assets/{gitGraphDiagram-WWUBYQGX-eravdti4.js → gitGraphDiagram-WWUBYQGX-dm9agbna.js} +1 -1
- package/static/dist/assets/{glimmer-js-39i0urfb.js → glimmer-js-d4icg38p.js} +1 -1
- package/static/dist/assets/{glimmer-ts-gdtd6n73.js → glimmer-ts-bk75772u.js} +1 -1
- package/static/dist/assets/{glsl-o1djm5pp.js → glsl-lqormp2b.js} +1 -1
- package/static/dist/assets/{graphql-kj6rz55t.js → graphql-hw6s5qzd.js} +1 -1
- package/static/dist/assets/{hack-pe1qqr2i.js → hack-ozrv6l4d.js} +1 -1
- package/static/dist/assets/{haml-cknnbcqs.js → haml-gytfosdt.js} +1 -1
- package/static/dist/assets/{handlebars-hwq64orh.js → handlebars-b6g7jffd.js} +1 -1
- package/static/dist/assets/highlighted-body-KPVGNVTW-mmgz2czu.js +1 -0
- package/static/dist/assets/host-fr6lni1p.js +1 -0
- package/static/dist/assets/{html-derivative-h2i2pyjc.js → html-derivative-f1tqcroo.js} +1 -1
- package/static/dist/assets/{html-ha05xtvt.js → html-on6oj6yj.js} +1 -1
- package/static/dist/assets/{http-cf94zu27.js → http-odhufsl7.js} +1 -1
- package/static/dist/assets/{hurl-lslax568.js → hurl-mu680336.js} +1 -1
- package/static/dist/assets/index-73649w7o.js +10 -0
- package/static/dist/assets/index-lhf1fsdf.css +2 -0
- package/static/dist/assets/info-A6RAGUB7-lll1dqmm.js +1 -0
- package/static/dist/assets/infoDiagram-27XIBGKW-k7esc639.js +2 -0
- package/static/dist/assets/{ishikawaDiagram-5VMMS53U-i6ypq351.js → ishikawaDiagram-5VMMS53U-lgr6k9wo.js} +6 -6
- package/static/dist/assets/{java-ndjf2o89.js → java-nxbe8uju.js} +1 -1
- package/static/dist/assets/{javascript-coftlwy3.js → javascript-lz3kxcyz.js} +1 -1
- package/static/dist/assets/{jinja-etei9wy7.js → jinja-l5pk84gt.js} +1 -1
- package/static/dist/assets/{jison-hyekgft1.js → jison-jdutdwyy.js} +1 -1
- package/static/dist/assets/{journeyDiagram-3NMN7TZE-lduf1z95.js → journeyDiagram-3NMN7TZE-ndmbxuqk.js} +1 -1
- package/static/dist/assets/{json-lok0yaho.js → json-dnge4ssf.js} +1 -1
- package/static/dist/assets/{jsx-b64w0nbx.js → jsx-c3lwhkep.js} +1 -1
- package/static/dist/assets/{jsx-runtime-eu92a5pu.js → jsx-runtime-o40ukmjl.js} +1 -1
- package/static/dist/assets/{julia-jxxzz2yn.js → julia-hwd4rrcb.js} +1 -1
- package/static/dist/assets/{just-mc29iw9t.js → just-nka3oxdx.js} +1 -1
- package/static/dist/assets/{kanban-definition-UXKFOSKX-e18bu4pg.js → kanban-definition-UXKFOSKX-mjxym0jp.js} +28 -28
- package/static/dist/assets/katex-cbdd1ohw.css +1 -0
- package/static/dist/assets/katex-pbij2oo6.js +257 -0
- package/static/dist/assets/{latex-pafa03ft.js → latex-iqmr7e40.js} +1 -1
- package/static/dist/assets/lazyRouteComponent-cwb6l7xh.js +1 -0
- package/static/dist/assets/{line-meizqsvn.js → line-mtr5k6il.js} +1 -1
- package/static/dist/assets/linear-jlwrvvy6.js +1 -0
- package/static/dist/assets/link-iadw4ghw.js +2 -0
- package/static/dist/assets/{liquid-hz5c2pov.js → liquid-okmk9tbp.js} +1 -1
- package/static/dist/assets/locale-cs-n41hptse.js +5 -0
- package/static/dist/assets/locale-de-k7ht47ew.js +5 -0
- package/static/dist/assets/locale-es-ih078lco.js +5 -0
- package/static/dist/assets/locale-fr-ojabnf41.js +5 -0
- package/static/dist/assets/locale-it-kb96jufy.js +5 -0
- package/static/dist/assets/locale-ja-jpsp3xs1.js +7 -0
- package/static/dist/assets/locale-ko-cpzuy0n7.js +5 -0
- package/static/dist/assets/locale-pl-dbap4leu.js +5 -0
- package/static/dist/assets/locale-pt-nci7w72x.js +5 -0
- package/static/dist/assets/locale-ru-soghe4cs.js +7 -0
- package/static/dist/assets/locale-tr-ks00mu68.js +5 -0
- package/static/dist/assets/locale-vi-ij7twfm6.js +5 -0
- package/static/dist/assets/locale-zh-Hant-feakwr7a.js +5 -0
- package/static/dist/assets/locale-zh-ren0vjyv.js +7 -0
- package/static/dist/assets/login-lkhtzfcl.js +1 -0
- package/static/dist/assets/{lua-leyf8mlb.js → lua-krx4xj5h.js} +1 -1
- package/static/dist/assets/{marko-cn1fzpdv.js → marko-gbvpfehz.js} +1 -1
- package/static/dist/assets/{mdc-huhy88df.js → mdc-c21zlyi5.js} +1 -1
- package/static/dist/assets/mermaid-HWGCJPDP-jx0q5dm2.js +1 -0
- package/static/dist/assets/mermaid-parser.core-k91vdnvi.js +7 -0
- package/static/dist/assets/micromark-factory-space-fnmnpi6s.js +1 -0
- package/static/dist/assets/{mindmap-definition-YA3MSWOX-27b26eok.js → mindmap-definition-YA3MSWOX-ehztfea4.js} +28 -28
- package/static/dist/assets/{nginx-e7lvhsmn.js → nginx-f3d68ed4.js} +1 -1
- package/static/dist/assets/{nim-dun5d8mk.js → nim-dw0jcy70.js} +1 -1
- package/static/dist/assets/onboarding-f67z0a2r.js +1 -0
- package/static/dist/assets/packet-AYTQ26CC-ok2xrblz.js +1 -0
- package/static/dist/assets/pcmTap.worklet-74EKjWAZ.js +1 -0
- package/static/dist/assets/pegDiagram-XKGWAZYB-ec3rpj0m.js +1 -0
- package/static/dist/assets/{perl-bj3oqtet.js → perl-3c0r45c8.js} +1 -1
- package/static/dist/assets/{php-buzp12r0.js → php-rznhidvp.js} +1 -1
- package/static/dist/assets/pie-WAS4IAKB-iwkir23u.js +1 -0
- package/static/dist/assets/pieDiagram-E7YTZNPT-fcpqpekh.js +39 -0
- package/static/dist/assets/{pug-mjad7he9.js → pug-f2le4f3y.js} +1 -1
- package/static/dist/assets/{qml-d1gdp10s.js → qml-e2wbq9cr.js} +1 -1
- package/static/dist/assets/{quadrantDiagram-AXDQQJYC-hhue7d8o.js → quadrantDiagram-AXDQQJYC-lqsync43.js} +1 -1
- package/static/dist/assets/queryKeys-ed4n2egc.js +9 -0
- package/static/dist/assets/{r-o3r9c7ao.js → r-epfzuw6q.js} +1 -1
- package/static/dist/assets/radar-RG4KPBEZ-hje4jbk6.js +1 -0
- package/static/dist/assets/railroad-74A4TZTK-gll9n6qi.js +1 -0
- package/static/dist/assets/railroad-abnf-HS5TGJTU-o6h6lpn4.js +1 -0
- package/static/dist/assets/railroad-ebnf-LZEXJU2U-juaefj6o.js +1 -0
- package/static/dist/assets/railroad-peg-WCYAUIDC-cgfhvpah.js +1 -0
- package/static/dist/assets/railroadDiagram-O6MQD6OU-lp3w7awf.js +1 -0
- package/static/dist/assets/{razor-ilc585yr.js → razor-pea1u1d0.js} +1 -1
- package/static/dist/assets/refresh-cw-iccm7a4e.js +1 -0
- package/static/dist/assets/refresh-i9ya9dvl.js +1 -0
- package/static/dist/assets/{regexp-mpjtcvwm.js → regexp-hzte0a5x.js} +1 -1
- package/static/dist/assets/registry-g7w3353u.js +1 -0
- package/static/dist/assets/requirementDiagram-BXWQKSXE-ha8eibcf.js +84 -0
- package/static/dist/assets/rolldown-runtime-bnnw9q4a.js +1 -0
- package/static/dist/assets/{rst-jrkwjdyi.js → rst-m6tugj5o.js} +1 -1
- package/static/dist/assets/{ruby-n4tjcbpz.js → ruby-l19vq3w1.js} +1 -1
- package/static/dist/assets/runtime-nlxpnegr.js +1 -0
- package/static/dist/assets/{sankeyDiagram-P5KCCOFB-fqb8xcjf.js → sankeyDiagram-P5KCCOFB-oxf31pr9.js} +1 -1
- package/static/dist/assets/{sas-hjo6y93k.js → sas-ktdxo397.js} +1 -1
- package/static/dist/assets/{scss-ncidjv48.js → scss-kn3oxfwf.js} +1 -1
- package/static/dist/assets/{sequenceDiagram-WJ2MYXX4-dlcz6qjz.js → sequenceDiagram-WJ2MYXX4-jiq8lhyo.js} +11 -11
- package/static/dist/assets/settings_section_extensions_title-fadc7o65.js +1 -0
- package/static/dist/assets/share._token-o76ki0ly.js +2 -0
- package/static/dist/assets/{shellscript-m8whjpqd.js → shellscript-l100jtrg.js} +1 -1
- package/static/dist/assets/{shellsession-i5bj6syz.js → shellsession-m5qukwow.js} +1 -1
- package/static/dist/assets/sizeCapture-INFHLROL-dk1io13w.js +1 -0
- package/static/dist/assets/sliders-horizontal-ddcs88av.js +1 -0
- package/static/dist/assets/{soy-hhh36ysq.js → soy-mexlscur.js} +1 -1
- package/static/dist/assets/{sql-j21bzcl6.js → sql-o0ho5suh.js} +1 -1
- package/static/dist/assets/src-h89s0hs4.js +1 -0
- package/static/dist/assets/{stata-bbu2onh1.js → stata-be9ck1q3.js} +1 -1
- package/static/dist/assets/stateDiagram-D77RDMKH-f3fslnon.js +1 -0
- package/static/dist/assets/stateDiagram-v2-MP3YSRHH-hlt6yphw.js +1 -0
- package/static/dist/assets/{surrealql-l5vczuco.js → surrealql-gk1xkkyy.js} +1 -1
- package/static/dist/assets/{svelte-idn422g6.js → svelte-k6ecjilf.js} +1 -1
- package/static/dist/assets/swimlanes-42K2YHIH-k1hmk7jf.js +1 -0
- package/static/dist/assets/swimlanesDiagram-VR7AAH4N-hd5t9tnt.js +8 -0
- package/static/dist/assets/{templ-e22qou3c.js → templ-gf3bftk6.js} +1 -1
- package/static/dist/assets/{tex-deqpc5ee.js → tex-d21fqv1z.js} +1 -1
- package/static/dist/assets/{timeline-definition-24CTP7MA-b1vl2zil.js → timeline-definition-24CTP7MA-n3ncfy9r.js} +8 -8
- package/static/dist/assets/toast-liparz2j.js +56 -0
- package/static/dist/assets/treeView-Q6P3EWNA-l3y2pejt.js +1 -0
- package/static/dist/assets/treemap-WGGIJYW6-jown9axh.js +1 -0
- package/static/dist/assets/{ts-tags-ctnibc27.js → ts-tags-n0g61gr6.js} +1 -1
- package/static/dist/assets/{tsx-i163n32g.js → tsx-cvv7wv6q.js} +1 -1
- package/static/dist/assets/{twig-x0fi7wm8.js → twig-n1f67204.js} +1 -1
- package/static/dist/assets/{typescript-g18c34xz.js → typescript-og7o55cx.js} +1 -1
- package/static/dist/assets/untitled-mj86r4gz.js +1 -0
- package/static/dist/assets/useDebounced-g8r0ggve.js +1 -0
- package/static/dist/assets/useForm-o4uu74mx.js +1 -0
- package/static/dist/assets/{useParams-b4dq6n4q.js → useParams-ftsveu84.js} +1 -1
- package/static/dist/assets/{useSearch-ipfmv3he.js → useSearch-crqueqga.js} +1 -1
- package/static/dist/assets/vennDiagram-4TSXK5OY-dh59uq1c.js +34 -0
- package/static/dist/assets/{vue-mwu5l1bb.js → vue-ej4xpwqw.js} +1 -1
- package/static/dist/assets/{vue-html-nskj3du0.js → vue-html-d48xsjuo.js} +1 -1
- package/static/dist/assets/{vue-vine-cqe77smq.js → vue-vine-pev88ip6.js} +1 -1
- package/static/dist/assets/wardley-WFR3VGLG-e6d74b0j.js +1 -0
- package/static/dist/assets/wardleyDiagram-VM6X3IG4-hnofh78f.js +78 -0
- package/static/dist/assets/with-selector-nu1b5z0v.js +1 -0
- package/static/dist/assets/{xml-gau7gvrv.js → xml-ifxp6gqb.js} +1 -1
- package/static/dist/assets/{xsl-ew09zq8b.js → xsl-fug2cckc.js} +1 -1
- package/static/dist/assets/{xychartDiagram-S5SC5T6Z-e49523ab.js → xychartDiagram-S5SC5T6Z-bpymjelb.js} +6 -6
- package/static/dist/assets/{yaml-ofnrxtvf.js → yaml-fjmafnlo.js} +1 -1
- package/static/dist/extension-sdk.js +1 -1
- package/static/dist/index.html +1 -1
- package/static/dist/sw.js +180 -136
- 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/Markdown-lwviulmg.css +0 -1
- 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.ext._extensionId-gfbzpoou.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.session._sessionId-fn1bhde6.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/abnfDiagram-VCTEODGH-c8kdixke.js +0 -1
- package/static/dist/assets/architecture-7GRP2DOG-6crvnylg.js +0 -1
- package/static/dist/assets/architectureDiagram-5GKGNRK7-cku06x1p.js +0 -36
- 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/chunk-DU6HZSFF-ofajdixh.js +0 -128
- package/static/dist/assets/chunk-OSK3NFVY-e7gkikcw.js +0 -10
- 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/copy-magtofyp.js +0 -1
- package/static/dist/assets/cose-bilkent-JH36ORCC-eclcy6ja.js +0 -1
- package/static/dist/assets/cynefin-OW5HDTMX-siqlulkl.js +0 -1
- package/static/dist/assets/cynefinDiagram-5FMLGOSQ-i648tah9.js +0 -62
- package/static/dist/assets/cytoscape.esm-bqu53u12.js +0 -321
- package/static/dist/assets/dagre-GXQ25YYZ-mwn6uc17.js +0 -4
- package/static/dist/assets/dagre-lkoknbjn.js +0 -1
- package/static/dist/assets/diagram-UQ7AKVKN-mekq7zgq.js +0 -41
- package/static/dist/assets/diagram-VX7I27RA-xe6r5qfw.js +0 -24
- package/static/dist/assets/diagram-Z3DM3KII-nq07dg7k.js +0 -24
- package/static/dist/assets/ebnfDiagram-PWID7BFC-c62ipm3g.js +0 -1
- package/static/dist/assets/endpoints-klbxod4t.js +0 -64
- package/static/dist/assets/erDiagram-RLTQ6QDP-h39kmnzy.js +0 -99
- package/static/dist/assets/eventmodeling-NTZA5JFV-obfede4u.js +0 -1
- package/static/dist/assets/flowDiagram-HODETNUW-kdpsp1je.js +0 -1
- package/static/dist/assets/gitGraph-4MIJSDKK-fr2i7caa.js +0 -1
- package/static/dist/assets/highlighted-body-KPVGNVTW-jwpop9p8.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/info-A6RAGUB7-h11x0dxo.js +0 -1
- package/static/dist/assets/infoDiagram-27XIBGKW-dfh47dw9.js +0 -2
- package/static/dist/assets/lazyRouteComponent-e1y4t2i9.js +0 -1
- package/static/dist/assets/linear-becwlnhk.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/mermaid-parser.core-ntne45i9.js +0 -7
- package/static/dist/assets/not-found-ccnnac39.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/packet-AYTQ26CC-l6jdka8p.js +0 -1
- package/static/dist/assets/pegDiagram-XKGWAZYB-g1er3bxj.js +0 -1
- package/static/dist/assets/pie-WAS4IAKB-i0ylcrt9.js +0 -1
- package/static/dist/assets/pieDiagram-E7YTZNPT-fbz1e55z.js +0 -39
- package/static/dist/assets/radar-RG4KPBEZ-f9b70aa4.js +0 -1
- package/static/dist/assets/railroad-74A4TZTK-lgsuoeda.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/railroadDiagram-O6MQD6OU-eqpy9fnz.js +0 -1
- package/static/dist/assets/refresh-abtu93ke.js +0 -1
- package/static/dist/assets/refresh-cw-hlwb02qv.js +0 -1
- package/static/dist/assets/requirementDiagram-BXWQKSXE-ogf6puhi.js +0 -84
- package/static/dist/assets/rolldown-runtime-b9miacsj.js +0 -1
- package/static/dist/assets/root-mbklpuv4.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/sizeCapture-INFHLROL-g612l48j.js +0 -1
- package/static/dist/assets/sliders-horizontal-n1zm34qq.js +0 -1
- package/static/dist/assets/src-hjyib4mu.js +0 -1
- package/static/dist/assets/sse-iwku0sjo.js +0 -1
- package/static/dist/assets/stateDiagram-D77RDMKH-fyoznpcs.js +0 -1
- package/static/dist/assets/stateDiagram-v2-MP3YSRHH-ixhx8r46.js +0 -1
- package/static/dist/assets/swimlanes-42K2YHIH-oefldhfy.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/treeView-Q6P3EWNA-f7hcci5o.js +0 -1
- package/static/dist/assets/treemap-WGGIJYW6-nl85071a.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/static/dist/assets/vennDiagram-4TSXK5OY-h5hzbk0n.js +0 -34
- package/static/dist/assets/wardley-WFR3VGLG-jftvpm3e.js +0 -1
- package/static/dist/assets/wardleyDiagram-VM6X3IG4-hedh85ge.js +0 -78
- /package/static/dist/assets/{ChatPage-gmu5c7lf.css → TerminalPanel-gmu5c7lf.css} +0 -0
package/dist/sessions/service.js
CHANGED
|
@@ -1,28 +1,33 @@
|
|
|
1
|
+
import { unavailableContextDetail } from '@maudecode/talaria-web-contracts';
|
|
2
|
+
import { filterAsync } from '../util.js';
|
|
3
|
+
import { statSignature } from '../fs/stat.js';
|
|
4
|
+
import { SessionPrefixIndex } from './prefix-index.js';
|
|
1
5
|
import { str } from '../util.js';
|
|
2
|
-
import { randomUUID } from 'node:crypto';
|
|
6
|
+
import { createHmac, randomUUID } from 'node:crypto';
|
|
3
7
|
import { rmSync } from 'node:fs';
|
|
4
8
|
import { buildActiveTurnToken, copyJson, redactSessionData, redactValue, stripPublicInternalFields, withSceneToolDisplay } from '../redact.js';
|
|
5
9
|
import { DraftVersionConflict, normalizeDraftVersion } from './drafts.js';
|
|
6
|
-
import { allSessions, buildSessionListPayload, CLI_IDENTITY_FIELDS, isClaimableCliSource, isMessagingSessionRecord, ownerLockCandidates, withOwnerLocks, withSessionWireFlags, lineageRootId, mergeCliSidebarMetadata, sessionListResponse, sessionSearchMatches,
|
|
10
|
+
import { allSessions, buildSessionListPayload, reconcileLocalSessionRows, sessionLineageIds, CLI_IDENTITY_FIELDS, isClaimableCliSource, isMessagingSessionRecord, ownerLockCandidates, withOwnerLocks, withSessionWireFlags, lineageRootId, mergeCliSidebarMetadata, sessionListResponse, sessionSearchMatches, sessionSearchPreview, sessionSearchTerms } from './list.js';
|
|
7
11
|
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';
|
|
12
|
+
import { contextUsage, COMPRESSION_RECOVERY_ACTION_START_FOCUSED, compressionRecoveryPayload, forgetContextFigures, isSafeSessionId, lastMessageTimestamp, Session, sharePath, stripAttachedFilesMarker, titleFrom, WORKTREE_RETAINED_KEY } from './session.js';
|
|
13
|
+
import { SessionBusy, SessionNotFound } from './store.js';
|
|
14
|
+
import { UNSETTLED_TODO_KEY, attachTodoSnapshot, attachTodoState } from './todo.js';
|
|
11
15
|
import { isClaudeCodeSessionId } from './claude-code.js';
|
|
12
16
|
import { auditSessionRecovery, repairSafeSessionRecovery } from './recovery.js';
|
|
13
|
-
import {
|
|
17
|
+
import { stateDbWebDiagnosticIdsAsync, agentSessionRowsExistingAsync, CANONICAL_READ_ONLY_REASON, OWNERSHIP_UNAVAILABLE_REASON, CONVERSATION_ROUND_THRESHOLD, emptyLineageReport, stateDbSessionReadAsync, stateDbSessionSourcesAsync, stateDbTimestampSeconds, withStateDbListingReads } from './state-db.js';
|
|
14
18
|
import { completionIncomplete, fallbackHandoffSummary, HANDOFF_SYSTEM_PROMPT, handoffMarker, handoffPayload, handoffTranscript, messageHandoffPayload, sameHandoff } from './handoff.js';
|
|
15
19
|
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';
|
|
20
|
+
import { chatReadiness, runtimeFailureDetails, SidecarError } from '../sidecar/client.js';
|
|
21
|
+
import { agentSteerText, ApiMessageSummary, appendStateDbIdentityRows, attachedFilesPrompt, attachmentObjects, checkpointTurnStart, dedupeContext, isContextCompressionMarker, journalOutputRows, looksLikeCurrentUserTurn, stoppedTurnContext, mergeSessionMessagesAppendOnly, messageIdentity, pendingUserRow, reasoningFieldsText, sanitizeMessagesForApi, withDataUseRequirements, stateDbSeenId, stripWorkspacePrefix, withAttachmentObjects, newestSettledReply, withBodyExcerpts, withDisplayMedia, withMarkerKinds, withMirrorSources, withSceneRowMedia, withPendingUserTurn, withToolCallOutcomes, withoutRunningTurnOutput } from './merge.js';
|
|
18
22
|
import { withBackgroundUpdates } from './background-updates.js';
|
|
23
|
+
import { markedStateDbGeneration, stateDbIdentityKeys, stampStateDbGeneration, stateDbMessageRepresented } from './state-db-identity.js';
|
|
19
24
|
import { sourceKind } from './source-kind.js';
|
|
20
25
|
import { withBackgroundLinks } from './background-tasks.js';
|
|
21
|
-
import { messagesForLimitedPayload, messageWindowForDisplay, MAX_MSG_LIMIT, parseMsgLimit, toolCallsForMessageWindow } from './window.js';
|
|
26
|
+
import { messagesForLimitedPayload, messageCountsAsRenderable, messageWindowForDisplay, MAX_MSG_LIMIT, parseMsgLimit, toolCallsForMessageWindow } from './window.js';
|
|
22
27
|
import { redactText } from '../redact.js';
|
|
23
28
|
import { workspaceDisplayName } from '../workspace/workspaces.js';
|
|
24
29
|
import { buildShareSnapshot } from './shares.js';
|
|
25
|
-
import {
|
|
30
|
+
import { mediaAnchorRootResolved, resolveMediaAccess, mediaRefPath, mediaTarget } from '../workspace/media.js';
|
|
26
31
|
import { projectMediaRefs } from '../workspace/media-refs.js';
|
|
27
32
|
import { loadGatewaySessionIdentityMap } from './list.js';
|
|
28
33
|
import { join } from 'node:path';
|
|
@@ -38,11 +43,16 @@ export class HttpFailure extends Error {
|
|
|
38
43
|
}
|
|
39
44
|
/**
|
|
40
45
|
* Python `_agent_runtime_barrier_response`: a stale local Agent checkout is refused with a typed 409 before the caller
|
|
41
|
-
* mutates anything.
|
|
46
|
+
* mutates anything. An unavailable connection is refused before dispatch; other check failures are not a verdict
|
|
47
|
+
* and let the caller proceed.
|
|
42
48
|
*/
|
|
43
49
|
export async function ensureAgentRuntimeCurrent(sidecar) {
|
|
44
50
|
if (!sidecar)
|
|
45
51
|
return;
|
|
52
|
+
// Runtime RPCs bypass the client's ready gate and can queue behind an unfinished handshake.
|
|
53
|
+
if (chatReadiness(sidecar).condition === 'sidecar_unavailable') {
|
|
54
|
+
throw new HttpFailure(503, 'The Agent sidecar is not ready; retry once it finishes starting.', { condition: 'sidecar_unavailable' });
|
|
55
|
+
}
|
|
46
56
|
try {
|
|
47
57
|
await sidecar.call('runtime.ensure_current', {});
|
|
48
58
|
}
|
|
@@ -51,8 +61,33 @@ export async function ensureAgentRuntimeCurrent(sidecar) {
|
|
|
51
61
|
throw staleRuntimeFailure(error);
|
|
52
62
|
}
|
|
53
63
|
}
|
|
64
|
+
/** The health probe is also the browser's server-reachability probe (30 s), so the readiness check answers well inside it. */
|
|
65
|
+
const CHAT_READINESS_CHECK_TIMEOUT_MS = 5_000;
|
|
66
|
+
/** TAL-1254: how soon a worktree lock the sidecar could not confirm is tried again. */
|
|
67
|
+
export const WORKTREE_RETAIN_RETRY_MS = 60_000;
|
|
68
|
+
/**
|
|
69
|
+
* TAL-951: whether a chat turn would be admitted now. Ready and source-stale incompatible sidecars both run turn
|
|
70
|
+
* admission's live check: recovery state can change after the handshake. A check
|
|
71
|
+
* that cannot answer fails closed as `sidecar_unavailable`.
|
|
72
|
+
*/
|
|
73
|
+
export async function currentChatReadiness(sidecar) {
|
|
74
|
+
const readiness = chatReadiness(sidecar);
|
|
75
|
+
if (!sidecar || (!readiness.available && readiness.condition !== 'agent_runtime_stale'))
|
|
76
|
+
return readiness;
|
|
77
|
+
try {
|
|
78
|
+
await sidecar.call('runtime.ensure_current', {}, { timeoutMs: CHAT_READINESS_CHECK_TIMEOUT_MS });
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
if (error instanceof SidecarError && error.condition === 'agent_runtime_stale')
|
|
82
|
+
return { available: false, ...runtimeFailureDetails(error.condition, error.data), condition: 'agent_runtime_stale' };
|
|
83
|
+
return { available: false, condition: error instanceof SidecarError && error.condition === 'agent_incompatible' ? 'agent_incompatible' : 'sidecar_unavailable' };
|
|
84
|
+
}
|
|
85
|
+
// The live guard can clear after a rollback, but the client's incompatible admission gate still refuses chat.
|
|
86
|
+
return readiness.available ? readiness : { available: false, ...runtimeFailureDetails('agent_runtime_stale'), condition: 'agent_runtime_stale' };
|
|
87
|
+
}
|
|
54
88
|
function staleRuntimeFailure(error) {
|
|
55
|
-
|
|
89
|
+
const { message, ...details } = runtimeFailureDetails(error.condition, error.data);
|
|
90
|
+
return new HttpFailure(409, message ?? error.message, details);
|
|
56
91
|
}
|
|
57
92
|
/** TAL-536: the most of a dead run's journal stale-stream cleanup reads (Python's recovery window). */
|
|
58
93
|
const RECOVERY_JOURNAL_MAX_BYTES = 8 * 1024 * 1024;
|
|
@@ -60,29 +95,34 @@ const RECOVERY_JOURNAL_MAX_ROWS = 65536;
|
|
|
60
95
|
const isDict = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v);
|
|
61
96
|
export class SessionService {
|
|
62
97
|
deps;
|
|
98
|
+
owner = null;
|
|
63
99
|
/** TAL-255: one manual compression worker per session, shared by every compress route. */
|
|
64
100
|
compressionJobs;
|
|
101
|
+
/** TAL-1005: the turns now waiting on their session's compression job before they read its history. */
|
|
102
|
+
compactionWaiters = new Set();
|
|
103
|
+
worktreeRetainRetry = null;
|
|
104
|
+
worktreesUnconfirmed = 0;
|
|
65
105
|
constructor(deps) {
|
|
66
106
|
this.deps = deps;
|
|
67
107
|
this.compressionJobs = new CompressionJobs();
|
|
68
108
|
}
|
|
69
109
|
get store() { return this.deps.store; }
|
|
70
110
|
/**
|
|
71
|
-
*
|
|
111
|
+
* User-turn admission shared by `chat.start` and automatic turns through `TurnRunner.startServerTurn`.
|
|
72
112
|
* A requested workspace must be trusted; the session's own one, when its directory is gone, falls back to the last one.
|
|
73
113
|
*/
|
|
74
|
-
turnWorkspace(s, requested) {
|
|
114
|
+
async turnWorkspace(s, requested) {
|
|
75
115
|
const ws = this.deps.workspaces;
|
|
76
116
|
if (requested !== undefined && requested !== null && requested !== '') {
|
|
77
117
|
try {
|
|
78
|
-
return ws.resolveTrusted(str(requested), s.profile);
|
|
118
|
+
return (await ws.resolveTrusted(str(requested), s.profile));
|
|
79
119
|
}
|
|
80
120
|
catch (error) {
|
|
81
121
|
throw new HttpFailure(400, error.message);
|
|
82
122
|
}
|
|
83
123
|
}
|
|
84
124
|
try {
|
|
85
|
-
const [workspace, recovered] = ws.resolveImplicitWithRecovery(s.workspace, (p) => ws.lastWorkspace(p), s.profile);
|
|
125
|
+
const [workspace, recovered] = (await ws.resolveImplicitWithRecovery(s.workspace, async (p) => (await ws.lastWorkspace(p)), s.profile));
|
|
86
126
|
if (recovered)
|
|
87
127
|
s.workspace = workspace;
|
|
88
128
|
return workspace;
|
|
@@ -91,7 +131,7 @@ export class SessionService {
|
|
|
91
131
|
throw new HttpFailure(400, error.message);
|
|
92
132
|
}
|
|
93
133
|
}
|
|
94
|
-
/**
|
|
134
|
+
/** Split qualified ids and opportunistically repair against fresh evidence; missing discovery never delays a turn. */
|
|
95
135
|
turnModel(s, body = {}) {
|
|
96
136
|
const requestedModel = str(body.model) || s.model;
|
|
97
137
|
const requestedProvider = 'model_provider' in body ? body.model_provider : s.model_provider;
|
|
@@ -105,18 +145,19 @@ export class SessionService {
|
|
|
105
145
|
* TAL-186: rewrites a session's media references to their URLs. A local path is rewritten only when `/api/media`
|
|
106
146
|
* serves it for this session, under the same resolution, so every rewritten reference loads.
|
|
107
147
|
*/
|
|
108
|
-
mediaProjector(s) {
|
|
148
|
+
async mediaProjector(s) {
|
|
109
149
|
const media = this.deps.media;
|
|
150
|
+
const access = media ? await resolveMediaAccess(media.access) : null;
|
|
110
151
|
const served = new Map();
|
|
111
152
|
const localUrl = (path) => {
|
|
112
|
-
if (!media?.localIo(s.profile))
|
|
153
|
+
if (!media?.localIo(s.profile) || !access)
|
|
113
154
|
return null;
|
|
114
155
|
if (served.has(path))
|
|
115
156
|
return served.get(path) ?? null;
|
|
116
157
|
let url = null;
|
|
117
158
|
try {
|
|
118
159
|
const target = mediaTarget(mediaRefPath(path));
|
|
119
|
-
if (
|
|
160
|
+
if (mediaAnchorRootResolved(target, s, access))
|
|
120
161
|
url = `./api/media?${new URLSearchParams({ path: target, session_id: s.session_id }).toString()}`;
|
|
121
162
|
}
|
|
122
163
|
catch { /* an invalid path stays text */ }
|
|
@@ -143,7 +184,7 @@ export class SessionService {
|
|
|
143
184
|
* not slip past the profile check into a handler that trims and trusts it. A non-empty id that is still unsafe after
|
|
144
185
|
* trimming can name no session and is refused here instead of falling through.
|
|
145
186
|
*/
|
|
146
|
-
sessionIdVisible(raw) {
|
|
187
|
+
async sessionIdVisible(raw) {
|
|
147
188
|
if (typeof raw !== 'string' || !raw.trim())
|
|
148
189
|
return true;
|
|
149
190
|
const sid = raw.trim();
|
|
@@ -151,62 +192,112 @@ export class SessionService {
|
|
|
151
192
|
return false;
|
|
152
193
|
let session;
|
|
153
194
|
try {
|
|
154
|
-
session = this.store.get(sid, { metadataOnly: true });
|
|
195
|
+
session = (await this.store.get(sid, { metadataOnly: true }));
|
|
155
196
|
}
|
|
156
197
|
catch {
|
|
157
198
|
return true;
|
|
158
199
|
}
|
|
159
200
|
return this.visibleToActiveProfile(session.profile);
|
|
160
201
|
}
|
|
202
|
+
localTargets = null;
|
|
203
|
+
/** One receipt-backed physical target for every operation on a local conversation alias, read on the state.db worker. */
|
|
204
|
+
async resolveSessionId(sid) {
|
|
205
|
+
try {
|
|
206
|
+
const path = this.stateDbPath(null);
|
|
207
|
+
const stamp = () => JSON.stringify([statSignature(path), statSignature(`${path}-wal`), path]);
|
|
208
|
+
const key = stamp();
|
|
209
|
+
if (this.localTargets?.key !== key)
|
|
210
|
+
this.localTargets = { key, ids: new Map() };
|
|
211
|
+
const cached = this.localTargets.ids.get(sid);
|
|
212
|
+
if (cached)
|
|
213
|
+
return cached;
|
|
214
|
+
const { target, read } = await stateDbSessionReadAsync({ target: sid, read: false }, 'stateDbLocalTargetRead', path, sid);
|
|
215
|
+
// A failed or changing read cannot seed a routing cache.
|
|
216
|
+
const targets = this.localTargets;
|
|
217
|
+
if (read && targets.key === key && stamp() === key) {
|
|
218
|
+
if (targets.ids.size >= 500)
|
|
219
|
+
targets.ids.clear();
|
|
220
|
+
targets.ids.set(sid, target);
|
|
221
|
+
}
|
|
222
|
+
return target;
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
return sid;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
161
228
|
/** Python `_lookup_cli_session_metadata(...).get('read_only')` plus the persisted sidecar flag and a not-claimable foreign owner. */
|
|
162
|
-
isReadOnlyImport(sid) {
|
|
229
|
+
async isReadOnlyImport(sid, reads = this.sessionGateReads(sid)) {
|
|
163
230
|
try {
|
|
164
|
-
const s = this.store.get(sid, { metadataOnly: true });
|
|
165
|
-
if (this.
|
|
231
|
+
const s = (await this.store.get(sid, { metadataOnly: true }));
|
|
232
|
+
if (await this.loadedReadOnlyReason(s, undefined, reads))
|
|
166
233
|
return true;
|
|
167
234
|
}
|
|
168
235
|
catch {
|
|
169
236
|
// No sidecar: a foreign state.db transcript whose owner refuses claiming is read-only too.
|
|
170
|
-
const { session, reason } = this.claimOrSynthesizeCliSession(sid);
|
|
237
|
+
const { session, reason } = (await this.claimOrSynthesizeCliSession(sid, undefined, reads));
|
|
171
238
|
if (session && reason === 'not_claimable')
|
|
172
239
|
return true;
|
|
173
240
|
}
|
|
174
|
-
const meta =
|
|
241
|
+
const meta = await reads.meta();
|
|
175
242
|
return meta !== null && Boolean(meta.read_only);
|
|
176
243
|
}
|
|
177
244
|
/** Python `_session_is_subagent_view_only`: a delegated child by any signal — the persisted sidecar or the state.db row. */
|
|
178
|
-
isSubagentViewOnly(sid) {
|
|
245
|
+
async isSubagentViewOnly(sid, reads = this.sessionGateReads(sid)) {
|
|
246
|
+
let profile = this.deps.activeProfile();
|
|
179
247
|
try {
|
|
180
|
-
const s = this.store.get(sid);
|
|
248
|
+
const s = (await this.store.get(sid, { metadataOnly: true, cacheOnMiss: false, promote: false }));
|
|
249
|
+
profile = s.profile || 'default';
|
|
181
250
|
if (str(s.source_tag || s.raw_source || s.session_source).trim().toLowerCase() === 'subagent')
|
|
182
251
|
return true;
|
|
183
252
|
}
|
|
184
253
|
catch { /* no sidecar */ }
|
|
185
|
-
|
|
186
|
-
|
|
254
|
+
// Admission needs authority, not the sidebar's visibility, message counts or enrichment.
|
|
255
|
+
const owners = await reads.owners(profile);
|
|
256
|
+
if (!owners)
|
|
257
|
+
throw new HttpFailure(403, OWNERSHIP_UNAVAILABLE_REASON);
|
|
258
|
+
const owner = owners.get(sid);
|
|
259
|
+
return str(typeof owner === 'object' ? owner.source : owner).trim().toLowerCase() === 'subagent';
|
|
260
|
+
}
|
|
261
|
+
/** TAL-358: batched `sessions.source` owners from the active profile's state.db, read on the state.db worker (TAL-923). */
|
|
262
|
+
stateDbSources = async (ids, profile = this.deps.activeProfile()) => {
|
|
263
|
+
try {
|
|
264
|
+
return await stateDbSessionSourcesAsync(this.stateDbPath(profile), ids);
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
/** The owners of `byProfile`'s ids, read up front, as the synchronous lookup `withOwnerLocks` takes. */
|
|
271
|
+
async ownerSources(byProfile, read = this.stateDbSources) {
|
|
272
|
+
const owners = new Map(await Promise.all([...byProfile].map(async ([profile, ids]) => [profile, ids.size ? await read([...ids], profile) : new Map()])));
|
|
273
|
+
return (_ids, profile = this.deps.activeProfile()) => owners.get(profile) ?? null;
|
|
274
|
+
}
|
|
275
|
+
/** `withOwnerLocks` with the owners read on the state.db worker, so a gate or payload never opens state.db on this thread. */
|
|
276
|
+
async ownerLocked(rows, read) {
|
|
277
|
+
return withOwnerLocks(rows, await this.ownerSources(ownerLockCandidates(rows), read));
|
|
187
278
|
}
|
|
188
|
-
/** TAL-358: batched `sessions.source` owners from the active profile's state.db. */
|
|
189
|
-
stateDbSources = (ids) => stateDbSessionSources(join(this.deps.profileHome(this.deps.activeProfile()), 'state.db'), ids);
|
|
190
279
|
/**
|
|
191
280
|
* TAL-590 (Python #3238): the read-only CLI or API-server imports among `rows` whose active-profile state.db row is
|
|
192
281
|
* gone, so their source was deleted outside Web and no Web action could remove them. An unreadable state.db proves
|
|
193
282
|
* nothing (`agentSessionRowsExisting`); another profile's session or one with a run the runtime still holds is never
|
|
194
283
|
* an orphan, while a stream id a crash left behind does not count as one.
|
|
195
284
|
*/
|
|
196
|
-
orphanedImports(rows) {
|
|
197
|
-
const candidates = this.orphanCandidates(rows);
|
|
285
|
+
async orphanedImports(rows, existing, owners, reads) {
|
|
286
|
+
const candidates = (await this.orphanCandidates(rows));
|
|
198
287
|
if (!candidates.length)
|
|
199
288
|
return new Set();
|
|
200
|
-
|
|
201
|
-
|
|
289
|
+
existing ??= await (reads ? reads.existing() : agentSessionRowsExistingAsync(this.stateDbPath(null), candidates));
|
|
290
|
+
if (owners === undefined)
|
|
291
|
+
owners = await (reads ? reads.owners() : this.stateDbSources(candidates));
|
|
292
|
+
return new Set(candidates.filter((sid) => !existing.has(sid) && owners !== null && typeof owners.get(sid) !== 'object'));
|
|
202
293
|
}
|
|
203
|
-
orphanCandidates(rows) {
|
|
204
|
-
return rows
|
|
294
|
+
async orphanCandidates(rows) {
|
|
295
|
+
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
296
|
.map((r) => str(r.session_id));
|
|
206
297
|
}
|
|
207
|
-
isOrphanedImport(sid) {
|
|
298
|
+
async isOrphanedImport(sid, reads = this.sessionGateReads(sid)) {
|
|
208
299
|
try {
|
|
209
|
-
return this.orphanedImports([this.store.get(sid, { metadataOnly: true }).compact()]).size > 0;
|
|
300
|
+
return (await this.orphanedImports([(await this.store.get(sid, { metadataOnly: true })).compact()], undefined, undefined, reads)).size > 0;
|
|
210
301
|
}
|
|
211
302
|
catch {
|
|
212
303
|
return false;
|
|
@@ -216,51 +307,100 @@ export class SessionService {
|
|
|
216
307
|
* The list's owner locks and orphan prune (an orphan's sidecar goes, as nothing else ever removes it), with their
|
|
217
308
|
* state.db reads made on the listing worker up front so the synchronous build never opens state.db (TAL-920).
|
|
218
309
|
*/
|
|
219
|
-
async listStateDbLookups(rows) {
|
|
310
|
+
async listStateDbLookups(rows, cliRowIds) {
|
|
220
311
|
const dbPath = this.stateDbPath(null);
|
|
221
|
-
const
|
|
222
|
-
const
|
|
223
|
-
const
|
|
312
|
+
const activeProfile = this.deps.activeProfile();
|
|
313
|
+
const byProfile = ownerLockCandidates(rows);
|
|
314
|
+
const activeIds = byProfile.get(activeProfile) ?? new Set();
|
|
315
|
+
for (const id of cliRowIds)
|
|
316
|
+
activeIds.add(id);
|
|
317
|
+
byProfile.set(activeProfile, activeIds);
|
|
318
|
+
const owners = await this.ownerSources(byProfile);
|
|
319
|
+
const checked = new Set((await this.orphanCandidates(rows)));
|
|
224
320
|
const existing = checked.size ? await agentSessionRowsExistingAsync(dbPath, checked) : new Set();
|
|
225
321
|
return {
|
|
226
|
-
stateDbSources:
|
|
227
|
-
pruneOrphanedImports: (current) => {
|
|
322
|
+
stateDbSources: owners,
|
|
323
|
+
pruneOrphanedImports: async (current) => {
|
|
228
324
|
// 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 =
|
|
325
|
+
const orphans = (await this.orphanedImports(current.filter((r) => checked.has(str(r.session_id))), existing, owners([], activeProfile)));
|
|
230
326
|
for (const sid of orphans)
|
|
231
|
-
this.store.
|
|
327
|
+
(await this.store.delete(sid));
|
|
232
328
|
return orphans;
|
|
233
329
|
},
|
|
234
330
|
};
|
|
235
331
|
}
|
|
236
332
|
/** 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
|
-
|
|
333
|
+
async readOnlyReason(s, profile, reads) {
|
|
334
|
+
if (typeof s === 'string') {
|
|
335
|
+
try {
|
|
336
|
+
s = await this.store.get(s, { metadataOnly: true, cacheOnMiss: false, promote: false });
|
|
337
|
+
}
|
|
338
|
+
catch (error) {
|
|
339
|
+
if (!(error instanceof SessionNotFound))
|
|
340
|
+
return OWNERSHIP_UNAVAILABLE_REASON;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return await this.loadedReadOnlyReason(s, profile, reads);
|
|
344
|
+
}
|
|
345
|
+
async loadedReadOnlyReason(s, profile, reads) {
|
|
346
|
+
let raw = typeof s === 'string' ? { session_id: s, profile: this.deps.activeProfile() }
|
|
347
|
+
: { 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 };
|
|
348
|
+
if (profile !== undefined)
|
|
349
|
+
raw = this.deps.profilesMatch(str(raw.profile) || null, profile) ? { ...raw, profile } : { session_id: raw.session_id, profile };
|
|
350
|
+
const row = (await this.ownerLocked([raw], reads ? (_ids, ownerProfile) => reads.owners(ownerProfile) : undefined))[0];
|
|
351
|
+
return row.read_only ? str(row.read_only_reason) || 'Read-only imported sessions cannot be modified from WebUI' : null;
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* The final owner check before a protected sidecar dispatch or write: callers dispatch as soon as it resolves. The
|
|
355
|
+
* owner is the Agent's, which can change at any moment, so reading it on the state.db worker (TAL-923) widens no
|
|
356
|
+
* window Web could close; the session lock, where one is held, still serializes Web's own writes.
|
|
357
|
+
*/
|
|
358
|
+
async assertWritable(session, profile, reads) {
|
|
359
|
+
const reason = await this.readOnlyReason(session, profile, reads);
|
|
360
|
+
if (reason)
|
|
361
|
+
throw new HttpFailure(403, reason);
|
|
362
|
+
}
|
|
363
|
+
/** Controls can exist without a local session file; use the same id the sidecar will address. */
|
|
364
|
+
async idForMutation(sid) {
|
|
365
|
+
sid = sid.trim();
|
|
366
|
+
if (!sid)
|
|
367
|
+
throw new HttpFailure(400, 'Missing required field(s): session_id');
|
|
368
|
+
await this.assertWritable(sid);
|
|
369
|
+
return sid;
|
|
370
|
+
}
|
|
371
|
+
async isReadOnly(s) { return (await this.readOnlyReason(s)) !== null; }
|
|
372
|
+
/** Recheck after the mutex wait; legacy local metadata/handoff actions keep their existing read-only allowance. */
|
|
373
|
+
withMutationLock(sid, fn, opts = {}) {
|
|
374
|
+
return this.store.withLock(sid, async () => {
|
|
375
|
+
if (opts.allowReadOnly)
|
|
376
|
+
(await this.rejectViewOnly(sid, 'modified'));
|
|
377
|
+
else
|
|
378
|
+
(await this.assertWritable(sid));
|
|
379
|
+
return fn();
|
|
380
|
+
}, opts);
|
|
242
381
|
}
|
|
243
382
|
/** 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');
|
|
383
|
+
async getForMutation(sid, reads) {
|
|
384
|
+
let s = (await this.get404(sid, {}, reads));
|
|
385
|
+
s = (await this.store.ensureFull(sid, s));
|
|
386
|
+
await this.assertWritable(s, undefined, reads?.sid === s.session_id ? reads : undefined);
|
|
249
387
|
if (str(s.source_tag || s.raw_source).trim().toLowerCase() === 'subagent')
|
|
250
388
|
throw new HttpFailure(403, 'Read-only subagent child session');
|
|
251
389
|
return s;
|
|
252
390
|
}
|
|
253
|
-
get404(sid, opts = {}) {
|
|
391
|
+
async get404(sid, opts = {}, reads) {
|
|
392
|
+
sid = await this.resolveSessionId(sid);
|
|
254
393
|
try {
|
|
255
|
-
return this.store.get(sid, opts);
|
|
394
|
+
return (await this.store.get(sid, opts));
|
|
256
395
|
}
|
|
257
396
|
catch (error) {
|
|
258
397
|
if (error instanceof SessionNotFound) {
|
|
259
398
|
// Python `_get_or_materialize_session`: a claimable foreign (CLI/TUI/Desktop) session gains a WebUI sidecar
|
|
260
399
|
// on its first mutation; a read-only foreign source answers 403 from `getForMutation`, a missing one 404.
|
|
261
|
-
const synth = this.claimOrSynthesizeCliSession(sid);
|
|
400
|
+
const synth = (await this.claimOrSynthesizeCliSession(sid, undefined, reads));
|
|
262
401
|
if (synth.session && synth.reason === 'materialized') {
|
|
263
|
-
this.store.save(synth.session);
|
|
402
|
+
await this.store.save(synth.session);
|
|
403
|
+
await this.store.touch(synth.session);
|
|
264
404
|
this.deps.titleClaimed?.(synth.session);
|
|
265
405
|
return synth.session;
|
|
266
406
|
}
|
|
@@ -272,16 +412,36 @@ export class SessionService {
|
|
|
272
412
|
}
|
|
273
413
|
}
|
|
274
414
|
/** The Agent's state.db rows for this session (empty for subagent views, which never merge). */
|
|
275
|
-
stateDbRows(s) {
|
|
276
|
-
return this.stateDbRead(s).rows;
|
|
415
|
+
async stateDbRows(s) {
|
|
416
|
+
return (await this.stateDbRead(s)).rows;
|
|
277
417
|
}
|
|
278
|
-
/**
|
|
418
|
+
/**
|
|
419
|
+
* `stateDbRows` and whether the read succeeded on a state.db whose messages carry ids (TAL-493). TAL-1162: read on the
|
|
420
|
+
* state.db worker; a worker failure is a failed read.
|
|
421
|
+
*/
|
|
279
422
|
stateDbRead(s) {
|
|
280
|
-
if (
|
|
281
|
-
return
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
423
|
+
if (isSubagentView(s))
|
|
424
|
+
return Promise.resolve(failedStateDbRead());
|
|
425
|
+
return stateDbSessionReadAsync(failedStateDbRead(), 'stateDbSessionRead', this.stateDbPath(s.profile), s.session_id, { stitch: false, lineage: stateDbReadLineage(s) });
|
|
426
|
+
}
|
|
427
|
+
/** `stateDbRead`'s projection of the rows past `afterId` alone (the detail's page tail), on the state.db worker. */
|
|
428
|
+
stateDbTailRead(s, afterId, unidentified) {
|
|
429
|
+
return stateDbSessionReadAsync(failedStateDbRead(), 'stateDbSessionTailRead', this.stateDbPath(s.profile), s.session_id, afterId, { lineage: stateDbReadLineage(s), unidentified });
|
|
430
|
+
}
|
|
431
|
+
/** The one read a turn's settlement builds its context from and settles with (`stateDbSettlementRead`). */
|
|
432
|
+
settlementRead(s) {
|
|
433
|
+
const unchanged = { ...failedStateDbRead(), lineage: s.state_db_lineage ?? [s.session_id], rotated: null };
|
|
434
|
+
if (isSubagentView(s))
|
|
435
|
+
return Promise.resolve(unchanged);
|
|
436
|
+
return stateDbSessionReadAsync(unchanged, 'stateDbSettlementRead', this.stateDbPath(s.profile), s.session_id, s.state_db_lineage);
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Preserve existing transcript provenance before a turn adds Agent rows from its starting database read. The stamp
|
|
440
|
+
* lands before this returns; the read follows.
|
|
441
|
+
*/
|
|
442
|
+
beginStateDbTurn(s) {
|
|
443
|
+
stampStateDbGeneration([...s.messages, ...s.context_messages], markedStateDbGeneration(s));
|
|
444
|
+
return this.stateDbRead(s);
|
|
285
445
|
}
|
|
286
446
|
stateDbPath(profile) {
|
|
287
447
|
return join(this.deps.profileHome(profile ?? this.deps.activeProfile()), 'state.db');
|
|
@@ -291,17 +451,21 @@ export class SessionService {
|
|
|
291
451
|
* rows (a WebUI conversation continued from the CLI shows the CLI turns). This is the coordinate space `GET
|
|
292
452
|
* /api/session` exposes, so branching and the next model history slice/extend the same list.
|
|
293
453
|
*/
|
|
294
|
-
mergedTranscript(s, local
|
|
454
|
+
mergedTranscript(s, local, stateRows) {
|
|
295
455
|
if (!stateRows.length)
|
|
296
456
|
return local;
|
|
297
|
-
return mergeSessionMessagesAppendOnly(local, stateRows, { truncationWatermark: s.truncation_watermark, compressedWatermark: s.truncation_watermark_compressed, stateDbSeenId: currentStateDbSeenId(s, stateRows) });
|
|
457
|
+
return mergeSessionMessagesAppendOnly(local, stateRows, { truncationWatermark: s.truncation_watermark, compressedWatermark: s.truncation_watermark_compressed, stateDbSeenId: currentStateDbSeenId(s, stateRows), stateDbGeneration: markedStateDbGeneration(s) });
|
|
298
458
|
}
|
|
299
459
|
/**
|
|
300
460
|
* TAL-493: record the highest state.db id this boundary or settled turn read, under the session lock in the same save,
|
|
301
461
|
* so the merge never replays a covered row and appends every row committed after the read. Call it after the boundary
|
|
302
462
|
* fields are set: the marker holds only while they and the row it names stay as recorded.
|
|
303
463
|
*/
|
|
304
|
-
markStateDbSeen(s, read
|
|
464
|
+
markStateDbSeen(s, read, opts = {}) {
|
|
465
|
+
// Freeze numeric provenance before replacing the marker, including after a database replacement.
|
|
466
|
+
const priorGeneration = markedStateDbGeneration(s);
|
|
467
|
+
if (priorGeneration !== null)
|
|
468
|
+
stampStateDbGeneration([...s.messages, ...s.context_messages], priorGeneration);
|
|
305
469
|
// A failed read says nothing. A settlement keeps the marker; a boundary drops it (fail closed): it may cut rows the
|
|
306
470
|
// marker still admits without moving a field, and the timestamp rules honour its watermark.
|
|
307
471
|
if (!read.ok) {
|
|
@@ -326,22 +490,38 @@ export class SessionService {
|
|
|
326
490
|
* turn started from but the transcript lacks (a CLI continuation read into its context) are kept before the turn.
|
|
327
491
|
*/
|
|
328
492
|
settleStateDb(s, turn) {
|
|
329
|
-
const read = turn.read
|
|
330
|
-
const
|
|
331
|
-
const
|
|
493
|
+
const read = turn.read;
|
|
494
|
+
const agentAdded = withoutRows(turn.agentRows ?? [], turn.previousContext);
|
|
495
|
+
const contextAdded = withoutRows(s.context_messages, turn.previousContext);
|
|
496
|
+
if (typeof turn.startGeneration === 'string') {
|
|
497
|
+
stampStateDbGeneration(s.messages.filter((m) => m._turn_id !== turn.turnId), markedStateDbGeneration(s));
|
|
498
|
+
stampStateDbGeneration([...s.messages.filter((m) => m._turn_id === turn.turnId), ...contextAdded, ...agentAdded], turn.startGeneration);
|
|
499
|
+
}
|
|
500
|
+
const startGeneration = turn.previousContext.findLast((m) => typeof m._state_db_generation === 'string')?._state_db_generation;
|
|
501
|
+
const generation = turn.startGeneration ?? markedStateDbGeneration(s) ?? (typeof startGeneration === 'string' ? startGeneration : null);
|
|
502
|
+
const readGeneration = read.generation;
|
|
503
|
+
const generationChanged = generation !== null && typeof readGeneration === 'string' && generation !== readGeneration;
|
|
504
|
+
const includeUids = read.rows.some((m) => stateDbIdentityKeys(m).some((id) => id.startsWith('uid:')));
|
|
505
|
+
const identities = (m) => stateDbIdentityKeys(m, includeUids, generation);
|
|
506
|
+
const startedWith = new Set(turn.previousContext.flatMap((m) => identities(m)));
|
|
507
|
+
const wasInContext = (m) => stateDbMessageRepresented(m, startedWith, includeUids, generation);
|
|
508
|
+
const earlier = mergeSessionMessagesAppendOnly(s.messages, read.rows.filter(wasInContext), { stateDbSeenId: -1, stateDbGeneration: generation }).slice(s.messages.length);
|
|
332
509
|
const added = new Map();
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
510
|
+
const addedIds = new Set(agentAdded.flatMap((m) => identities(m)));
|
|
511
|
+
for (const m of agentAdded) {
|
|
512
|
+
if (!identities(m).length)
|
|
513
|
+
added.set(settledIdentity(m), (added.get(settledIdentity(m)) ?? 0) + 1);
|
|
514
|
+
}
|
|
515
|
+
const ownWorkAfter = turn.agentRows?.length || generationChanged ? null : turn.startId ?? null;
|
|
336
516
|
// 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;
|
|
517
|
+
const seenId = currentStateDbSeenId(s, read.rows) ?? (generationChanged ? null : turn.startId ?? null);
|
|
518
|
+
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
519
|
const fresh = new Set(shown.slice(s.messages.length));
|
|
340
520
|
// The turn's own rows the merge already matched to their local copies use up their share before any shown row can.
|
|
341
521
|
if (seenId !== null) {
|
|
342
522
|
for (const m of read.rows) {
|
|
343
523
|
const id = m._state_db_row_id;
|
|
344
|
-
if (typeof id !== 'number' || id <= seenId ||
|
|
524
|
+
if (typeof id !== 'number' || id <= seenId || wasInContext(m) || fresh.has(m))
|
|
345
525
|
continue;
|
|
346
526
|
const left = added.get(settledIdentity(m)) ?? 0;
|
|
347
527
|
if (left > 0)
|
|
@@ -350,7 +530,9 @@ export class SessionService {
|
|
|
350
530
|
}
|
|
351
531
|
const missed = [...fresh].filter((m) => {
|
|
352
532
|
const id = m._state_db_row_id;
|
|
353
|
-
if (
|
|
533
|
+
if (wasInContext(m))
|
|
534
|
+
return false;
|
|
535
|
+
if (stateDbMessageRepresented(m, addedIds, includeUids, generation))
|
|
354
536
|
return false;
|
|
355
537
|
const key = settledIdentity(m);
|
|
356
538
|
const left = added.get(key) ?? 0;
|
|
@@ -367,17 +549,17 @@ export class SessionService {
|
|
|
367
549
|
if (missed.length) {
|
|
368
550
|
s.messages.push(...copyJson(missed));
|
|
369
551
|
if (s.context_messages.length)
|
|
370
|
-
s.context_messages.
|
|
552
|
+
s.context_messages = mergeSessionMessagesAppendOnly(s.context_messages, copyJson(missed), { stateDbSeenId: -1, stateDbGeneration: generation });
|
|
371
553
|
}
|
|
372
554
|
this.markStateDbSeen(s, read);
|
|
373
555
|
// TAL-529: a compression rotation moved the Agent to a continuation, whose first rows restate the compressed context.
|
|
374
556
|
// The read follows the new lineage from here, and everything it holds now is covered.
|
|
375
557
|
// ponytail: a CLI row committed to the continuation before this settlement is covered with them; telling them apart
|
|
376
558
|
// needs the Agent to mark the rows it writes at rotation.
|
|
377
|
-
const lineage =
|
|
559
|
+
const lineage = read.lineage;
|
|
378
560
|
if (JSON.stringify(lineage) !== JSON.stringify(s.state_db_lineage ?? [s.session_id])) {
|
|
379
561
|
s.state_db_lineage = lineage.length > 1 ? lineage : null;
|
|
380
|
-
this.markStateDbSeen(s);
|
|
562
|
+
this.markStateDbSeen(s, read.rotated ?? failedStateDbRead());
|
|
381
563
|
}
|
|
382
564
|
}
|
|
383
565
|
/**
|
|
@@ -411,46 +593,83 @@ export class SessionService {
|
|
|
411
593
|
*/
|
|
412
594
|
modelContext(s, stateRows) {
|
|
413
595
|
const local = s.context_messages.length ? s.context_messages : s.messages.filter((m) => !m._error && !m._partial);
|
|
414
|
-
return local.some((m) => isContextCompressionMarker(m)) ? local : this.mergedTranscript(s, local, stateRows
|
|
596
|
+
return local.some((m) => isContextCompressionMarker(m)) ? local : this.mergedTranscript(s, local, stateRows);
|
|
597
|
+
}
|
|
598
|
+
/** `modelContext` over a state.db read taken now. */
|
|
599
|
+
async readModelContext(s) {
|
|
600
|
+
return this.modelContext(s, await this.stateDbRows(s));
|
|
415
601
|
}
|
|
416
602
|
/**
|
|
417
603
|
* TAL-518: the model context a `/btw` clone inherits: the chat's history plus its running turn's prompt, which
|
|
418
604
|
* deferred save keeps out of `messages` and `context_messages` until settlement.
|
|
419
605
|
*/
|
|
420
|
-
sideQuestionContext(s) {
|
|
421
|
-
const context = structuredClone(this.
|
|
606
|
+
async sideQuestionContext(s) {
|
|
607
|
+
const context = structuredClone(await this.readModelContext(s));
|
|
422
608
|
const pending = this.pendingTurn(s);
|
|
423
609
|
return pending ? withPendingUserTurn(context, pending) : context;
|
|
424
610
|
}
|
|
425
611
|
/** Python `_is_messaging_session_id`: from the WebUI metadata or the Agent's row. */
|
|
426
|
-
isMessagingSession(sid) {
|
|
612
|
+
async isMessagingSession(sid, reads = this.sessionGateReads(sid)) {
|
|
427
613
|
try {
|
|
428
|
-
if (isMessagingSessionRecord(this.store.get(sid, { metadataOnly: true }).compact()))
|
|
614
|
+
if (isMessagingSessionRecord((await this.store.get(sid, { metadataOnly: true })).compact()))
|
|
429
615
|
return true;
|
|
430
616
|
}
|
|
431
617
|
catch { /* absent */ }
|
|
432
|
-
const meta =
|
|
618
|
+
const meta = await reads.meta();
|
|
433
619
|
return meta !== null && isMessagingSessionRecord(meta);
|
|
434
620
|
}
|
|
435
|
-
|
|
436
|
-
|
|
621
|
+
sessionGateReads(sid) {
|
|
622
|
+
const profile = this.deps.activeProfile();
|
|
623
|
+
const dbPath = this.stateDbPath(profile);
|
|
624
|
+
const owners = new Map();
|
|
625
|
+
let row;
|
|
626
|
+
let meta;
|
|
627
|
+
let transcript;
|
|
628
|
+
let context;
|
|
629
|
+
let existing;
|
|
630
|
+
return {
|
|
631
|
+
sid,
|
|
632
|
+
owners: (ownerProfile = profile) => {
|
|
633
|
+
let pending = owners.get(ownerProfile);
|
|
634
|
+
if (!pending) {
|
|
635
|
+
pending = this.stateDbSources([sid], ownerProfile);
|
|
636
|
+
owners.set(ownerProfile, pending);
|
|
637
|
+
}
|
|
638
|
+
return pending;
|
|
639
|
+
},
|
|
640
|
+
row: () => row ??= stateDbSessionReadAsync(null, 'stateDbSessionRow', dbPath, sid),
|
|
641
|
+
meta: () => meta ??= this.lookupCliMeta(sid),
|
|
642
|
+
messages: (stitch) => stitch
|
|
643
|
+
? transcript ??= isClaudeCodeSessionId(sid) ? Promise.resolve(this.deps.claudeCode?.messages(sid) ?? []) : stateDbSessionReadAsync([], 'stateDbSessionMessages', dbPath, sid, { stitch: true })
|
|
644
|
+
: context ??= stateDbSessionReadAsync([], 'stateDbSessionMessages', dbPath, sid, { stitch: false }),
|
|
645
|
+
existing: () => existing ??= agentSessionRowsExistingAsync(dbPath, [sid]),
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* Python `_lookup_cli_session_metadata`: the sidebar row for a state.db session in the active profile, else its plain
|
|
650
|
+
* row (a segment its chain's row does not name). Both are read by id: the sidebar listing would scan every session to
|
|
651
|
+
* rank its window (TAL-923).
|
|
652
|
+
*/
|
|
653
|
+
async lookupCliMeta(sid) {
|
|
437
654
|
try {
|
|
438
655
|
if (isClaudeCodeSessionId(sid))
|
|
439
|
-
return this.claudeCodeRows().find((r) => str(r.session_id) === sid) ?? null;
|
|
440
|
-
|
|
656
|
+
return (await this.claudeCodeRows()).find((r) => str(r.session_id) === sid) ?? null;
|
|
657
|
+
const profile = this.deps.activeProfile();
|
|
658
|
+
return (await this.deps.cliSessions(profile, { sourceFilter: null, sessionIds: [sid], sidebarRows: true })).rows.find((r) => str(r.session_id) === sid)
|
|
659
|
+
?? (await this.deps.cliSessions(profile, { sourceFilter: null, sessionIds: [sid] })).rows[0] ?? null;
|
|
441
660
|
}
|
|
442
661
|
catch {
|
|
443
662
|
return null;
|
|
444
663
|
}
|
|
445
664
|
}
|
|
446
|
-
claudeCodeRows() {
|
|
447
|
-
return this.deps.claudeCode?.rows(this.deps.workspaces.lastWorkspace(this.deps.activeProfile())) ?? [];
|
|
665
|
+
async claudeCodeRows() {
|
|
666
|
+
return this.deps.claudeCode?.rows((await this.deps.workspaces.lastWorkspace(this.deps.activeProfile()))) ?? [];
|
|
448
667
|
}
|
|
449
668
|
/** Python `_session_index_marks_was_webui`: an index row that once owned a WebUI sidecar (self-heal 404). */
|
|
450
|
-
indexMarksWasWebui(sid) {
|
|
669
|
+
async indexMarksWasWebui(sid) {
|
|
451
670
|
let entries;
|
|
452
671
|
try {
|
|
453
|
-
entries = this.store.readIndexEntries();
|
|
672
|
+
entries = (await this.store.readIndexEntries());
|
|
454
673
|
}
|
|
455
674
|
catch {
|
|
456
675
|
return false;
|
|
@@ -472,20 +691,24 @@ export class SessionService {
|
|
|
472
691
|
* a WebUI sidecar. `materialized` sessions are writeable (the caller persists the sidecar), `not_claimable` ones are
|
|
473
692
|
* read-only stubs, `was_webui`/`no_foreign_state`/`invalid_sid` answer 404.
|
|
474
693
|
*/
|
|
475
|
-
claimOrSynthesizeCliSession(sid, metaIn) {
|
|
694
|
+
async claimOrSynthesizeCliSession(sid, metaIn, reads) {
|
|
476
695
|
if (!isSafeSessionId(sid))
|
|
477
696
|
return { session: null, reason: 'invalid_sid' };
|
|
478
697
|
const profile = this.deps.activeProfile();
|
|
479
|
-
|
|
480
|
-
|
|
698
|
+
sid = await this.resolveSessionId(sid);
|
|
699
|
+
if (reads?.sid !== sid)
|
|
700
|
+
reads = this.sessionGateReads(sid);
|
|
701
|
+
const [row, owners] = await Promise.all([reads.row(), reads.owners()]);
|
|
481
702
|
const stateDbSource = str(row?.source).trim().toLowerCase();
|
|
482
703
|
const subagentChild = stateDbSource === 'subagent';
|
|
483
|
-
|
|
704
|
+
const canonical = typeof owners?.get(sid) === 'object';
|
|
705
|
+
if (((await this.indexMarksWasWebui(sid)) || ((await this.store.wasDeleted(sid)) && ['', 'webui', 'fork'].includes(stateDbSource))) && !subagentChild && !canonical)
|
|
484
706
|
return { session: null, reason: 'was_webui' };
|
|
485
|
-
const msgs =
|
|
486
|
-
|
|
707
|
+
const msgs = await reads.messages(true);
|
|
708
|
+
const meta = { ...(metaIn ?? (await reads.meta()) ?? {}) };
|
|
709
|
+
// An owner receipt proves a session before its first turn (a fresh diagnostic chat, TAL-1040); a bare row does not.
|
|
710
|
+
if (!msgs.length && !(meta._lineage_root_id && meta._lineage_tip_id) && !canonical)
|
|
487
711
|
return { session: null, reason: 'no_foreign_state' };
|
|
488
|
-
const meta = { ...(metaIn ?? this.lookupCliMeta(sid) ?? {}) };
|
|
489
712
|
if (row) {
|
|
490
713
|
if (!meta.source_tag && stateDbSource)
|
|
491
714
|
meta.source_tag = stateDbSource;
|
|
@@ -502,12 +725,16 @@ export class SessionService {
|
|
|
502
725
|
if (!meta.updated_at && (row.ended_at || row.started_at))
|
|
503
726
|
meta.updated_at = row.ended_at || row.started_at;
|
|
504
727
|
}
|
|
505
|
-
|
|
506
|
-
const
|
|
507
|
-
const
|
|
728
|
+
// An owner read that fails is an unknown owner, which never releases a claim (TAL-793).
|
|
729
|
+
const claimable = owners !== null && isClaimableCliSource(meta, owners.get(sid) || stateDbSource);
|
|
730
|
+
const workspace = str(meta.workspace || meta.cwd).trim() || (await this.deps.workspaces.lastWorkspace(profile));
|
|
731
|
+
const defaults = (await this.store.deps.defaults(profile));
|
|
732
|
+
const context = Array.isArray(meta._lineage_ids) ? await reads.messages(false) : null;
|
|
508
733
|
const session = new Session({
|
|
509
734
|
session_id: sid, title: str(meta.title) || 'CLI Session', workspace, model: str(meta.model) || 'unknown', model_provider: str(meta.model_provider) || null,
|
|
510
735
|
messages: msgs, created_at: Number(meta.created_at) || 0, updated_at: Number(meta.updated_at) || 0, profile: str(meta.profile) || null,
|
|
736
|
+
pinned: Boolean(meta.pinned), archived: Boolean(meta.archived),
|
|
737
|
+
...(context ? { context_messages: context } : {}),
|
|
511
738
|
is_cli_session: claimable ? true : !subagentChild, source_tag: str(meta.source_tag) || null, raw_source: str(meta.raw_source) || null,
|
|
512
739
|
session_source: str(meta.session_source) || null, source_label: str(meta.source_label) || null, read_only: !claimable,
|
|
513
740
|
// Python `import_cli_session`: the claimed sidecar keeps the row's lineage, background project and channel identity
|
|
@@ -517,9 +744,9 @@ export class SessionService {
|
|
|
517
744
|
}, defaults);
|
|
518
745
|
return { session, reason: claimable ? 'materialized' : 'not_claimable' };
|
|
519
746
|
}
|
|
520
|
-
mutationTarget(sid, verb) {
|
|
747
|
+
async mutationTarget(sid, verb, reads) {
|
|
521
748
|
try {
|
|
522
|
-
return this.getForMutation(sid);
|
|
749
|
+
return (await this.getForMutation(sid, reads));
|
|
523
750
|
}
|
|
524
751
|
catch (error) {
|
|
525
752
|
if (error instanceof SessionNotFound)
|
|
@@ -537,49 +764,67 @@ export class SessionService {
|
|
|
537
764
|
if (isClaudeCodeSessionId(sid))
|
|
538
765
|
throw new HttpFailure(400, `Claude Code sessions are view-only and cannot be ${verb} from WebUI`);
|
|
539
766
|
}
|
|
540
|
-
|
|
767
|
+
async rejectViewOnly(sid, verb, reads) {
|
|
768
|
+
sid = sid.trim();
|
|
769
|
+
if (reads?.sid !== sid)
|
|
770
|
+
reads = this.sessionGateReads(sid);
|
|
541
771
|
this.rejectClaudeCode(sid, verb);
|
|
542
|
-
|
|
772
|
+
let profile = this.deps.activeProfile();
|
|
773
|
+
try {
|
|
774
|
+
profile = (await this.store.get(sid, { metadataOnly: true, cacheOnMiss: false, promote: false })).profile || 'default';
|
|
775
|
+
}
|
|
776
|
+
catch { /* foreign row */ }
|
|
777
|
+
const owners = await reads.owners(profile);
|
|
778
|
+
const owner = owners?.get(sid);
|
|
779
|
+
if (!owners || typeof owner === 'object')
|
|
780
|
+
throw new HttpFailure(403, typeof owner === 'object' ? owner.reason ?? CANONICAL_READ_ONLY_REASON : OWNERSHIP_UNAVAILABLE_REASON);
|
|
781
|
+
if ((await this.isSubagentViewOnly(sid, reads)))
|
|
543
782
|
throw new HttpFailure(400, `Subagent sessions are view-only and cannot be ${verb} from WebUI`);
|
|
783
|
+
return sid;
|
|
784
|
+
}
|
|
785
|
+
/** All transcript transports use the same live-run and durable-pending evidence. */
|
|
786
|
+
async backgroundUpdates(s, messages, reviewRows) {
|
|
787
|
+
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
788
|
}
|
|
545
789
|
/** 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) ?? []);
|
|
790
|
+
async backgroundLinked(s, messages) {
|
|
791
|
+
return withBackgroundLinks(messages, (await this.deps.backgroundReceipts?.(s.session_id)) ?? []);
|
|
548
792
|
}
|
|
549
793
|
/** `compact()` plus messages, redacted for the wire (Python `_public_session_projection`). */
|
|
550
|
-
publicSession(s, withMessages = true) {
|
|
551
|
-
const payload = this.wireRow(s);
|
|
794
|
+
async publicSession(s, withMessages = true, messages = s.messages) {
|
|
795
|
+
const payload = (await this.wireRow(s));
|
|
552
796
|
// Mutation replies replace a client's transcript, so they carry the same server-built scenes as the detail.
|
|
553
797
|
if (withMessages) {
|
|
554
|
-
payload.messages = this.backgroundLinked(s, hydrateAnchorActivityScenes(withToolCallOutcomes(
|
|
798
|
+
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
799
|
payload.compression_reference = compressionReference(s, payload.messages);
|
|
556
800
|
}
|
|
557
801
|
return redactSessionData(payload, this.deps.redactEnabled());
|
|
558
802
|
}
|
|
803
|
+
/** Opaque read identity, including context changes that do not alter display message counts. */
|
|
804
|
+
contextDetailKey(s) {
|
|
805
|
+
return createHash('sha256').update(JSON.stringify([s.session_id, s.profile, s.model, s.model_provider, s.workspace, s.updated_at, s.messages.length, s.context_messages, s.post_compression_context_tokens_estimate, s.active_stream_id])).digest('hex');
|
|
806
|
+
}
|
|
559
807
|
/** `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 }),
|
|
808
|
+
async wireRow(s) {
|
|
809
|
+
return withSessionWireFlags((await this.ownerLocked([{ ...s.compact({ contextLengthFor: this.deps.contextLengthFor, modelOptionFor: this.deps.modelOptionFor }), context_detail_key: this.contextDetailKey(s), assistant_name: this.assistantName(s), workspace_name: (await this.workspaceNames([s]))(s) }]))[0], this.deps.runtime.activeStreamIds);
|
|
562
810
|
}
|
|
563
811
|
/**
|
|
564
812
|
* TAL-303: the label for a session's workspace, from its own profile's registry (a profileless row is the root's).
|
|
565
813
|
* One resolver serves one response, so each profile's registry is read once however many rows it names.
|
|
566
814
|
*/
|
|
567
|
-
workspaceNames() {
|
|
815
|
+
async workspaceNames(rows) {
|
|
568
816
|
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);
|
|
817
|
+
for (const profile of new Set(rows.map((row) => str(row.profile) || 'default'))) {
|
|
818
|
+
let entries;
|
|
819
|
+
try {
|
|
820
|
+
entries = await this.deps.workspaces.entries(profile);
|
|
580
821
|
}
|
|
581
|
-
|
|
582
|
-
|
|
822
|
+
catch {
|
|
823
|
+
entries = [];
|
|
824
|
+
}
|
|
825
|
+
registries.set(profile, entries);
|
|
826
|
+
}
|
|
827
|
+
return (row) => workspaceDisplayName(str(row.workspace), registries.get(str(row.profile) || 'default') ?? []);
|
|
583
828
|
}
|
|
584
829
|
/** The agent's display name for a session's profile (`assistant_name`). */
|
|
585
830
|
assistantName(s) {
|
|
@@ -590,48 +835,251 @@ export class SessionService {
|
|
|
590
835
|
return redactSessionData(s.toDocument(), this.deps.redactEnabled());
|
|
591
836
|
}
|
|
592
837
|
// ── detail ──────────────────────────────────────────────────────────────
|
|
593
|
-
loadRevision(s) {
|
|
594
|
-
return
|
|
838
|
+
async loadRevision(s) {
|
|
839
|
+
return (await this.store.revision(s.session_id));
|
|
840
|
+
}
|
|
841
|
+
pageTails = new WeakMap();
|
|
842
|
+
pageContexts = new WeakMap();
|
|
843
|
+
prefixIndex(s, revision) {
|
|
844
|
+
const previous = s.prefixIndex;
|
|
845
|
+
if (previous?.rows === s.messages && previous.revision === revision && previous.length <= s.messages.length) {
|
|
846
|
+
previous.extend();
|
|
847
|
+
return previous;
|
|
848
|
+
}
|
|
849
|
+
return s.prefixIndex = new SessionPrefixIndex(s.messages, revision, markedStateDbGeneration(s), { toolCalls: s.tool_calls });
|
|
850
|
+
}
|
|
851
|
+
/** Existing projections, once per complete turn range, in absolute transcript coordinates. */
|
|
852
|
+
async deriveRange(s, rows, baseIndex, initialTurn, journaled, refs, calls, reviewRows) {
|
|
853
|
+
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 })));
|
|
854
|
+
}
|
|
855
|
+
async limitedDetail(s, index, limit, before, pending, journaled, retried = false) {
|
|
856
|
+
let prefixEnd = s.messages.length;
|
|
857
|
+
const turnId = pending?.turnId ?? journaled?.turnId;
|
|
858
|
+
if (turnId)
|
|
859
|
+
prefixEnd = Math.min(prefixEnd, index.turns.get(turnId)?.first ?? prefixEnd);
|
|
860
|
+
const prompt = pending ? index.prompts.get(pending.activeTurnToken) : undefined;
|
|
861
|
+
if (prompt !== undefined)
|
|
862
|
+
prefixEnd = Math.min(prefixEnd, prompt);
|
|
863
|
+
const dbPath = this.stateDbPath(s.profile);
|
|
864
|
+
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 ?? '')]);
|
|
865
|
+
let cached = this.pageTails.get(index);
|
|
866
|
+
if (cached?.key !== key || cached.contextRows !== s.context_messages || cached.contextLength !== s.context_messages.length) {
|
|
867
|
+
const streaming = Boolean(s.active_stream_id && !this.compactionWaiters.has(s.active_stream_id));
|
|
868
|
+
const contextKey = JSON.stringify([index.revision, s.active_stream_id, index.length, streaming]);
|
|
869
|
+
let context = this.pageContexts.get(index);
|
|
870
|
+
if (context?.key !== contextKey || context.contextRows !== s.context_messages || context.contextLength !== s.context_messages.length) {
|
|
871
|
+
const summary = !s.context_messages.length && !s.active_stream_id ? index.model : new ApiMessageSummary(s.active_stream_id, !s.context_messages.length);
|
|
872
|
+
if (summary !== index.model && !streaming) {
|
|
873
|
+
for (const row of s.context_messages.length ? s.context_messages : s.messages) {
|
|
874
|
+
summary.add(row);
|
|
875
|
+
if (summary.enough)
|
|
876
|
+
break;
|
|
877
|
+
}
|
|
878
|
+
summary.finish();
|
|
879
|
+
}
|
|
880
|
+
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 };
|
|
881
|
+
this.pageContexts.set(index, context);
|
|
882
|
+
}
|
|
883
|
+
let stateRows;
|
|
884
|
+
let effectiveSeenId = null;
|
|
885
|
+
let cacheable = true;
|
|
886
|
+
let appended = null;
|
|
887
|
+
// TAL-1162: the transcript reads run on the state.db worker. A session that changed meanwhile no longer matches this
|
|
888
|
+
// index or the lifecycle snapshots: null asks the caller to take them again, and a second change is answered as
|
|
889
|
+
// read, uncached.
|
|
890
|
+
const [localRows, localCount, updatedAt, seenId] = [s.messages, s.messages.length, s.updated_at, s.state_db_seen_id];
|
|
891
|
+
const changed = () => s.messages !== localRows || s.messages.length !== localCount || s.updated_at !== updatedAt || s.state_db_seen_id !== seenId;
|
|
892
|
+
if (typeof seenId === 'number' && !isSubagentView(s)) {
|
|
893
|
+
const read = await this.stateDbTailRead(s, seenId, [...index.unidentified, ...context.unidentified]);
|
|
894
|
+
if (changed()) {
|
|
895
|
+
if (!retried)
|
|
896
|
+
return null;
|
|
897
|
+
cacheable = false;
|
|
898
|
+
}
|
|
899
|
+
if (!read.ok)
|
|
900
|
+
cacheable = false;
|
|
901
|
+
const markerRows = read.anchor ? [read.anchor, ...read.rows] : read.rows;
|
|
902
|
+
if (read.ok && read.idCapable && currentStateDbSeenId(s, markerRows) !== null) {
|
|
903
|
+
// Covered rows consume unidentified local copies before a new duplicate. Any such match needs the full snapshot.
|
|
904
|
+
const ambiguous = read.requiresFullRead === true;
|
|
905
|
+
if (!ambiguous) {
|
|
906
|
+
const includeUids = read.rows.some((m) => stateDbIdentityKeys(m).some((id) => id.startsWith('uid:')));
|
|
907
|
+
const mode = includeUids ? 1 : 0;
|
|
908
|
+
appended = appendStateDbIdentityRows(read.rows, seenId, index.identities[mode], index.unidentified[mode], index.generation, includeUids);
|
|
909
|
+
stateRows = read.rows;
|
|
910
|
+
effectiveSeenId = seenId;
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
if (appended === null) {
|
|
915
|
+
stateRows = await this.stateDbRows(s);
|
|
916
|
+
if (changed()) {
|
|
917
|
+
if (!retried)
|
|
918
|
+
return null;
|
|
919
|
+
cacheable = false;
|
|
920
|
+
}
|
|
921
|
+
effectiveSeenId = currentStateDbSeenId(s, stateRows);
|
|
922
|
+
appended = this.mergedTranscript(s, s.messages, stateRows).slice(s.messages.length);
|
|
923
|
+
}
|
|
924
|
+
let tail = [...s.messages.slice(prefixEnd), ...appended];
|
|
925
|
+
if (pending)
|
|
926
|
+
tail = withPendingUserTurn(tail, { ...pending, localCount: s.messages.length - prefixEnd });
|
|
927
|
+
if (journaled)
|
|
928
|
+
tail = withoutRunningTurnOutput(tail, { ...journaled, localCount: s.messages.length - prefixEnd });
|
|
929
|
+
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 } });
|
|
930
|
+
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);
|
|
931
|
+
const owners = new Map();
|
|
932
|
+
const sources = async (ids, profile) => {
|
|
933
|
+
const ownerKey = JSON.stringify([ids, profile]);
|
|
934
|
+
const existing = owners.get(ownerKey);
|
|
935
|
+
if (existing)
|
|
936
|
+
return existing;
|
|
937
|
+
const result = await this.stateDbSources(ids, profile);
|
|
938
|
+
if (result)
|
|
939
|
+
owners.set(ownerKey, result);
|
|
940
|
+
return result;
|
|
941
|
+
};
|
|
942
|
+
cached = { key, tail, index: tailIndex, enoughContext, sources, contextRows: s.context_messages, contextLength: s.context_messages.length,
|
|
943
|
+
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) } };
|
|
944
|
+
if (cacheable)
|
|
945
|
+
this.pageTails.set(index, cached);
|
|
946
|
+
}
|
|
947
|
+
const { tail, index: tailIndex } = cached;
|
|
948
|
+
const total = prefixEnd + tail.length;
|
|
949
|
+
const end = before === null ? total : Math.max(0, Math.min(before, total));
|
|
950
|
+
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) : [])];
|
|
951
|
+
const idAt = (at) => at < prefixEnd ? index.turnId(at) : tailIndex.turnId(at);
|
|
952
|
+
const turnSpan = (id) => {
|
|
953
|
+
const prefix = index.turns.get(id), suffix = tailIndex.turns.get(id);
|
|
954
|
+
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 };
|
|
955
|
+
};
|
|
956
|
+
const spanAt = (at) => {
|
|
957
|
+
const id = idAt(at), own = turnSpan(id);
|
|
958
|
+
const dependencies = [index.dependencies.get(id), tailIndex.dependencies.get(id)];
|
|
959
|
+
const first = Math.min(...dependencies.map((span) => span?.first ?? total));
|
|
960
|
+
const last = Math.max(...dependencies.map((span) => span ? Math.min(span.last, total - 1) : -1));
|
|
961
|
+
return { first: Math.min(own.first, first), last: Math.max(own.last, last + 1) };
|
|
962
|
+
};
|
|
963
|
+
// A notification can refer to a parent turn outside this page. Read that indexed turn, never the whole prefix.
|
|
964
|
+
const reviewTurns = new Map();
|
|
965
|
+
const reviewRows = (id) => {
|
|
966
|
+
let rows = reviewTurns.get(id);
|
|
967
|
+
if (!rows) {
|
|
968
|
+
const span = turnSpan(id);
|
|
969
|
+
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) : [];
|
|
970
|
+
reviewTurns.set(id, rows);
|
|
971
|
+
}
|
|
972
|
+
return rows;
|
|
973
|
+
};
|
|
974
|
+
const refs = { get: (ref) => index.refCountBefore(ref, prefixEnd) + (tailIndex.refCounts.get(ref) ?? 0) };
|
|
975
|
+
if (cached.newestReply === undefined) {
|
|
976
|
+
let newestReply = null;
|
|
977
|
+
const excluded = new Set(s.active_stream_id ? [s.active_stream_id] : []);
|
|
978
|
+
selection: for (const owner of [tailIndex, index])
|
|
979
|
+
for (const candidate of owner.replyTurns(owner === index ? prefixEnd : total, excluded)) {
|
|
980
|
+
const span = turnSpan(idAt(candidate)), rows = readRange(span.first, span.last);
|
|
981
|
+
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);
|
|
982
|
+
const newest = newestSettledReply(derived, s.active_stream_id, span.first);
|
|
983
|
+
if (newest !== null) {
|
|
984
|
+
newestReply = newest;
|
|
985
|
+
break selection;
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
cached.newestReply = newestReply;
|
|
989
|
+
}
|
|
990
|
+
let start = end ? spanAt(Math.max(0, end - limit)).first : 0;
|
|
991
|
+
if (pending && before === null)
|
|
992
|
+
start = Math.min(start, prefixEnd);
|
|
993
|
+
let stop = end ? spanAt(end - 1).last : 0;
|
|
994
|
+
// Stamped ids can recur non-contiguously. Include every row of a selected turn, as the full derivation does.
|
|
995
|
+
let derived = [];
|
|
996
|
+
for (;;) {
|
|
997
|
+
for (let at = start; at < stop; at += 1) {
|
|
998
|
+
const span = spanAt(at);
|
|
999
|
+
if (span.first < start) {
|
|
1000
|
+
start = span.first;
|
|
1001
|
+
at = start - 1;
|
|
1002
|
+
}
|
|
1003
|
+
stop = Math.max(stop, span.last);
|
|
1004
|
+
}
|
|
1005
|
+
const rows = readRange(start, stop);
|
|
1006
|
+
const calls = index.toolCalls(start, rows.length);
|
|
1007
|
+
derived = (await this.deriveRange(s, rows, start, start ? idAt(start - 1) : 'legacy:start', Boolean(journaled), refs, calls, reviewRows));
|
|
1008
|
+
const [page, relative] = messageWindowForDisplay(derived, limit, end - start);
|
|
1009
|
+
if (relative > 0 || start === 0 || page.filter((m) => messageCountsAsRenderable(m)).length >= limit) {
|
|
1010
|
+
let offset = start + relative;
|
|
1011
|
+
let messages = page;
|
|
1012
|
+
if (pending && before === null) {
|
|
1013
|
+
const at = derived.findIndex((m) => m.role === 'user' && m._active_turn_token === pending.activeTurnToken);
|
|
1014
|
+
if (at >= 0 && start + at < offset) {
|
|
1015
|
+
messages = derived.slice(at, relative + messages.length);
|
|
1016
|
+
offset = start + at;
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
return { messages, offset, newestReply: cached.newestReply, ...cached.summary, enoughContext: cached.enoughContext, sources: cached.sources };
|
|
1020
|
+
}
|
|
1021
|
+
start = spanAt(Math.max(0, start - Math.max(limit, stop - start))).first;
|
|
1022
|
+
}
|
|
595
1023
|
}
|
|
596
1024
|
/** Python `_handle_session_get` on the WebUI sidecar (state.db merge pending). */
|
|
597
|
-
detail(sid, query) {
|
|
1025
|
+
async detail(sid, query) {
|
|
598
1026
|
if (!sid)
|
|
599
1027
|
throw new HttpFailure(400, 'session_id is required');
|
|
1028
|
+
sid = await this.resolveSessionId(sid);
|
|
600
1029
|
const loadMessages = (query.messages ?? '1') !== '0';
|
|
601
1030
|
const msgLimit = parseMsgLimit(query.msg_limit ?? null);
|
|
602
1031
|
const msgBeforeRaw = query.msg_before;
|
|
603
1032
|
const msgBefore = msgBeforeRaw ? (Number.isInteger(Number(msgBeforeRaw)) ? Number(msgBeforeRaw) : null) : null;
|
|
604
1033
|
let s;
|
|
605
1034
|
try {
|
|
606
|
-
s = this.store.get(sid, { metadataOnly: !loadMessages });
|
|
1035
|
+
s = (await this.store.get(sid, { metadataOnly: !loadMessages }));
|
|
607
1036
|
}
|
|
608
1037
|
catch {
|
|
609
|
-
return this.foreignSessionDetail(sid);
|
|
1038
|
+
return (await this.foreignSessionDetail(sid, loadMessages, msgLimit, msgBefore));
|
|
610
1039
|
}
|
|
611
|
-
const revisionBefore = this.loadRevision(s);
|
|
1040
|
+
const revisionBefore = (await this.loadRevision(s));
|
|
612
1041
|
if (!this.visibleToActiveProfile(s.profile)) {
|
|
613
1042
|
if (s.profile)
|
|
614
1043
|
throw new HttpFailure(409, 'Session belongs to a different profile', { code: 'session_profile_mismatch', session_id: sid, profile: s.profile });
|
|
615
1044
|
throw new HttpFailure(404, 'Session not found');
|
|
616
1045
|
}
|
|
617
|
-
this.clearStaleStreamState(s);
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
1046
|
+
await this.clearStaleStreamState(s);
|
|
1047
|
+
return this.detailPayload(s, loadMessages, msgLimit, msgBefore, revisionBefore);
|
|
1048
|
+
}
|
|
1049
|
+
/** Project an already settled session without reloading it or running endpoint recovery/profile gates. */
|
|
1050
|
+
async windowedDetail(s, limit) {
|
|
1051
|
+
return this.detailPayload(s, true, limit, null, await this.loadRevision(s));
|
|
1052
|
+
}
|
|
1053
|
+
async detailPayload(s, loadMessages, msgLimit, msgBefore, revisionBefore) {
|
|
1054
|
+
// TAL-1162: the full transcript read runs on the state.db worker before the lifecycle snapshots are taken, so they
|
|
1055
|
+
// describe the session the payload is built from.
|
|
1056
|
+
const stateRows = loadMessages && msgLimit === null ? await this.stateDbRows(s) : [];
|
|
1057
|
+
let journaled = loadMessages ? (await this.journaledActiveTurn(s)) : null;
|
|
1058
|
+
let pending = loadMessages ? this.pendingTurn(s) : null;
|
|
1059
|
+
let prefix = loadMessages && msgLimit !== null ? this.prefixIndex(s, revisionBefore) : null;
|
|
1060
|
+
let limited = prefix && msgLimit !== null ? (await this.limitedDetail(s, prefix, msgLimit, msgBefore, pending, journaled)) : null;
|
|
1061
|
+
if (prefix && msgLimit !== null && !limited) {
|
|
1062
|
+
// The session changed during the page's transcript read: every snapshot is taken again, once.
|
|
1063
|
+
revisionBefore = await this.loadRevision(s);
|
|
1064
|
+
journaled = await this.journaledActiveTurn(s);
|
|
1065
|
+
pending = this.pendingTurn(s);
|
|
1066
|
+
prefix = this.prefixIndex(s, revisionBefore);
|
|
1067
|
+
limited = await this.limitedDetail(s, prefix, msgLimit, msgBefore, pending, journaled, true);
|
|
1068
|
+
}
|
|
1069
|
+
let transcript = loadMessages && !limited ? this.mergedTranscript(s, s.messages, stateRows) : [];
|
|
1070
|
+
if (pending && !limited)
|
|
623
1071
|
transcript = withPendingUserTurn(transcript, pending);
|
|
624
|
-
if (journaled)
|
|
1072
|
+
if (journaled && !limited)
|
|
625
1073
|
transcript = withoutRunningTurnOutput(transcript, { ...journaled, localCount: s.messages.length });
|
|
626
1074
|
// 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;
|
|
1075
|
+
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 }))) : [];
|
|
1076
|
+
let truncated = limited?.messages ?? [];
|
|
1077
|
+
let offset = limited?.offset ?? 0;
|
|
1078
|
+
let summaryCount = limited?.total ?? null;
|
|
1079
|
+
let summaryLast = limited?.last ?? null;
|
|
632
1080
|
if (loadMessages) {
|
|
633
|
-
|
|
634
|
-
|
|
1081
|
+
if (!limited)
|
|
1082
|
+
[truncated, offset] = messageWindowForDisplay(all, msgLimit, msgBefore);
|
|
635
1083
|
// TAL-368: a tail window starts no later than the running turn's prompt, so a turn whose output stays in the
|
|
636
1084
|
// transcript (no journal to replay it) keeps its prompt; older pages end before it, so it is never sent twice.
|
|
637
1085
|
// ponytail: the window grows with that turn's persisted rows; clip the turn instead if degraded runs get long.
|
|
@@ -641,7 +1089,7 @@ export class SessionService {
|
|
|
641
1089
|
offset = prompt;
|
|
642
1090
|
}
|
|
643
1091
|
// 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);
|
|
1092
|
+
truncated = withBodyExcerpts(withDisplayMedia(truncated, (await this.mediaProjector(s))), s.active_stream_id, limited ? limited.newestReply : newestSettledReply(all, s.active_stream_id), offset);
|
|
645
1093
|
if (msgLimit !== null)
|
|
646
1094
|
truncated = messagesForLimitedPayload(truncated);
|
|
647
1095
|
}
|
|
@@ -649,10 +1097,12 @@ export class SessionService {
|
|
|
649
1097
|
summaryCount = s.metadataMessageCount ?? s.messages.length;
|
|
650
1098
|
summaryLast = lastMessageTimestamp(s.messages) ?? 0;
|
|
651
1099
|
}
|
|
652
|
-
const windowed = loadMessages && msgLimit !== null && (msgBefore !== null || truncated.length < all.length);
|
|
1100
|
+
const windowed = loadMessages && msgLimit !== null && (msgBefore !== null || truncated.length < (limited?.total ?? all.length));
|
|
653
1101
|
let toolCalls = loadMessages ? s.tool_calls : [];
|
|
654
1102
|
if (windowed)
|
|
655
|
-
toolCalls = toolCallsForMessageWindow(toolCalls, offset, truncated.length);
|
|
1103
|
+
toolCalls = prefix ? prefix.toolCalls(offset, truncated.length, true) : toolCallsForMessageWindow(toolCalls, offset, truncated.length);
|
|
1104
|
+
if (loadMessages && s.active_stream_id && msgBefore === null)
|
|
1105
|
+
toolCalls = [...toolCalls, ...(this.deps.runtime.liveToolProgress?.(s.active_stream_id) ?? [])];
|
|
656
1106
|
const mergedCount = summaryCount ?? all.length;
|
|
657
1107
|
let mergedLast = summaryLast ?? 0;
|
|
658
1108
|
if (summaryLast === null && all.length) {
|
|
@@ -660,7 +1110,7 @@ export class SessionService {
|
|
|
660
1110
|
}
|
|
661
1111
|
const activeStreamIds = this.deps.runtime.activeStreamIds;
|
|
662
1112
|
const raw = {
|
|
663
|
-
...s.compact({ includeRuntime: true, activeStreamIds, contextLengthFor: this.deps.contextLengthFor, modelOptionFor: this.deps.modelOptionFor }),
|
|
1113
|
+
...s.compact({ ...(prefix ? { transcriptSummary: prefix.summary() } : {}), includeRuntime: true, activeStreamIds, contextLengthFor: this.deps.contextLengthFor, modelOptionFor: this.deps.modelOptionFor }),
|
|
664
1114
|
messages: truncated,
|
|
665
1115
|
message_count: mergedCount,
|
|
666
1116
|
tool_calls: toolCalls,
|
|
@@ -674,10 +1124,12 @@ export class SessionService {
|
|
|
674
1124
|
};
|
|
675
1125
|
if (loadMessages)
|
|
676
1126
|
raw.transcript_seq = journaled ? { stream_id: journaled.turnId, seq: 0 } : null;
|
|
677
|
-
if (
|
|
1127
|
+
if (limited)
|
|
1128
|
+
attachTodoSnapshot(raw, limited.todo, s.extra[UNSETTLED_TODO_KEY]);
|
|
1129
|
+
else if (loadMessages)
|
|
678
1130
|
attachTodoState(raw, all, s.extra[UNSETTLED_TODO_KEY]);
|
|
679
1131
|
if (loadMessages)
|
|
680
|
-
raw.compression_reference = compressionReference(s, all);
|
|
1132
|
+
raw.compression_reference = limited ? limited.compression : compressionReference(s, all);
|
|
681
1133
|
if (mergedLast) {
|
|
682
1134
|
raw.last_message_at = Math.max(Number(raw.last_message_at ?? 0) || 0, mergedLast);
|
|
683
1135
|
raw.updated_at = Math.max(Number(raw.updated_at ?? 0) || 0, mergedLast);
|
|
@@ -686,30 +1138,39 @@ export class SessionService {
|
|
|
686
1138
|
raw._messages_truncated = isTruncated;
|
|
687
1139
|
raw._messages_offset = offset;
|
|
688
1140
|
raw._msg_limit_max = MAX_MSG_LIMIT;
|
|
689
|
-
const revisionAfter = this.loadRevision(s);
|
|
1141
|
+
const revisionAfter = (await this.loadRevision(s));
|
|
690
1142
|
raw._load_revision = revisionBefore !== null && revisionBefore === revisionAfter ? hashRevision(revisionBefore) : `unstable-${randomUUID().replace(/-/g, '')}`;
|
|
691
|
-
raw
|
|
1143
|
+
Object.assign(raw, (await this.ownerLocked([raw], limited?.sources))[0]);
|
|
692
1144
|
raw.assistant_name = this.assistantName(s);
|
|
693
|
-
raw.workspace_name = this.workspaceNames()(s);
|
|
694
|
-
raw.branched_from = this.branchedFrom(s);
|
|
1145
|
+
raw.workspace_name = (await this.workspaceNames([s]))(s);
|
|
1146
|
+
raw.branched_from = (await this.branchedFrom(s));
|
|
695
1147
|
withSessionWireFlags(raw, activeStreamIds);
|
|
696
1148
|
// A metadata-only read lacks the history the gate counts, so it carries no flag.
|
|
697
1149
|
if (loadMessages)
|
|
698
|
-
raw.can_compress = this.canCompress(s, raw.read_only === true, stateRows);
|
|
1150
|
+
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);
|
|
1151
|
+
raw.confirms_deep_truncate = true;
|
|
699
1152
|
raw.pending_steers = raw.active_stream_id ? (this.deps.runtime.pendingSteers?.(str(raw.active_stream_id)) ?? []) : [];
|
|
700
|
-
|
|
1153
|
+
// TAL-1006: the compression a client shows on load: the running manual job's, else the live run's open span.
|
|
1154
|
+
const job = this.compressionJobs.get(s.session_id);
|
|
1155
|
+
raw.run_status = this.currentRunStatus(s.session_id, str(raw.active_stream_id) || null);
|
|
1156
|
+
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;
|
|
1157
|
+
raw.automatic_turns_held = (await this.deps.runtime.automaticTurnsHeld?.(s.session_id)) ?? false;
|
|
1158
|
+
raw.notices = this.deps.runtime.sessionNotices?.(s.session_id) ?? [];
|
|
1159
|
+
raw.goal = this.deps.runtime.sessionGoal?.(s.session_id) ?? null;
|
|
1160
|
+
const publicRow = redactSessionData(raw, this.deps.redactEnabled());
|
|
1161
|
+
return this.owner ? await this.owner.project(publicRow, msgLimit, msgBefore, loadMessages) : publicRow;
|
|
701
1162
|
}
|
|
702
1163
|
/**
|
|
703
1164
|
* TAL-454: the chat `/branch` copied, while it still loads (archived included). A compression continuation is also a
|
|
704
1165
|
* fork of its parent but not a branch, so it links nowhere.
|
|
705
1166
|
*/
|
|
706
|
-
branchedFrom(s) {
|
|
1167
|
+
async branchedFrom(s) {
|
|
707
1168
|
const parentId = s.parent_session_id;
|
|
708
1169
|
if (!parentId || str(s.session_source).trim().toLowerCase() !== 'fork' || s.compression_recovery_source_session_id)
|
|
709
1170
|
return null;
|
|
710
1171
|
let title;
|
|
711
1172
|
try {
|
|
712
|
-
const parent = this.store.get(parentId, { metadataOnly: true, promote: false, cacheOnMiss: false });
|
|
1173
|
+
const parent = (await this.store.get(parentId, { metadataOnly: true, promote: false, cacheOnMiss: false }));
|
|
713
1174
|
if (!this.visibleToActiveProfile(parent.profile))
|
|
714
1175
|
return null;
|
|
715
1176
|
title = parent.title;
|
|
@@ -718,10 +1179,10 @@ export class SessionService {
|
|
|
718
1179
|
// A read-only foreign parent (a cron run) has no sidecar and opens from the active profile's state.db while it
|
|
719
1180
|
// has transcript rows and is not a deleted chat (`claimOrSynthesizeCliSession`'s `was_webui` and `no_foreign_state`).
|
|
720
1181
|
const dbPath = this.stateDbPath(null);
|
|
721
|
-
const row = stateDbSessionRow
|
|
722
|
-
if (!row || this.store.wasDeleted(parentId) || this.indexMarksWasWebui(parentId))
|
|
1182
|
+
const row = await stateDbSessionReadAsync(null, 'stateDbSessionRow', dbPath, parentId);
|
|
1183
|
+
if (!row || (await this.store.wasDeleted(parentId)) || (await this.indexMarksWasWebui(parentId)))
|
|
723
1184
|
return null;
|
|
724
|
-
if (!
|
|
1185
|
+
if (!(await stateDbSessionReadAsync(false, 'stateDbSessionHasMessages', dbPath, parentId)))
|
|
725
1186
|
return null;
|
|
726
1187
|
title = row.title;
|
|
727
1188
|
}
|
|
@@ -731,12 +1192,12 @@ export class SessionService {
|
|
|
731
1192
|
* TAL-316: the active run whose journal can replay its output from the start. The detail then leaves that output to the
|
|
732
1193
|
* replay; with no active run, no identifiable prompt, or no complete journal, it returns the persisted transcript unchanged.
|
|
733
1194
|
*/
|
|
734
|
-
journaledActiveTurn(s) {
|
|
1195
|
+
async journaledActiveTurn(s) {
|
|
735
1196
|
const turnId = str(s.active_stream_id).trim();
|
|
736
1197
|
const activeTurnToken = buildActiveTurnToken(turnId, s.pending_started_at);
|
|
737
1198
|
if (!turnId || !activeTurnToken || this.deps.runtime.journalDegraded(turnId))
|
|
738
1199
|
return null;
|
|
739
|
-
const summary = this.deps.journal?.findRunSummary(turnId);
|
|
1200
|
+
const summary = (await this.deps.journal?.findRunSummary(turnId));
|
|
740
1201
|
if (summary?.session_id !== s.session_id || summary.journal_pruned)
|
|
741
1202
|
return null;
|
|
742
1203
|
return { turnId, startedAt: Number(s.pending_started_at), activeTurnToken };
|
|
@@ -752,8 +1213,9 @@ export class SessionService {
|
|
|
752
1213
|
return { turnId, startedAt, activeTurnToken, localCount: s.messages.length, prompt: pendingUserRow(text, s.pending_attachments, startedAt, s.pending_user_source || 'webui', turnId) };
|
|
753
1214
|
}
|
|
754
1215
|
/** A session without a sidecar, synthesized from state.db for this profile; 404/409 like the detail. */
|
|
755
|
-
foreignSession(sid) {
|
|
756
|
-
|
|
1216
|
+
async foreignSession(sid) {
|
|
1217
|
+
sid = await this.resolveSessionId(sid);
|
|
1218
|
+
const meta = (await this.lookupCliMeta(sid));
|
|
757
1219
|
const profile = str(meta?.profile) || null;
|
|
758
1220
|
const profileAgnostic = str(meta?.source_tag || meta?.raw_source).trim().toLowerCase() === 'claude_code';
|
|
759
1221
|
if (!profileAgnostic && !this.visibleToActiveProfile(profile)) {
|
|
@@ -761,16 +1223,19 @@ export class SessionService {
|
|
|
761
1223
|
throw new HttpFailure(409, 'Session belongs to a different profile', { code: 'session_profile_mismatch', session_id: sid, profile });
|
|
762
1224
|
throw new HttpFailure(404, 'Session not found');
|
|
763
1225
|
}
|
|
764
|
-
const { session: synth, reason } = this.claimOrSynthesizeCliSession(sid, meta);
|
|
1226
|
+
const { session: synth, reason } = (await this.claimOrSynthesizeCliSession(sid, meta));
|
|
765
1227
|
if (!synth || reason === 'was_webui')
|
|
766
1228
|
throw new HttpFailure(404, 'Session not found');
|
|
767
1229
|
return { synth, meta };
|
|
768
1230
|
}
|
|
769
1231
|
/** Python `_handle_session_get` without a sidecar: the state.db transcript as a (read-only or claimable) foreign stub. */
|
|
770
|
-
|
|
771
|
-
|
|
1232
|
+
foreignMessages(messages, session) {
|
|
1233
|
+
return hydrateAnchorActivityScenes(withToolCallOutcomes(withMarkerKinds(withTurnIds(messages), session), [], null), {});
|
|
1234
|
+
}
|
|
1235
|
+
async foreignSessionDetail(sid, loadMessages, msgLimit, msgBefore) {
|
|
1236
|
+
const { synth, meta } = (await this.foreignSession(sid));
|
|
772
1237
|
// The same turn projection as a WebUI session: turn ids, tool outcomes, then each completed turn's scene.
|
|
773
|
-
const msgs =
|
|
1238
|
+
const msgs = this.foreignMessages(synth.messages, synth);
|
|
774
1239
|
const lastTs = Number(msgs[msgs.length - 1]?.timestamp ?? 0) || 0;
|
|
775
1240
|
const sess = {
|
|
776
1241
|
session_id: synth.session_id, title: synth.title, workspace: synth.workspace, model: synth.model, message_count: msgs.length,
|
|
@@ -778,14 +1243,15 @@ export class SessionService {
|
|
|
778
1243
|
pinned: synth.pinned, archived: synth.archived, project_id: synth.project_id ?? null, profile: synth.profile,
|
|
779
1244
|
is_cli_session: synth.is_cli_session, source_tag: synth.source_tag, raw_source: synth.raw_source, session_source: synth.session_source,
|
|
780
1245
|
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),
|
|
1246
|
+
assistant_name: this.assistantName(synth), workspace_name: (await this.workspaceNames([synth]))(synth), confirms_deep_truncate: true,
|
|
782
1247
|
};
|
|
783
1248
|
attachTodoState(sess, msgs);
|
|
784
|
-
const merged = withSessionWireFlags(meta ? mergeCliSidebarMetadata(sess, meta) : sess, this.deps.runtime.activeStreamIds);
|
|
785
|
-
|
|
1249
|
+
const merged = withSessionWireFlags((await this.ownerLocked([meta ? mergeCliSidebarMetadata(sess, meta) : sess]))[0], this.deps.runtime.activeStreamIds);
|
|
1250
|
+
const publicRow = redactSessionData(merged, this.deps.redactEnabled());
|
|
1251
|
+
return this.owner ? await this.owner.project(publicRow, msgLimit, msgBefore, loadMessages) : publicRow;
|
|
786
1252
|
}
|
|
787
1253
|
/** Clear persisted streaming flags when no live stream backs them (Python `_clear_stale_stream_state`). */
|
|
788
|
-
clearStaleStreamState(session) {
|
|
1254
|
+
async clearStaleStreamState(session) {
|
|
789
1255
|
const streamId = session.active_stream_id;
|
|
790
1256
|
if (!streamId)
|
|
791
1257
|
return false;
|
|
@@ -796,9 +1262,12 @@ export class SessionService {
|
|
|
796
1262
|
const pendingAge = session.pending_started_at ? this.deps.now() - session.pending_started_at : null;
|
|
797
1263
|
if (session.hasPendingPrompt && pendingAge !== null && pendingAge < 30)
|
|
798
1264
|
return false;
|
|
1265
|
+
// Reading a display-only session must not settle the previous Talaria worker's transcript.
|
|
1266
|
+
if ((await this.isReadOnly(session)))
|
|
1267
|
+
return false;
|
|
799
1268
|
let target = session;
|
|
800
1269
|
if (session.loadedMetadataOnly) {
|
|
801
|
-
const full = this.store.load(session.session_id);
|
|
1270
|
+
const full = (await this.store.load(session.session_id));
|
|
802
1271
|
if (!full)
|
|
803
1272
|
return false;
|
|
804
1273
|
target = full;
|
|
@@ -816,13 +1285,13 @@ export class SessionService {
|
|
|
816
1285
|
// run that never finished with an answer, so a dead stream never silently drops what the user sent or what streamed.
|
|
817
1286
|
const turnId = str(target.active_stream_id);
|
|
818
1287
|
// 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)
|
|
1288
|
+
const events = (await this.deps.journal?.readRunEventTail(target.session_id, turnId, RECOVERY_JOURNAL_MAX_BYTES, RECOVERY_JOURNAL_MAX_ROWS))?.events ?? [];
|
|
820
1289
|
const { rows: output, answered } = journalOutputRows(events, turnId);
|
|
821
1290
|
const pendingText = str(target.pending_user_message);
|
|
822
1291
|
const attachments = [...target.pending_attachments];
|
|
823
1292
|
// The run's starting read died with its worker: the state.db rows from before it started stand in for it. Its own
|
|
824
1293
|
// rows open with its prompt and run to the next prompt; anything else another client wrote meanwhile stays.
|
|
825
|
-
const read = this.
|
|
1294
|
+
const read = await this.settlementRead(target);
|
|
826
1295
|
const runStart = target.pending_started_at || events[0]?.created_at || this.deps.now();
|
|
827
1296
|
const before = read.rows.filter((m) => Number(m.timestamp) < runStart);
|
|
828
1297
|
const runRows = read.rows.filter((m) => Number(m.timestamp) >= runStart);
|
|
@@ -843,7 +1312,7 @@ export class SessionService {
|
|
|
843
1312
|
if (target.hasPendingPrompt || output.length) {
|
|
844
1313
|
target.messages.push(...output);
|
|
845
1314
|
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 });
|
|
1315
|
+
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
1316
|
// 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
1317
|
this.settleStateDb(target, { turnId, previousContext, agentRows: ownRows, startId: stateDbSeenId(before) ?? (read.idCapable ? 0 : null), read });
|
|
849
1318
|
}
|
|
@@ -853,7 +1322,7 @@ export class SessionService {
|
|
|
853
1322
|
target.pending_started_at = null;
|
|
854
1323
|
target.pending_user_source = null;
|
|
855
1324
|
try {
|
|
856
|
-
this.store.save(target, { touchUpdatedAt: false });
|
|
1325
|
+
(await this.store.save(target, { touchUpdatedAt: false }));
|
|
857
1326
|
}
|
|
858
1327
|
catch {
|
|
859
1328
|
return false;
|
|
@@ -864,33 +1333,60 @@ export class SessionService {
|
|
|
864
1333
|
session.pending_attachments = [];
|
|
865
1334
|
session.pending_started_at = null;
|
|
866
1335
|
session.pending_user_source = null;
|
|
867
|
-
this.store.touch(target);
|
|
1336
|
+
await this.store.touch(target);
|
|
868
1337
|
}
|
|
869
1338
|
return true;
|
|
870
1339
|
}
|
|
871
1340
|
// ── list / search ────────────────────────────────────────────────────────
|
|
1341
|
+
/** Expand saved aliases in one worker query independently of the bounded native sidebar window. */
|
|
1342
|
+
async persistedAliasMetadata(rows, existing = []) {
|
|
1343
|
+
const profile = this.deps.activeProfile();
|
|
1344
|
+
const represented = new Set(existing.flatMap((row) => [...sessionLineageIds(row)]));
|
|
1345
|
+
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));
|
|
1346
|
+
if (!ids.length)
|
|
1347
|
+
return { rows: existing };
|
|
1348
|
+
const extra = await this.deps.cliSessions(profile, { sourceFilter: null, sessionIds: ids });
|
|
1349
|
+
return { rows: [...new Map([...existing, ...extra.rows].map((row) => [str(row.session_id), row])).values()], storage: extra.storage };
|
|
1350
|
+
}
|
|
872
1351
|
async list(params) {
|
|
873
1352
|
const activeProfile = this.deps.activeProfile();
|
|
1353
|
+
const webuiRows = (await allSessions(this.store, { sidebarMetadataOnly: true }));
|
|
874
1354
|
const wantState = params.showCliSessions || params.showCronSessions || params.showWebhookSessions || params.showKanbanSessions;
|
|
875
1355
|
// Python reads every profile's state.db under all_profiles; this port projects the active profile only.
|
|
876
1356
|
const cliRead = wantState ? await this.deps.cliSessions(activeProfile, { sourceFilter: params.sourceFilter ?? null }) : undefined;
|
|
877
|
-
const claudeCodeRows = params.showCliSessions && params.showClaudeCodeSessions ? this.claudeCodeRows() : [];
|
|
878
|
-
|
|
1357
|
+
const claudeCodeRows = params.showCliSessions && params.showClaudeCodeSessions ? (await this.claudeCodeRows()) : [];
|
|
1358
|
+
let cliRows = cliRead ? [...cliRead.rows, ...claudeCodeRows] : undefined;
|
|
1359
|
+
let agentStorage = cliRead?.storage;
|
|
1360
|
+
// TAL-1040: diagnostic chats Web started are Web chats, read even when external Agent sessions are hidden.
|
|
1361
|
+
const webDiagnosticIds = new Set(params.sourceFilter ? [] : await stateDbWebDiagnosticIdsAsync(join(this.deps.profileHome(activeProfile), 'state.db')));
|
|
1362
|
+
const unread = [...webDiagnosticIds].filter((id) => !cliRows?.some((row) => sessionLineageIds(row).has(id)));
|
|
1363
|
+
if (unread.length) {
|
|
1364
|
+
const extra = await this.deps.cliSessions(activeProfile, { sourceFilter: null, sessionIds: unread, keepEmpty: true });
|
|
1365
|
+
cliRows = [...(cliRows ?? []), ...extra.rows];
|
|
1366
|
+
agentStorage ??= extra.storage;
|
|
1367
|
+
}
|
|
879
1368
|
const gatewayIdentity = loadGatewaySessionIdentityMap(join(this.deps.profileHome(activeProfile), 'sessions', 'sessions.json'));
|
|
880
1369
|
const truncatedSources = cliRead?.truncated;
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
1370
|
+
if (cliRows) {
|
|
1371
|
+
const expanded = await this.persistedAliasMetadata(webuiRows, cliRows);
|
|
1372
|
+
cliRows = expanded.rows;
|
|
1373
|
+
if (expanded.storage && (expanded.storage.state === 'corrupt' || agentStorage?.state === 'ok'))
|
|
1374
|
+
agentStorage = expanded.storage;
|
|
1375
|
+
}
|
|
1376
|
+
const lookups = await withStateDbListingReads(() => this.listStateDbLookups(webuiRows, (cliRows ?? []).map((r) => str(r.session_id))));
|
|
1377
|
+
const payload = (await buildSessionListPayload(this.store, { ...params, ...(cliRows ? { cliRows } : {}), ...(truncatedSources ? { truncatedSources } : {}), ...(agentStorage ? { agentStorage } : {}), gatewayIdentity, ...lookups, webuiRows, webDiagnosticIds, activeProfile, isolatedProfileMode: this.deps.isolatedProfileMode(), profilesMatch: this.deps.profilesMatch }));
|
|
1378
|
+
return sessionListResponse(payload, this.deps.runtime, this.deps.redactEnabled(), this.deps.now(), await this.workspaceNames([...payload.sessions, ...payload.sidebar_reference_sessions]));
|
|
885
1379
|
}
|
|
886
|
-
search(q, opts) {
|
|
1380
|
+
async search(q, opts) {
|
|
887
1381
|
const activeProfile = this.deps.activeProfile();
|
|
888
|
-
let sessions = allSessions(this.store);
|
|
1382
|
+
let sessions = (await allSessions(this.store));
|
|
889
1383
|
if (!opts.allProfiles)
|
|
890
1384
|
sessions = sessions.filter((r) => this.deps.profilesMatch(str(r.profile) || null, activeProfile));
|
|
891
|
-
|
|
1385
|
+
const metadata = await this.persistedAliasMetadata(sessions);
|
|
1386
|
+
sessions = reconcileLocalSessionRows(sessions, metadata.rows, this.deps.profilesMatch);
|
|
1387
|
+
sessions = await this.ownerLocked(sessions);
|
|
892
1388
|
const redact = this.deps.redactEnabled();
|
|
893
|
-
const workspaceName = this.workspaceNames();
|
|
1389
|
+
const workspaceName = await this.workspaceNames(sessions);
|
|
894
1390
|
const redactRow = (item) => {
|
|
895
1391
|
withSessionWireFlags(item, this.deps.runtime.activeStreamIds);
|
|
896
1392
|
item.workspace_name = workspaceName(item);
|
|
@@ -905,17 +1401,16 @@ export class SessionService {
|
|
|
905
1401
|
if (!query)
|
|
906
1402
|
return { sessions: sessions.map((s) => redactRow({ ...s })), all_profiles: opts.allProfiles, active_profile: activeProfile };
|
|
907
1403
|
const terms = sessionSearchTerms(query);
|
|
1404
|
+
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
1405
|
const results = [];
|
|
909
1406
|
for (const s of sessions) {
|
|
910
1407
|
if (sessionSearchMatches(str(s.title), terms)) {
|
|
911
1408
|
results.push(redactRow({ ...s, match_type: 'title' }));
|
|
912
1409
|
continue;
|
|
913
1410
|
}
|
|
914
|
-
|
|
915
|
-
continue;
|
|
916
|
-
const hit = this.contentMatch(str(s.session_id), terms, opts.depth);
|
|
1411
|
+
const hit = matches.get(str(s.session_id));
|
|
917
1412
|
if (hit)
|
|
918
|
-
results.push(redactRow({ ...s, ...hit }));
|
|
1413
|
+
results.push(redactRow({ ...s, ...this.contentMatch(hit.text, terms) }));
|
|
919
1414
|
}
|
|
920
1415
|
return { sessions: results, query, count: results.length, all_profiles: opts.allProfiles, active_profile: activeProfile };
|
|
921
1416
|
}
|
|
@@ -935,46 +1430,35 @@ export class SessionService {
|
|
|
935
1430
|
if (!query)
|
|
936
1431
|
return { ...base, sessions: rows };
|
|
937
1432
|
const terms = sessionSearchTerms(query);
|
|
1433
|
+
const metadataMatch = (row) => ['workspace', 'workspace_name', 'model', 'model_provider', 'profile', 'source_label'].some((k) => sessionSearchMatches(str(row[k]), terms));
|
|
1434
|
+
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
1435
|
const results = [];
|
|
939
1436
|
for (const row of rows) {
|
|
940
1437
|
if (sessionSearchMatches(str(row.title), terms))
|
|
941
1438
|
results.push({ ...row, match_type: 'title' });
|
|
942
|
-
else if (
|
|
1439
|
+
else if (metadataMatch(row))
|
|
943
1440
|
results.push({ ...row, match_type: 'metadata' });
|
|
944
|
-
else
|
|
945
|
-
const hit =
|
|
1441
|
+
else {
|
|
1442
|
+
const hit = matches.get(str(row.session_id));
|
|
946
1443
|
if (hit)
|
|
947
|
-
results.push({ ...row, ...hit });
|
|
1444
|
+
results.push({ ...row, ...this.contentMatch(hit.text, terms) });
|
|
948
1445
|
}
|
|
949
1446
|
}
|
|
950
1447
|
return { ...base, sessions: results, query, count: results.length };
|
|
951
1448
|
}
|
|
952
1449
|
/** 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;
|
|
1450
|
+
contentMatch(text, terms) {
|
|
1451
|
+
const item = { match_type: 'content' };
|
|
1452
|
+
const preview = sessionSearchPreview(text, terms);
|
|
1453
|
+
if (preview)
|
|
1454
|
+
item.match_preview = redactText(preview, this.deps.redactEnabled());
|
|
1455
|
+
return item;
|
|
972
1456
|
}
|
|
973
1457
|
// ── status / usage ───────────────────────────────────────────────────────
|
|
974
|
-
status(sid) {
|
|
975
|
-
const s = this.get404(sid, { metadataOnly: true });
|
|
976
|
-
this.clearStaleStreamState(s);
|
|
977
|
-
const full = this.get404(sid);
|
|
1458
|
+
async status(sid) {
|
|
1459
|
+
const s = (await this.get404(sid, { metadataOnly: true }));
|
|
1460
|
+
await this.clearStaleStreamState(s);
|
|
1461
|
+
const full = (await this.get404(sid));
|
|
978
1462
|
const inp = Math.trunc(full.input_tokens) || 0;
|
|
979
1463
|
const out = Math.trunc(full.output_tokens) || 0;
|
|
980
1464
|
const profile = full.profile || 'default';
|
|
@@ -985,37 +1469,60 @@ export class SessionService {
|
|
|
985
1469
|
catch {
|
|
986
1470
|
hermesHome = '';
|
|
987
1471
|
}
|
|
988
|
-
const { is_streaming: streaming, active_stream_id: live, read_only: readOnly, active_turn_origin: origin } = this.wireRow(full);
|
|
1472
|
+
const { is_streaming: streaming, active_stream_id: live, read_only: readOnly, read_only_reason: readOnlyReason, active_turn_origin: origin } = (await this.wireRow(full));
|
|
989
1473
|
return {
|
|
990
1474
|
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,
|
|
1475
|
+
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
1476
|
input_tokens: inp, output_tokens: out, total_tokens: inp + out, estimated_cost: full.estimated_cost,
|
|
993
1477
|
};
|
|
994
1478
|
}
|
|
995
|
-
|
|
996
|
-
|
|
1479
|
+
async contextDetail(sid, expectedKey) {
|
|
1480
|
+
if (await this.owner?.isOwned(sid))
|
|
1481
|
+
return this.owner.contextDetail(sid, expectedKey);
|
|
1482
|
+
const session = await this.get404(sid);
|
|
1483
|
+
const profile = this.deps.activeProfile();
|
|
1484
|
+
if (session.profile && session.profile !== profile || await this.loadedReadOnlyReason(session))
|
|
1485
|
+
return unavailableContextDetail();
|
|
1486
|
+
const sidecar = this.deps.sidecar?.();
|
|
1487
|
+
if (!sidecar)
|
|
1488
|
+
return unavailableContextDetail();
|
|
1489
|
+
// The read owns no cache. Compression and model/workspace replacement invalidate its response.
|
|
1490
|
+
const marks = (current = session) => this.contextDetailKey(current);
|
|
1491
|
+
const before = marks();
|
|
1492
|
+
if (before !== expectedKey)
|
|
1493
|
+
return unavailableContextDetail('changed');
|
|
1494
|
+
try {
|
|
1495
|
+
const detail = await sidecar.call('chat.context_detail', { session_id: sid, profile_home: this.deps.profileHome(profile), model: session.model, model_provider: session.model_provider, workspace: session.workspace });
|
|
1496
|
+
return this.deps.activeProfile() === profile && before === marks(await this.get404(sid)) ? detail : unavailableContextDetail('changed');
|
|
1497
|
+
}
|
|
1498
|
+
catch {
|
|
1499
|
+
return unavailableContextDetail();
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
async usage(sid) {
|
|
1503
|
+
const s = (await this.get404(sid));
|
|
997
1504
|
const inp = Math.trunc(s.input_tokens) || 0;
|
|
998
1505
|
const out = Math.trunc(s.output_tokens) || 0;
|
|
999
1506
|
return { input_tokens: inp, output_tokens: out, total_tokens: inp + out, estimated_cost: s.estimated_cost, model: s.model };
|
|
1000
1507
|
}
|
|
1001
1508
|
// ── creation ─────────────────────────────────────────────────────────────
|
|
1002
|
-
resolveNewSessionWorkspace(body, visiblePrevSessionId, profile) {
|
|
1509
|
+
async resolveNewSessionWorkspace(body, visiblePrevSessionId, profile) {
|
|
1003
1510
|
const candidate = body.workspace;
|
|
1004
1511
|
if (!candidate)
|
|
1005
1512
|
return null;
|
|
1006
1513
|
const value = str(candidate);
|
|
1007
1514
|
if (body.workspace_inherited_from_prev_session !== true || !visiblePrevSessionId)
|
|
1008
|
-
return this.deps.workspaces.resolveTrusted(value, profile);
|
|
1515
|
+
return (await this.deps.workspaces.resolveTrusted(value, profile));
|
|
1009
1516
|
let previous;
|
|
1010
1517
|
try {
|
|
1011
|
-
previous = this.store.get(visiblePrevSessionId, { metadataOnly: true });
|
|
1518
|
+
previous = (await this.store.get(visiblePrevSessionId, { metadataOnly: true }));
|
|
1012
1519
|
}
|
|
1013
1520
|
catch {
|
|
1014
|
-
return this.deps.workspaces.resolveTrusted(value, profile);
|
|
1521
|
+
return (await this.deps.workspaces.resolveTrusted(value, profile));
|
|
1015
1522
|
}
|
|
1016
1523
|
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);
|
|
1524
|
+
return (await this.deps.workspaces.resolveTrusted(value, profile));
|
|
1525
|
+
const [workspace] = (await this.deps.workspaces.resolveImplicitWithRecovery(value, async (p) => (await this.deps.workspaces.profileDefaultWorkspaceForBoot(p)), profile));
|
|
1019
1526
|
return workspace;
|
|
1020
1527
|
}
|
|
1021
1528
|
/** The server owns the names' normalization (TAL-631): trimmed, blanks dropped, and nothing left is the profile's defaults. */
|
|
@@ -1029,46 +1536,76 @@ export class SessionService {
|
|
|
1029
1536
|
const names = toolsets.map((t) => t.trim()).filter(Boolean);
|
|
1030
1537
|
return names.length ? names : null;
|
|
1031
1538
|
}
|
|
1032
|
-
create(body, opts = {}) {
|
|
1539
|
+
async create(body, opts = {}) {
|
|
1033
1540
|
// Python `new_session`: a body without a profile takes the request's active profile (cookie first).
|
|
1034
1541
|
const profile = (typeof body.profile === 'string' && body.profile) || this.deps.activeProfile();
|
|
1035
1542
|
let prevSessionId = typeof body.prev_session_id === 'string' && body.prev_session_id ? body.prev_session_id : null;
|
|
1036
|
-
if (prevSessionId && !this.sessionIdVisible(prevSessionId))
|
|
1543
|
+
if (prevSessionId && !(await this.sessionIdVisible(prevSessionId)))
|
|
1037
1544
|
prevSessionId = null;
|
|
1038
1545
|
// Python: leaving a session for a new one flushes the previous session's memory in the background (W6).
|
|
1039
1546
|
if (prevSessionId)
|
|
1040
|
-
this.deps.commitSessionMemory?.(prevSessionId);
|
|
1547
|
+
await this.deps.commitSessionMemory?.(prevSessionId);
|
|
1041
1548
|
let workspace;
|
|
1042
1549
|
try {
|
|
1043
|
-
workspace = this.resolveNewSessionWorkspace(body, prevSessionId, profile);
|
|
1550
|
+
workspace = (await this.resolveNewSessionWorkspace(body, prevSessionId, profile));
|
|
1044
1551
|
}
|
|
1045
1552
|
catch (error) {
|
|
1046
1553
|
throw new HttpFailure(400, error.message);
|
|
1047
1554
|
}
|
|
1048
1555
|
const [model, provider] = this.deps.modelStateFromRequest(body.model, body.model_provider, null);
|
|
1049
1556
|
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 });
|
|
1557
|
+
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
1558
|
if (opts.worktree)
|
|
1052
1559
|
this.publish('session_new', s.profile, s.session_id);
|
|
1053
1560
|
return s;
|
|
1054
1561
|
}
|
|
1562
|
+
/**
|
|
1563
|
+
* TAL-1040: an `ignore_config` / `safe` chat starts through the active profile's Agent owner, which freezes the mode before
|
|
1564
|
+
* any Agent import. That owner reads no profile default, so the launch route is explicit: the requested model, else this
|
|
1565
|
+
* profile's default as a normal chat starts, with that provider's configured endpoint.
|
|
1566
|
+
*/
|
|
1567
|
+
async createDiagnostic(body, mode) {
|
|
1568
|
+
const profile = (typeof body.profile === 'string' && body.profile) || this.deps.activeProfile();
|
|
1569
|
+
if (profile !== this.deps.activeProfile())
|
|
1570
|
+
throw new HttpFailure(400, 'A diagnostic chat starts in the active profile');
|
|
1571
|
+
if (!this.owner)
|
|
1572
|
+
throw new HttpFailure(409, 'The Agent cannot start a chat in this mode', { code: 'execution_mode_unavailable', reason: 'gateway_unavailable' });
|
|
1573
|
+
const defaults = await this.store.deps.defaults(profile);
|
|
1574
|
+
let workspace;
|
|
1575
|
+
try {
|
|
1576
|
+
workspace = await this.resolveNewSessionWorkspace(body, null, profile);
|
|
1577
|
+
}
|
|
1578
|
+
catch (error) {
|
|
1579
|
+
throw new HttpFailure(400, error.message);
|
|
1580
|
+
}
|
|
1581
|
+
const [requested, requestedProvider] = this.deps.modelStateFromRequest(body.model, body.model_provider, null);
|
|
1582
|
+
const model = requested ?? defaults.model;
|
|
1583
|
+
const provider = requested ? requestedProvider : requestedProvider ?? defaults.modelProvider ?? null;
|
|
1584
|
+
if (!model)
|
|
1585
|
+
throw new HttpFailure(400, 'Choose a model before starting a diagnostic chat');
|
|
1586
|
+
const toolsets = this.validateToolsetsShape(body.enabled_toolsets);
|
|
1587
|
+
const route = provider ? this.deps.profileLaunchRoute?.(profile, provider) ?? { provider, baseUrl: null } : null;
|
|
1588
|
+
const sid = await this.owner.create(profile, typeof body.request_id === 'string' ? body.request_id : null, { execution_mode: mode, cwd: workspace ?? defaults.workspace, model,
|
|
1589
|
+
...(route ? { provider: route.provider } : {}), ...(route?.baseUrl ? { base_url: route.baseUrl } : {}), ...(toolsets ? { toolsets } : {}) });
|
|
1590
|
+
return this.detail(sid, {});
|
|
1591
|
+
}
|
|
1055
1592
|
// ── simple metadata mutations ────────────────────────────────────────────
|
|
1056
1593
|
/** Python `_persist_generated_session_title`: title + generated/manual flags under the session lock, then insights sync. */
|
|
1057
1594
|
async persistGeneratedTitle(sid, nextTitle, eventReason) {
|
|
1058
1595
|
const title = nextTitle.trim().slice(0, 80) || 'Untitled';
|
|
1059
1596
|
let current;
|
|
1060
1597
|
try {
|
|
1061
|
-
current = this.store.get(sid);
|
|
1598
|
+
current = (await this.store.get(sid));
|
|
1062
1599
|
}
|
|
1063
1600
|
catch {
|
|
1064
1601
|
throw new HttpFailure(404, 'Session not found');
|
|
1065
1602
|
}
|
|
1066
|
-
if (this.isReadOnly(current))
|
|
1603
|
+
if ((await this.isReadOnly(current)))
|
|
1067
1604
|
throw new HttpFailure(403, `Session ${sid} is read-only`);
|
|
1068
|
-
await this.
|
|
1605
|
+
await this.withMutationLock(sid, async () => {
|
|
1069
1606
|
current.title = title;
|
|
1070
1607
|
markSessionTitleGenerated(current);
|
|
1071
|
-
this.store.save(current, { touchUpdatedAt: false });
|
|
1608
|
+
await this.store.save(current, { touchUpdatedAt: false });
|
|
1072
1609
|
});
|
|
1073
1610
|
// An explicit regenerate is user-requested, so state.db takes it over a manual name.
|
|
1074
1611
|
await this.deps.syncTitle(current, true);
|
|
@@ -1076,29 +1613,31 @@ export class SessionService {
|
|
|
1076
1613
|
return current;
|
|
1077
1614
|
}
|
|
1078
1615
|
async rename(sid, rawTitle) {
|
|
1079
|
-
const
|
|
1080
|
-
await this.
|
|
1616
|
+
const reads = this.sessionGateReads(sid);
|
|
1617
|
+
const s = (await this.mutationTarget(sid, 'renamed', reads));
|
|
1618
|
+
await this.withMutationLock(sid, async () => {
|
|
1081
1619
|
applySessionTitleRename(s, rawTitle);
|
|
1082
|
-
this.store.save(s);
|
|
1620
|
+
await this.store.save(s);
|
|
1083
1621
|
});
|
|
1084
1622
|
await this.deps.syncTitle(s, true);
|
|
1085
1623
|
this.publish('session_rename', s.profile, s.session_id);
|
|
1086
|
-
return { session: this.wireRow(s) };
|
|
1624
|
+
return { session: (await this.wireRow(s)) };
|
|
1087
1625
|
}
|
|
1088
1626
|
// The pin quota is counted across sessions, so the count-and-save transaction runs on one process-wide chain;
|
|
1089
1627
|
// per-session locks alone would let two pins of different sessions both see the last free slot.
|
|
1090
1628
|
pinChain = Promise.resolve();
|
|
1091
1629
|
pin(sid, pinRequested) {
|
|
1092
|
-
const run = this.pinChain.then(() => this.pinUnserialized(sid, pinRequested));
|
|
1630
|
+
const run = this.pinChain.then(async () => (await this.pinUnserialized(sid, pinRequested)));
|
|
1093
1631
|
this.pinChain = run.catch(() => undefined);
|
|
1094
1632
|
return run;
|
|
1095
1633
|
}
|
|
1096
1634
|
async pinUnserialized(sid, pinRequested) {
|
|
1097
|
-
this.
|
|
1098
|
-
|
|
1099
|
-
s = this.
|
|
1635
|
+
const reads = this.sessionGateReads(sid);
|
|
1636
|
+
await this.rejectViewOnly(sid, 'modified', reads);
|
|
1637
|
+
let s = (await this.get404(sid, {}, reads));
|
|
1638
|
+
s = (await this.store.ensureFull(sid, s));
|
|
1100
1639
|
if (pinRequested && !s.pinned) {
|
|
1101
|
-
const rows = allSessions(this.store);
|
|
1640
|
+
const rows = (await allSessions(this.store));
|
|
1102
1641
|
const byId = new Map(rows.filter((r) => r.session_id).map((r) => [str(r.session_id), r]));
|
|
1103
1642
|
byId.set(s.session_id, s.compact());
|
|
1104
1643
|
const targetLineage = lineageRootId(s.compact(), byId);
|
|
@@ -1108,38 +1647,165 @@ export class SessionService {
|
|
|
1108
1647
|
if (pinnedLineages.size >= limit)
|
|
1109
1648
|
throw new HttpFailure(400, `Up to ${limit} sessions can be pinned. Unpin one before pinning another.`);
|
|
1110
1649
|
}
|
|
1111
|
-
await this.
|
|
1650
|
+
await this.withMutationLock(sid, async () => {
|
|
1112
1651
|
s.pinned = pinRequested;
|
|
1113
|
-
this.store.save(s);
|
|
1114
|
-
});
|
|
1652
|
+
await this.store.save(s);
|
|
1653
|
+
}, { allowReadOnly: true });
|
|
1115
1654
|
this.publish('session_pin', s.profile, s.session_id);
|
|
1116
|
-
return { ok: true, session: this.wireRow(s) };
|
|
1655
|
+
return { ok: true, session: (await this.wireRow(s)) };
|
|
1656
|
+
}
|
|
1657
|
+
/**
|
|
1658
|
+
* TAL-1254: relock the worktrees of every unarchived worktree chat (or just `only`) for the running sidecar, so the
|
|
1659
|
+
* Agent's worktree sweep reads them as live, and flag each one git no longer has as retained. Runs before a
|
|
1660
|
+
* (re)started sidecar reports ready and when a chat is unarchived. The Agent's sweep runs outside Talaria, so a lock
|
|
1661
|
+
* the sidecar could not confirm is retried until git takes it; meanwhile that tree keeps its flag.
|
|
1662
|
+
*/
|
|
1663
|
+
async retainWorktrees(call, only) {
|
|
1664
|
+
const { rows, unread } = only ? { rows: [only.compact()], unread: 0 } : await this.worktreeSessionRows();
|
|
1665
|
+
const targets = rows.filter((row) => !row.archived && typeof row.worktree_path === 'string' && row.worktree_path && typeof row.session_id === 'string');
|
|
1666
|
+
if (!targets.length) {
|
|
1667
|
+
if (unread)
|
|
1668
|
+
this.retryWorktreeRetention();
|
|
1669
|
+
return;
|
|
1670
|
+
}
|
|
1671
|
+
let results;
|
|
1672
|
+
try {
|
|
1673
|
+
({ results } = await call('worktree.retain', { paths: [...new Set(targets.map((row) => String(row.worktree_path)))] }));
|
|
1674
|
+
}
|
|
1675
|
+
catch (error) {
|
|
1676
|
+
this.retryWorktreeRetention();
|
|
1677
|
+
throw error;
|
|
1678
|
+
}
|
|
1679
|
+
const unconfirmed = targets.filter((row) => !results[String(row.worktree_path)]).length + unread;
|
|
1680
|
+
if (!only && unconfirmed !== this.worktreesUnconfirmed)
|
|
1681
|
+
this.deps.log(`[webui] ${String(unconfirmed)} worktree lock(s) not yet held by the sidecar${unconfirmed ? '; retrying' : ''}`);
|
|
1682
|
+
if (!only)
|
|
1683
|
+
this.worktreesUnconfirmed = unconfirmed;
|
|
1684
|
+
if (unconfirmed)
|
|
1685
|
+
this.retryWorktreeRetention();
|
|
1686
|
+
for (const row of targets) {
|
|
1687
|
+
const state = results[String(row.worktree_path)];
|
|
1688
|
+
const sid = String(row.session_id);
|
|
1689
|
+
if (!state)
|
|
1690
|
+
continue;
|
|
1691
|
+
try {
|
|
1692
|
+
// `extra` follows the transcript in the file, past what a metadata-only read sees.
|
|
1693
|
+
if (row._unread)
|
|
1694
|
+
continue;
|
|
1695
|
+
const s = await this.store.ensureFull(sid, await this.store.get(sid, { metadataOnly: true }));
|
|
1696
|
+
if ((state === 'missing') === (s.extra[WORKTREE_RETAINED_KEY] === true))
|
|
1697
|
+
continue;
|
|
1698
|
+
await this.withMutationLock(sid, async () => {
|
|
1699
|
+
s.extra = Object.fromEntries(Object.entries(s.extra).filter(([key]) => key !== WORKTREE_RETAINED_KEY));
|
|
1700
|
+
if (state === 'missing')
|
|
1701
|
+
s.extra[WORKTREE_RETAINED_KEY] = true;
|
|
1702
|
+
await this.store.save(s, { touchUpdatedAt: false });
|
|
1703
|
+
}, { allowReadOnly: true });
|
|
1704
|
+
this.publish('session_worktree', s.profile, sid);
|
|
1705
|
+
}
|
|
1706
|
+
catch (error) {
|
|
1707
|
+
if (error instanceof SessionNotFound)
|
|
1708
|
+
continue;
|
|
1709
|
+
this.deps.log(`[webui] could not record the worktree state of ${sid}: ${error.message}`);
|
|
1710
|
+
this.retryWorktreeRetention();
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
/**
|
|
1715
|
+
* Every persisted worktree chat, read from its own file: the index only narrows the files to read, since a crash
|
|
1716
|
+
* between a save and its index update can leave a session out of the index or with a stale `archived`. A file that
|
|
1717
|
+
* cannot be read now, or a session directory that cannot be listed, falls back to the index rows (`_unread`, so their
|
|
1718
|
+
* flags are left alone) and counts in `unread`, which keeps retention retrying until every file has been read.
|
|
1719
|
+
*/
|
|
1720
|
+
async worktreeSessionRows() {
|
|
1721
|
+
let indexed;
|
|
1722
|
+
try {
|
|
1723
|
+
indexed = new Map((await this.store.readIndexEntries()).map((row) => [str(row.session_id), row]));
|
|
1724
|
+
}
|
|
1725
|
+
catch {
|
|
1726
|
+
indexed = new Map();
|
|
1727
|
+
}
|
|
1728
|
+
let persisted;
|
|
1729
|
+
try {
|
|
1730
|
+
persisted = await this.store.persistedIds({ strict: true });
|
|
1731
|
+
}
|
|
1732
|
+
catch {
|
|
1733
|
+
// The session directory cannot be listed now: retain what the index names and retry until it can.
|
|
1734
|
+
const rows = [...indexed.values()].filter((row) => row.worktree_path).map((row) => ({ ...row, archived: false, _unread: true }));
|
|
1735
|
+
return { rows, unread: Math.max(1, rows.length) };
|
|
1736
|
+
}
|
|
1737
|
+
const candidates = [...persisted].filter((sid) => !indexed.has(sid) || indexed.get(sid)?.worktree_path);
|
|
1738
|
+
let unread = 0;
|
|
1739
|
+
const rows = await Promise.all(candidates.map(async (sid) => {
|
|
1740
|
+
try {
|
|
1741
|
+
return (await this.store.get(sid, { metadataOnly: true })).compact();
|
|
1742
|
+
}
|
|
1743
|
+
catch (error) {
|
|
1744
|
+
if (error instanceof SessionNotFound)
|
|
1745
|
+
return null;
|
|
1746
|
+
unread += 1;
|
|
1747
|
+
const row = indexed.get(sid);
|
|
1748
|
+
return row?.worktree_path ? { ...row, archived: false, _unread: true } : null;
|
|
1749
|
+
}
|
|
1750
|
+
}));
|
|
1751
|
+
return { rows: rows.filter((row) => row !== null), unread };
|
|
1752
|
+
}
|
|
1753
|
+
/** One pending retry at a time; a sidecar that is not ready by then retains everything in its next ready gate instead. */
|
|
1754
|
+
retryWorktreeRetention() {
|
|
1755
|
+
if (this.worktreeRetainRetry)
|
|
1756
|
+
return;
|
|
1757
|
+
this.worktreeRetainRetry = setTimeout(() => {
|
|
1758
|
+
this.worktreeRetainRetry = null;
|
|
1759
|
+
const sidecar = this.deps.sidecar?.();
|
|
1760
|
+
if (sidecar?.status !== 'ready')
|
|
1761
|
+
return;
|
|
1762
|
+
this.retainWorktrees((method, params, opts) => sidecar.call(method, params, opts)).catch((error) => { this.deps.log(`[webui] worktree lock retry failed: ${String(error)}`); });
|
|
1763
|
+
}, WORKTREE_RETAIN_RETRY_MS);
|
|
1764
|
+
this.worktreeRetainRetry.unref();
|
|
1117
1765
|
}
|
|
1118
1766
|
async archive(sid, archived) {
|
|
1119
|
-
this.
|
|
1120
|
-
|
|
1121
|
-
s = this.
|
|
1122
|
-
await this.store.
|
|
1767
|
+
const reads = this.sessionGateReads(sid);
|
|
1768
|
+
await this.rejectViewOnly(sid, 'archived', reads);
|
|
1769
|
+
let s = (await this.get404(sid, {}, reads));
|
|
1770
|
+
s = (await this.store.ensureFull(sid, s));
|
|
1771
|
+
await this.withMutationLock(sid, async () => {
|
|
1772
|
+
s = await this.store.ensureFull(sid, await this.store.get(sid));
|
|
1773
|
+
const previous = { archived: s.archived, pinned: s.pinned };
|
|
1774
|
+
const wasArchived = (await this.store.loadMetadataOnly(sid))?.archived ?? false;
|
|
1123
1775
|
s.archived = archived;
|
|
1124
1776
|
if (archived)
|
|
1125
1777
|
s.pinned = false;
|
|
1126
|
-
|
|
1127
|
-
|
|
1778
|
+
try {
|
|
1779
|
+
await this.store.save(s, { touchUpdatedAt: false });
|
|
1780
|
+
const saved = await this.store.loadMetadataOnly(sid);
|
|
1781
|
+
if (saved?.archived !== archived || saved.pinned !== s.pinned)
|
|
1782
|
+
throw new HttpFailure(409, 'Archive was not saved; reload the chat and retry');
|
|
1783
|
+
}
|
|
1784
|
+
catch (error) {
|
|
1785
|
+
Object.assign(s, previous);
|
|
1786
|
+
throw error;
|
|
1787
|
+
}
|
|
1788
|
+
if (archived && !wasArchived)
|
|
1789
|
+
await this.finalizeSession(sid, s.profile, 'archive', stateDbReadLineage(s) ?? undefined);
|
|
1790
|
+
}, { allowReadOnly: true });
|
|
1791
|
+
const sidecar = this.deps.sidecar?.();
|
|
1792
|
+
if (!archived && sidecar)
|
|
1793
|
+
await this.retainWorktrees((method, params, opts) => sidecar.call(method, params, opts), s).catch(() => undefined);
|
|
1128
1794
|
this.publish('session_archive', s.profile, s.session_id);
|
|
1129
|
-
return { ok: true, session: this.wireRow(s), ...worktreeRetainedPayload(s) };
|
|
1795
|
+
return { ok: true, session: (await this.wireRow(s)), ...worktreeRetainedPayload(s) };
|
|
1130
1796
|
}
|
|
1131
1797
|
async move(sid, targetProjectId) {
|
|
1132
|
-
const s = this.mutationTarget(sid, 'moved');
|
|
1798
|
+
const s = (await this.mutationTarget(sid, 'moved'));
|
|
1133
1799
|
if (targetProjectId) {
|
|
1134
1800
|
const sessionProfile = s.profile || this.deps.activeProfile();
|
|
1135
|
-
const target = this.deps.projects.load().find((p) => p.project_id === targetProjectId);
|
|
1801
|
+
const target = (await this.deps.projects.load()).find((p) => p.project_id === targetProjectId);
|
|
1136
1802
|
if (!target || !this.deps.profilesMatch(target.profile ?? null, sessionProfile))
|
|
1137
1803
|
throw new HttpFailure(404, 'Project not found');
|
|
1138
1804
|
}
|
|
1139
1805
|
try {
|
|
1140
|
-
await this.
|
|
1806
|
+
await this.withMutationLock(sid, async () => {
|
|
1141
1807
|
s.project_id = targetProjectId;
|
|
1142
|
-
this.store.save(s);
|
|
1808
|
+
await this.store.save(s);
|
|
1143
1809
|
}, { timeoutMs: 5000 });
|
|
1144
1810
|
}
|
|
1145
1811
|
catch (error) {
|
|
@@ -1148,67 +1814,111 @@ export class SessionService {
|
|
|
1148
1814
|
throw error;
|
|
1149
1815
|
}
|
|
1150
1816
|
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
|
-
|
|
1817
|
+
return { ok: true, session: (await this.wireRow(s)) };
|
|
1818
|
+
}
|
|
1819
|
+
modelConfirmationKey = randomUUID();
|
|
1820
|
+
async confirmModelSwitch(s, model, provider, token, retry = false) {
|
|
1821
|
+
const stateRows = await this.stateDbRows(s);
|
|
1822
|
+
const profile = s.profile ?? this.deps.activeProfile();
|
|
1823
|
+
const home = this.deps.profileHome(profile);
|
|
1824
|
+
const context = contextUsage(s).context_used_tokens;
|
|
1825
|
+
const messages = this.modelContext(s, stateRows);
|
|
1826
|
+
const before = JSON.stringify([s.model, s.model_provider, context, s.context_length, messages]);
|
|
1827
|
+
let decision;
|
|
1159
1828
|
try {
|
|
1160
|
-
|
|
1829
|
+
const sidecar = this.deps.sidecar?.();
|
|
1830
|
+
if (!sidecar)
|
|
1831
|
+
throw new Error('Agent sidecar is unavailable');
|
|
1832
|
+
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
1833
|
}
|
|
1162
1834
|
catch (error) {
|
|
1163
|
-
throw new HttpFailure(
|
|
1835
|
+
throw new HttpFailure(503, `Cannot check model selection warnings: ${error.message}`);
|
|
1164
1836
|
}
|
|
1165
|
-
await this.
|
|
1837
|
+
const stateRowsAfter = await this.stateDbRows(s);
|
|
1838
|
+
if (before !== JSON.stringify([s.model, s.model_provider, contextUsage(s).context_used_tokens, s.context_length, this.modelContext(s, stateRowsAfter)])) {
|
|
1839
|
+
if (retry)
|
|
1840
|
+
throw new HttpFailure(503, 'The session changed while checking model warnings. Try the selection again.');
|
|
1841
|
+
return this.confirmModelSwitch(s, model, provider, null, true);
|
|
1842
|
+
}
|
|
1843
|
+
if (!decision.warnings.length)
|
|
1844
|
+
return;
|
|
1845
|
+
const signature = JSON.stringify([profile, home, s.session_id, s.model, s.model_provider, model, provider, context, s.context_length, messages, decision]);
|
|
1846
|
+
const confirmToken = createHmac('sha256', this.modelConfirmationKey).update(signature).digest('hex');
|
|
1847
|
+
if (token !== confirmToken)
|
|
1848
|
+
throw new HttpFailure(409, decision.warnings.map((w) => w.message).join('\n\n'), { type: 'model_confirmation_required', warnings: decision.warnings, confirm_token: confirmToken });
|
|
1849
|
+
}
|
|
1850
|
+
async update(sid, body) {
|
|
1851
|
+
let s = (await this.mutationTarget(sid, 'updated'));
|
|
1852
|
+
let oldWs = s.workspace;
|
|
1853
|
+
let newWs = oldWs;
|
|
1854
|
+
await this.withMutationLock(sid, async () => {
|
|
1855
|
+
s = await this.mutationTarget(sid, 'updated');
|
|
1856
|
+
oldWs = s.workspace;
|
|
1857
|
+
try {
|
|
1858
|
+
newWs = await this.deps.workspaces.resolveTrusted(typeof body.workspace === 'string' ? body.workspace : s.workspace, s.profile);
|
|
1859
|
+
}
|
|
1860
|
+
catch (error) {
|
|
1861
|
+
throw new HttpFailure(400, error.message);
|
|
1862
|
+
}
|
|
1863
|
+
const oldModel = s.model;
|
|
1864
|
+
const oldProvider = s.model_provider;
|
|
1865
|
+
const hasModel = 'model' in body || 'model_provider' in body;
|
|
1866
|
+
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];
|
|
1867
|
+
const nextModel = model ?? s.model;
|
|
1868
|
+
const changed = hasModel && (str(oldModel) !== str(nextModel) || str(oldProvider) !== str(provider));
|
|
1869
|
+
const contextLength = changed ? (this.deps.resolveContextLength ? await this.deps.resolveContextLength(nextModel, provider, s.profile) : this.deps.contextLengthFor(nextModel, provider)) : s.context_length;
|
|
1870
|
+
if (changed)
|
|
1871
|
+
await this.confirmModelSwitch(s, nextModel ?? '', provider, body.confirm_token);
|
|
1872
|
+
await this.assertWritable(s);
|
|
1166
1873
|
s.workspace = newWs;
|
|
1167
|
-
if (
|
|
1168
|
-
|
|
1169
|
-
if (model !== null)
|
|
1170
|
-
s.model = model;
|
|
1874
|
+
if (hasModel) {
|
|
1875
|
+
s.model = nextModel;
|
|
1171
1876
|
s.model_provider = provider;
|
|
1172
|
-
if (
|
|
1173
|
-
s.context_length =
|
|
1174
|
-
s
|
|
1175
|
-
s.last_prompt_tokens = 0;
|
|
1176
|
-
this.deps.runtime.evictAgent(sid);
|
|
1877
|
+
if (changed) {
|
|
1878
|
+
s.context_length = contextLength;
|
|
1879
|
+
forgetContextFigures(s);
|
|
1177
1880
|
}
|
|
1178
1881
|
}
|
|
1179
|
-
|
|
1882
|
+
if (changed || oldWs !== newWs)
|
|
1883
|
+
await this.deps.runtime.evictAgent(sid);
|
|
1884
|
+
await this.store.save(s);
|
|
1180
1885
|
});
|
|
1181
1886
|
if (str(oldWs) !== str(newWs))
|
|
1182
1887
|
this.deps.runtime.closeTerminal(sid);
|
|
1183
|
-
this.deps.workspaces.setLastWorkspace(newWs, s.profile);
|
|
1184
|
-
return { session: this.publicSession(s) };
|
|
1888
|
+
await this.deps.workspaces.setLastWorkspace(newWs, s.profile);
|
|
1889
|
+
return { session: (await this.publicSession(s)) };
|
|
1185
1890
|
}
|
|
1186
1891
|
async setToolsets(sid, toolsets) {
|
|
1187
|
-
this.
|
|
1892
|
+
await this.rejectViewOnly(sid, 'modified');
|
|
1188
1893
|
const cleaned = this.validateToolsetsShape(toolsets);
|
|
1189
|
-
const s = this.mutationTarget(sid, 'modified');
|
|
1190
|
-
await this.
|
|
1894
|
+
const s = (await this.mutationTarget(sid, 'modified'));
|
|
1895
|
+
await this.withMutationLock(sid, async () => {
|
|
1191
1896
|
s.enabled_toolsets = cleaned;
|
|
1192
|
-
this.store.save(s);
|
|
1897
|
+
await this.store.save(s);
|
|
1193
1898
|
});
|
|
1194
1899
|
return { ok: true, enabled_toolsets: s.enabled_toolsets };
|
|
1195
1900
|
}
|
|
1196
1901
|
/** Python `/api/personality/set` persistence: the name only; the prompt is resolved by the caller. */
|
|
1197
1902
|
async setPersonality(sid, name) {
|
|
1198
|
-
this.
|
|
1199
|
-
const s = this.mutationTarget(sid, 'modified');
|
|
1200
|
-
await this.
|
|
1903
|
+
await this.rejectViewOnly(sid, 'modified');
|
|
1904
|
+
const s = (await this.mutationTarget(sid, 'modified'));
|
|
1905
|
+
await this.withMutationLock(sid, async () => {
|
|
1201
1906
|
s.personality = name;
|
|
1202
|
-
this.store.save(s);
|
|
1907
|
+
await this.store.save(s);
|
|
1203
1908
|
});
|
|
1204
1909
|
return typeof s.personality === 'string' ? s.personality : null;
|
|
1205
1910
|
}
|
|
1206
1911
|
// ── transcript mutations ─────────────────────────────────────────────────
|
|
1207
|
-
|
|
1208
|
-
|
|
1912
|
+
/**
|
|
1913
|
+
* TAL-929: under a `hold`, a cut that discards more than one user turn changes nothing and answers with its counts,
|
|
1914
|
+
* unless `confirmToken` is the token of the rows it would remove now: a transcript that changed since asks again.
|
|
1915
|
+
* Without one (a released App, which asked on the device) the cut proceeds as before.
|
|
1916
|
+
*/
|
|
1917
|
+
async truncate(sid, keepRaw, hold = null) {
|
|
1918
|
+
await this.rejectViewOnly(sid, 'modified');
|
|
1209
1919
|
if (keepRaw === null || keepRaw === undefined)
|
|
1210
1920
|
throw new HttpFailure(400, 'Missing required field(s): keep_count');
|
|
1211
|
-
const s = this.mutationTarget(sid, 'modified');
|
|
1921
|
+
const s = (await this.mutationTarget(sid, 'modified'));
|
|
1212
1922
|
// Python `int(body["keep_count"])`: a float truncates, a non-integer string is rejected.
|
|
1213
1923
|
let keep;
|
|
1214
1924
|
if (typeof keepRaw === 'number')
|
|
@@ -1221,26 +1931,61 @@ export class SessionService {
|
|
|
1221
1931
|
throw new HttpFailure(400, 'keep_count must be an integer');
|
|
1222
1932
|
if (keep < 0)
|
|
1223
1933
|
throw new HttpFailure(400, 'keep_count must be non-negative');
|
|
1224
|
-
await this.
|
|
1934
|
+
const refused = await this.withMutationLock(sid, async () => {
|
|
1935
|
+
// One state.db read decides and marks the boundary, so a row committed meanwhile is neither counted nor hidden.
|
|
1936
|
+
const read = await this.stateDbRead(s);
|
|
1937
|
+
if (hold) {
|
|
1938
|
+
// A failed read cannot say whether a CLI continuation would go too: fail closed. The Agent keeps a state.db for every
|
|
1939
|
+
// profile it has run in, so a missing one is never evidence of a session without state.db rows.
|
|
1940
|
+
if (!read.ok)
|
|
1941
|
+
throw new HttpFailure(503, read.reason ?? 'The session history could not be read. Please try again in a moment.');
|
|
1942
|
+
const held = (await this.heldCut(s, keep, read.rows));
|
|
1943
|
+
if (held && held.confirm_token !== hold.confirmToken)
|
|
1944
|
+
return held;
|
|
1945
|
+
}
|
|
1946
|
+
// A cut inside the state.db continuation keeps the continuation's rows before it: the boundary below hides every
|
|
1947
|
+
// row this read saw, so those rows join the stored transcript and model history first.
|
|
1948
|
+
if (keep > s.messages.length) {
|
|
1949
|
+
if (s.context_messages.length)
|
|
1950
|
+
s.context_messages = this.modelContext(s, read.rows);
|
|
1951
|
+
s.messages = this.mergedTranscript(s, s.messages, read.rows);
|
|
1952
|
+
}
|
|
1225
1953
|
truncateSessionAtKeep(s, keep);
|
|
1226
|
-
this.markStateDbSeen(s,
|
|
1227
|
-
this.store.save(s);
|
|
1954
|
+
this.markStateDbSeen(s, read, { boundary: true });
|
|
1955
|
+
await this.store.save(s);
|
|
1956
|
+
return null;
|
|
1228
1957
|
});
|
|
1229
|
-
|
|
1230
|
-
|
|
1958
|
+
if (refused)
|
|
1959
|
+
return refused;
|
|
1960
|
+
await this.deps.runtime.evictAgent(sid);
|
|
1961
|
+
return { ok: true, session: (await this.publicSession(s)) };
|
|
1962
|
+
}
|
|
1963
|
+
/**
|
|
1964
|
+
* TAL-929: the confirmation a cut at `keep` needs, or null for one that removes at most one user turn. Counted on the rows
|
|
1965
|
+
* the detail shows, state.db continuation included, since `keep` indexes them: the user turns the cut removes, and the
|
|
1966
|
+
* messages after the row it starts at.
|
|
1967
|
+
*/
|
|
1968
|
+
async heldCut(s, keep, stateRows) {
|
|
1969
|
+
const transcript = this.mergedTranscript(s, s.messages, stateRows);
|
|
1970
|
+
const cut = ((await this.publicSession(s, true, transcript)).messages ?? []).map((m, i) => ({ m, i })).filter(({ m, i }) => i >= keep && m._display === 'row');
|
|
1971
|
+
const userTurns = cut.filter(({ m }) => m.role === 'user').length;
|
|
1972
|
+
if (userTurns <= 1)
|
|
1973
|
+
return null;
|
|
1974
|
+
const token = createHash('sha256').update(JSON.stringify(transcript.slice(keep))).digest('hex').slice(0, 32);
|
|
1975
|
+
return { ok: false, confirm_required: true, discard_user_turns: userTurns, discard_messages: cut.filter(({ i }) => i > keep).length, confirm_token: token };
|
|
1231
1976
|
}
|
|
1232
1977
|
// ── manual compression (TAL-255) ────────────────────────────────────────
|
|
1233
1978
|
/**
|
|
1234
1979
|
* Python `_handle_session_compress_start`: validate, then join the session's running job or start a fresh one (a
|
|
1235
1980
|
* finished job is replaced). The stale-runtime refusal comes before a job exists; a running job is joined without it.
|
|
1236
1981
|
*/
|
|
1237
|
-
async startCompression(sid, focusRaw) {
|
|
1982
|
+
async startCompression(sid, focusRaw, opts = {}) {
|
|
1238
1983
|
const running = () => { const job = this.compressionJobs.get(sid); return job?.status === 'running' ? job : undefined; };
|
|
1239
1984
|
// A running job is joined first: once it has installed its short context, the guards below would refuse a new one.
|
|
1240
1985
|
const existing = running();
|
|
1241
1986
|
if (existing)
|
|
1242
1987
|
return existing;
|
|
1243
|
-
const { s } = this.compressionTarget(sid);
|
|
1988
|
+
const { s } = (await this.compressionTarget(sid));
|
|
1244
1989
|
const focusTopic = str(focusRaw).trim().slice(0, 500) || null;
|
|
1245
1990
|
await ensureAgentRuntimeCurrent(this.deps.sidecar?.() ?? null);
|
|
1246
1991
|
// Another start may have admitted a job while the runtime check awaited.
|
|
@@ -1253,8 +1998,8 @@ export class SessionService {
|
|
|
1253
1998
|
throw new HttpFailure(409, `Profile '${str(profile)}' is being deleted.`);
|
|
1254
1999
|
const release = this.deps.profileActivity?.(profile);
|
|
1255
2000
|
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(
|
|
2001
|
+
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() };
|
|
2002
|
+
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
2003
|
const known = error instanceof HttpFailure;
|
|
1259
2004
|
if (!known)
|
|
1260
2005
|
this.deps.log(`[webui] Manual compression worker failed for session ${sid}: ${error.message}`);
|
|
@@ -1263,28 +2008,121 @@ export class SessionService {
|
|
|
1263
2008
|
this.compressionJobs.set(job);
|
|
1264
2009
|
return job;
|
|
1265
2010
|
}
|
|
2011
|
+
/**
|
|
2012
|
+
* TAL-1005: compact the session in its joinable compression job before the next turn reads its history. A running job
|
|
2013
|
+
* is always joined; a new one starts when the session is `due` (`compactionDue`, read from the store when omitted).
|
|
2014
|
+
* `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
|
|
2015
|
+
* job. True when the job installed a compressed context. Never throws: a failed job leaves the Agent's own preflight.
|
|
2016
|
+
*/
|
|
2017
|
+
async ensureCompacted(sid, opts = {}) {
|
|
2018
|
+
const { turn, signal } = opts;
|
|
2019
|
+
if (turn)
|
|
2020
|
+
this.compactionWaiters.add(turn);
|
|
2021
|
+
try {
|
|
2022
|
+
let job = this.compressionJobs.get(sid);
|
|
2023
|
+
if (job?.status !== 'running')
|
|
2024
|
+
job = (opts.due ?? (await this.storedCompactionDue(sid))) ? await this.startCompression(sid, null, { automatic: true }) : undefined;
|
|
2025
|
+
if (!job || signal?.aborted)
|
|
2026
|
+
return false;
|
|
2027
|
+
if (turn)
|
|
2028
|
+
job.turns.add(turn);
|
|
2029
|
+
await opts.onWait?.();
|
|
2030
|
+
const aborted = new Promise((resolve) => { signal?.addEventListener('abort', () => { resolve(); }, { once: true }); });
|
|
2031
|
+
await Promise.race([job.done, aborted]);
|
|
2032
|
+
return !signal?.aborted && job.status === 'done';
|
|
2033
|
+
}
|
|
2034
|
+
catch (error) {
|
|
2035
|
+
this.deps.log(`[webui] compaction before the next turn of ${sid} did not run: ${error.message}`);
|
|
2036
|
+
return false;
|
|
2037
|
+
}
|
|
2038
|
+
finally {
|
|
2039
|
+
if (turn)
|
|
2040
|
+
this.compactionWaiters.delete(turn);
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
2043
|
+
/**
|
|
2044
|
+
* TAL-1005: measure the model context again when it holds a post-compression estimate the rows after it may have
|
|
2045
|
+
* outgrown (a waiting turn's prompt, a stopped or failed turn's work). Skipped while a turn owns the session, which
|
|
2046
|
+
* settles its own figures, or once the estimate changed meanwhile. True when it stored a new estimate.
|
|
2047
|
+
*/
|
|
2048
|
+
async remeasureContext(sid) {
|
|
2049
|
+
const sidecar = this.deps.sidecar?.();
|
|
2050
|
+
let s;
|
|
2051
|
+
try {
|
|
2052
|
+
s = (await this.store.get(sid));
|
|
2053
|
+
}
|
|
2054
|
+
catch {
|
|
2055
|
+
return false;
|
|
2056
|
+
}
|
|
2057
|
+
const before = s.post_compression_context_tokens_estimate;
|
|
2058
|
+
if (before === null || s.active_stream_id || !sidecar)
|
|
2059
|
+
return false;
|
|
2060
|
+
let tokens;
|
|
2061
|
+
try {
|
|
2062
|
+
tokens = (await sidecar.call('models.estimate_tokens', { messages: await this.readModelContext(s) })).tokens;
|
|
2063
|
+
}
|
|
2064
|
+
catch {
|
|
2065
|
+
return false;
|
|
2066
|
+
}
|
|
2067
|
+
return this.store.withLock(sid, async () => {
|
|
2068
|
+
let live;
|
|
2069
|
+
try {
|
|
2070
|
+
live = (await this.store.get(sid));
|
|
2071
|
+
}
|
|
2072
|
+
catch {
|
|
2073
|
+
return false;
|
|
2074
|
+
}
|
|
2075
|
+
if (live.active_stream_id || live.post_compression_context_tokens_estimate !== before || (await this.isReadOnly(live)))
|
|
2076
|
+
return false;
|
|
2077
|
+
live.post_compression_context_tokens_estimate = Math.max(1, tokens);
|
|
2078
|
+
await this.store.save(live, { touchUpdatedAt: false });
|
|
2079
|
+
return true;
|
|
2080
|
+
});
|
|
2081
|
+
}
|
|
2082
|
+
async storedCompactionDue(sid) {
|
|
2083
|
+
try {
|
|
2084
|
+
return this.compactionDue((await this.store.get(sid, { metadataOnly: true })));
|
|
2085
|
+
}
|
|
2086
|
+
catch {
|
|
2087
|
+
return false;
|
|
2088
|
+
}
|
|
2089
|
+
}
|
|
2090
|
+
/** Whether the session's context (the post-compression estimate, else the last prompt) reached the next turn's threshold. */
|
|
2091
|
+
compactionDue(s) {
|
|
2092
|
+
const positive = (v) => { const n = Math.trunc(Number(v ?? 0)); return Number.isFinite(n) && n > 0 ? n : null; };
|
|
2093
|
+
const used = positive(s.post_compression_context_tokens_estimate) ?? positive(s.last_prompt_tokens);
|
|
2094
|
+
const threshold = positive(s.next_turn_threshold_tokens) ?? positive(s.threshold_tokens);
|
|
2095
|
+
return used !== null && threshold !== null && used >= threshold;
|
|
2096
|
+
}
|
|
1266
2097
|
/** 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
|
-
|
|
2098
|
+
* repeat compression builds on the previous summary instead of re-reading the whole display transcript. `context` is
|
|
2099
|
+
* that history unsanitized and `stateRows` the state.db read it came from. */
|
|
2100
|
+
async compressionTarget(sid) {
|
|
2101
|
+
await this.rejectViewOnly(sid, 'compressed');
|
|
2102
|
+
const s = (await this.mutationTarget(sid, 'compressed'));
|
|
2103
|
+
const stateRows = await this.stateDbRows(s);
|
|
2104
|
+
const context = this.compressionContext(s, stateRows);
|
|
2105
|
+
return { s, history: sanitizeMessagesForApi(context), context, stateRows };
|
|
2106
|
+
}
|
|
2107
|
+
/**
|
|
2108
|
+
* The loaded session's own compression guards: no running turn and enough model history. TAL-1005: a turn waiting on
|
|
2109
|
+
* the compression has not read its history yet, so it does not block; the context leaves out its rows, as it does.
|
|
2110
|
+
*/
|
|
2111
|
+
compressionContext(s, stateRows) {
|
|
2112
|
+
const turn = s.active_stream_id;
|
|
2113
|
+
if (turn && !this.compactionWaiters.has(turn))
|
|
1276
2114
|
throw new HttpFailure(409, 'Session is still streaming; wait for the current turn to finish.');
|
|
1277
|
-
const
|
|
1278
|
-
if (
|
|
2115
|
+
const context = this.modelContext(s, stateRows).filter((m) => !turn || m._turn_id !== turn);
|
|
2116
|
+
if (sanitizeMessagesForApi(context).length < 4)
|
|
1279
2117
|
throw new HttpFailure(400, 'Not enough conversation to compress (need at least 4 messages).');
|
|
1280
|
-
return
|
|
2118
|
+
return context;
|
|
1281
2119
|
}
|
|
1282
2120
|
/** TAL-614: whether a manual compression would start now: `compressionTarget`'s gates, with `readOnly` the wire flag. */
|
|
1283
2121
|
canCompress(s, readOnly, stateRows) {
|
|
1284
2122
|
if (readOnly || isClaudeCodeSessionId(s.session_id))
|
|
1285
2123
|
return false;
|
|
1286
2124
|
try {
|
|
1287
|
-
this.
|
|
2125
|
+
this.compressionContext(s, stateRows);
|
|
1288
2126
|
return true;
|
|
1289
2127
|
}
|
|
1290
2128
|
catch (error) {
|
|
@@ -1294,18 +2132,19 @@ export class SessionService {
|
|
|
1294
2132
|
}
|
|
1295
2133
|
}
|
|
1296
2134
|
/** 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) {
|
|
2135
|
+
async handoffTarget(sid) {
|
|
2136
|
+
sid = sid.trim();
|
|
1298
2137
|
let local = null;
|
|
1299
2138
|
try {
|
|
1300
|
-
local = this.store.get(sid);
|
|
2139
|
+
local = (await this.store.get(sid));
|
|
1301
2140
|
}
|
|
1302
2141
|
catch { /* a foreign session without a WebUI record */ }
|
|
1303
2142
|
const profile = local?.profile ?? this.deps.activeProfile();
|
|
1304
2143
|
return { local, profile, dbPath: this.stateDbPath(profile) };
|
|
1305
2144
|
}
|
|
1306
2145
|
/** 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);
|
|
2146
|
+
async conversationRounds(sid, since) {
|
|
2147
|
+
const rounds = await stateDbSessionReadAsync(0, 'countConversationRounds', (await this.handoffTarget(sid)).dbPath, sid, since);
|
|
1309
2148
|
return { ok: true, rounds, threshold: CONVERSATION_ROUND_THRESHOLD, should_show: rounds >= CONVERSATION_ROUND_THRESHOLD };
|
|
1310
2149
|
}
|
|
1311
2150
|
/**
|
|
@@ -1314,40 +2153,41 @@ export class SessionService {
|
|
|
1314
2153
|
* back to a deterministic local summary rather than an error.
|
|
1315
2154
|
*/
|
|
1316
2155
|
async handoffSummary(sid, since) {
|
|
1317
|
-
this.
|
|
1318
|
-
|
|
1319
|
-
const
|
|
2156
|
+
const reads = this.sessionGateReads(sid.trim());
|
|
2157
|
+
sid = (await this.rejectViewOnly(sid, 'summarized', reads));
|
|
2158
|
+
const { local, profile, dbPath } = (await this.handoffTarget(sid));
|
|
2159
|
+
const rounds = await stateDbSessionReadAsync(0, 'countConversationRounds', dbPath, sid, since);
|
|
1320
2160
|
if (rounds < CONVERSATION_ROUND_THRESHOLD)
|
|
1321
2161
|
throw new HttpFailure(400, 'Not enough conversation rounds to generate a summary.');
|
|
1322
2162
|
// A running turn settles the transcript it started from, which would drop a card appended meanwhile.
|
|
1323
2163
|
if (local?.active_stream_id)
|
|
1324
2164
|
throw new HttpFailure(409, 'Session is still streaming; wait for the current turn to finish.');
|
|
1325
|
-
const all = stateDbSessionMessages
|
|
2165
|
+
const all = await stateDbSessionReadAsync([], 'stateDbSessionMessages', dbPath, sid, { stitch: true });
|
|
1326
2166
|
const messages = (since === null ? all : all.filter((m) => { const ts = stateDbTimestampSeconds(m.timestamp); return ts !== null && ts > since; })).slice(-50);
|
|
1327
2167
|
if (messages.length < 2)
|
|
1328
2168
|
throw new HttpFailure(400, 'Not enough messages to summarize.');
|
|
1329
2169
|
const sidecar = this.deps.sidecar?.() ?? null;
|
|
1330
2170
|
await ensureAgentRuntimeCurrent(sidecar);
|
|
1331
|
-
const meta = local ? null :
|
|
1332
|
-
const generated = await this.generateHandoffSummary(sidecar, profile, local, meta, messages);
|
|
2171
|
+
const meta = local ? null : (await reads.meta());
|
|
2172
|
+
const generated = await this.generateHandoffSummary(sidecar, profile, local, meta, messages, [...all, ...(local?.messages ?? []), ...(local?.context_messages ?? [])]);
|
|
1333
2173
|
const pick = (row, ...keys) => { for (const key of keys) {
|
|
1334
2174
|
const v = str(row?.[key]).trim();
|
|
1335
2175
|
if (v)
|
|
1336
2176
|
return v;
|
|
1337
2177
|
} return ''; };
|
|
1338
2178
|
// Python `get_cli_sessions` never listed WebUI's own state.db rows, so they name no channel.
|
|
1339
|
-
const cliRow = meta ??
|
|
2179
|
+
const cliRow = meta ?? (await reads.meta());
|
|
1340
2180
|
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
2181
|
await this.persistHandoffMarker(sid, profile, handoffMarker(sid, generated.summary, channel, rounds, generated.fallback, this.deps.now()));
|
|
1342
2182
|
return { ok: true, summary: generated.summary, message_count: messages.length, rounds, fallback: generated.fallback, ...(generated.warning ? { warning: generated.warning } : {}) };
|
|
1343
2183
|
}
|
|
1344
2184
|
/** The main model's summary at 700 tokens, once more at 1400 when it was cut off, else the local fallback. */
|
|
1345
|
-
async generateHandoffSummary(sidecar, profile, local, meta, messages) {
|
|
2185
|
+
async generateHandoffSummary(sidecar, profile, local, meta, messages, source) {
|
|
1346
2186
|
const fallback = (warning) => ({ summary: fallbackHandoffSummary(messages), fallback: true, ...(warning ? { warning: `Summary generation used local fallback: ${warning}` } : {}) });
|
|
1347
2187
|
if (!sidecar)
|
|
1348
2188
|
return fallback('the Agent sidecar is not running');
|
|
1349
2189
|
const [model, provider] = local ? this.deps.modelStateFromRequest(local.model, local.model_provider, local.model_provider) : [str(meta?.model).trim() || null, null];
|
|
1350
|
-
const prompt = [{ role: 'system', content: HANDOFF_SYSTEM_PROMPT }, { role: 'user', content: `Conversation transcript:\n${handoffTranscript(messages)}` }];
|
|
2190
|
+
const prompt = withDataUseRequirements([{ role: 'system', content: HANDOFF_SYSTEM_PROMPT }, { role: 'user', content: `Conversation transcript:\n${handoffTranscript(messages)}` }], source);
|
|
1351
2191
|
const ask = (maxTokens) => sidecar.call('aux.complete', { profile_home: this.deps.profileHome(profile), task: 'handoff_summary', messages: prompt, model: model ?? '', provider, max_tokens: maxTokens, temperature: 0.2 }, { timeoutMs: 60_000 });
|
|
1352
2192
|
try {
|
|
1353
2193
|
let result = await ask(700);
|
|
@@ -1372,7 +2212,8 @@ export class SessionService {
|
|
|
1372
2212
|
async persistHandoffMarker(sid, profile, marker) {
|
|
1373
2213
|
const card = messageHandoffPayload(marker);
|
|
1374
2214
|
const toStateDb = async () => {
|
|
1375
|
-
|
|
2215
|
+
await this.rejectViewOnly(sid, 'summarized');
|
|
2216
|
+
if (sameHandoff(handoffPayload(await stateDbSessionReadAsync(null, 'stateDbTailToolContent', this.stateDbPath(profile), sid)), card))
|
|
1376
2217
|
return true;
|
|
1377
2218
|
const sidecar = this.deps.sidecar?.();
|
|
1378
2219
|
if (!sidecar)
|
|
@@ -1384,35 +2225,41 @@ export class SessionService {
|
|
|
1384
2225
|
return false;
|
|
1385
2226
|
}
|
|
1386
2227
|
};
|
|
1387
|
-
await this.
|
|
2228
|
+
await this.withMutationLock(sid, async () => {
|
|
1388
2229
|
let live = null;
|
|
1389
2230
|
try {
|
|
1390
|
-
live = this.store.get(sid);
|
|
2231
|
+
live = (await this.store.get(sid));
|
|
1391
2232
|
}
|
|
1392
2233
|
catch { /* no WebUI record */ }
|
|
1393
2234
|
// A turn that started while the summary was generated would drop the card when it settles.
|
|
1394
2235
|
if (live?.active_stream_id)
|
|
1395
2236
|
throw new HttpFailure(409, 'Session is still streaming; wait for the current turn to finish.');
|
|
1396
|
-
const toLocal = () => {
|
|
2237
|
+
const toLocal = async () => {
|
|
2238
|
+
await this.rejectViewOnly(sid, 'summarized');
|
|
1397
2239
|
if (!live)
|
|
1398
2240
|
return false;
|
|
1399
2241
|
if (sameHandoff(messageHandoffPayload(live.messages.at(-1)), card))
|
|
1400
2242
|
return true;
|
|
1401
2243
|
live.messages.push(marker);
|
|
1402
|
-
this.store.save(live);
|
|
2244
|
+
await this.store.save(live);
|
|
1403
2245
|
return true;
|
|
1404
2246
|
};
|
|
1405
|
-
const messaging = this.isMessagingSession(sid);
|
|
1406
|
-
const saved = messaging ? [await toStateDb(), toLocal()].some(Boolean) : toLocal() || await toStateDb();
|
|
2247
|
+
const messaging = (await this.isMessagingSession(sid));
|
|
2248
|
+
const saved = messaging ? [await toStateDb(), (await toLocal())].some(Boolean) : (await toLocal()) || await toStateDb();
|
|
1407
2249
|
// The caller may switch away on success, so a card saved nowhere is an error, not a summary.
|
|
1408
2250
|
if (!saved)
|
|
1409
2251
|
throw new HttpFailure(503, 'The handoff summary could not be saved; please retry.');
|
|
1410
|
-
});
|
|
2252
|
+
}, { allowReadOnly: true });
|
|
2253
|
+
}
|
|
2254
|
+
/** The same live presentation for session detail and journal snapshot recovery. */
|
|
2255
|
+
currentRunStatus(sid, streamId) {
|
|
2256
|
+
const job = this.compressionJobs.get(sid);
|
|
2257
|
+
return job?.status === 'running' ? { phase: 'compressing' } : streamId ? (this.deps.runtime.runStatus?.(streamId) ?? null) : null;
|
|
1411
2258
|
}
|
|
1412
2259
|
/** The session's compression job; a deleted session's finished result is dropped rather than served. */
|
|
1413
|
-
compressionJob(sid) {
|
|
2260
|
+
async compressionJob(sid) {
|
|
1414
2261
|
try {
|
|
1415
|
-
this.store.get(sid, { metadataOnly: true });
|
|
2262
|
+
(await this.store.get(sid, { metadataOnly: true }));
|
|
1416
2263
|
}
|
|
1417
2264
|
catch {
|
|
1418
2265
|
this.compressionJobs.delete(sid);
|
|
@@ -1422,18 +2269,22 @@ export class SessionService {
|
|
|
1422
2269
|
}
|
|
1423
2270
|
/**
|
|
1424
2271
|
* 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
|
|
2272
|
+
* session lock refuse a result the session moved past (another stream, or a transcript changed) and install it as the
|
|
1426
2273
|
* 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.
|
|
2274
|
+
* The guards run again first: a stream may have started while the caller awaited the runtime check. TAL-1005: rows
|
|
2275
|
+
* the job's waiting turns added meanwhile (a prompt, a Stop) stay after the compressed context.
|
|
1428
2276
|
*/
|
|
1429
|
-
async compressSession(
|
|
1430
|
-
const {
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
2277
|
+
async compressSession(job) {
|
|
2278
|
+
const { session_id: sid, focus_topic: focusTopic } = job;
|
|
2279
|
+
const { s, history, context, stateRows: seenRows } = (await this.compressionTarget(sid));
|
|
2280
|
+
const ownRows = (m) => !s.active_stream_id || m._turn_id !== s.active_stream_id;
|
|
2281
|
+
// The display transcript and the model context must both still start where the compression found them, followed
|
|
2282
|
+
// only by the waiting turns' rows; the context is the exact history sent, so a state.db row that lands after this
|
|
2283
|
+
// read fails the commit instead of being dropped.
|
|
2284
|
+
// A turn still waiting to join (it started its own start meanwhile) joins this job, so it counts as one of its turns.
|
|
2285
|
+
const waited = (id) => job.turns.has(str(id)) || this.compactionWaiters.has(str(id));
|
|
2286
|
+
const sameRows = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
2287
|
+
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
2288
|
const sidecar = this.deps.sidecar?.();
|
|
1438
2289
|
if (!sidecar)
|
|
1439
2290
|
throw new HttpFailure(400, 'Compression failed: the Agent sidecar is not running');
|
|
@@ -1457,44 +2308,51 @@ export class SessionService {
|
|
|
1457
2308
|
throw new HttpFailure(result.status === 'lock_skipped' ? 409 : 400, result.message ?? 'Nothing to compress yet.');
|
|
1458
2309
|
const summary = result.summary ?? {};
|
|
1459
2310
|
let committed = false;
|
|
2311
|
+
let keptTurnRows = false;
|
|
1460
2312
|
try {
|
|
1461
|
-
const current = await this.
|
|
2313
|
+
const current = await this.withMutationLock(sid, async () => {
|
|
1462
2314
|
let live;
|
|
1463
2315
|
try {
|
|
1464
|
-
live = this.store.get(sid);
|
|
2316
|
+
live = (await this.store.get(sid));
|
|
1465
2317
|
}
|
|
1466
2318
|
catch {
|
|
1467
2319
|
throw new HttpFailure(404, 'Session not found');
|
|
1468
2320
|
}
|
|
1469
|
-
|
|
2321
|
+
// One state.db read serves the check, the kept display rows, and the boundary; every gate below decides after it.
|
|
2322
|
+
const read = await this.stateDbRead(live);
|
|
2323
|
+
const turn = live.active_stream_id;
|
|
2324
|
+
if (turn && !waited(turn))
|
|
1470
2325
|
throw new HttpFailure(409, 'Session stream state changed during compression; please retry.');
|
|
1471
|
-
// One state.db read serves the check, the kept display rows, and the boundary.
|
|
1472
|
-
const read = this.stateDbRead(live);
|
|
1473
2326
|
const stateRows = read.rows;
|
|
1474
|
-
|
|
2327
|
+
const tail = turnTail(this.modelContext(live, stateRows).filter((m) => !turn || m._turn_id !== turn), context);
|
|
2328
|
+
if (!tail || !turnTail(live.messages, s.messages))
|
|
1475
2329
|
throw new HttpFailure(409, 'Session was modified during compression; please retry.');
|
|
1476
2330
|
// Rows the sidecar returns without a timestamp take the newest one this compression saw, never the clock: the
|
|
1477
2331
|
// boundary then hides no state.db row the append-only merge would still show (it already drops rows at or
|
|
1478
2332
|
// before the newest local row), so a CLI row committed after the read follows the compressed context.
|
|
1479
|
-
const seen = [...
|
|
2333
|
+
const seen = [...s.messages, ...s.context_messages, ...seenRows].filter(ownRows).map((m) => Number(m.timestamp)).filter(Number.isFinite);
|
|
1480
2334
|
const stamp = seen.length ? Math.max(...seen) : this.deps.now();
|
|
1481
2335
|
const compressed = copyJson(result.messages);
|
|
1482
2336
|
for (const m of compressed)
|
|
1483
2337
|
m.timestamp ??= stamp;
|
|
1484
|
-
live.context_messages = compressed;
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
2338
|
+
live.context_messages = [...compressed, ...copyJson(tail)];
|
|
2339
|
+
keptTurnRows = tail.length > 0;
|
|
2340
|
+
if (!turn) {
|
|
2341
|
+
live.pending_user_message = null;
|
|
2342
|
+
live.pending_attachments = [];
|
|
2343
|
+
live.pending_started_at = null;
|
|
2344
|
+
live.pending_user_source = null;
|
|
2345
|
+
}
|
|
1490
2346
|
// The compressed history included any state.db-only continuation; the new boundary would hide those rows from
|
|
1491
2347
|
// the display, so the transcript keeps them before it is applied (and the anchor counts them).
|
|
1492
2348
|
const display = this.mergedTranscript(live, live.messages, stateRows);
|
|
1493
2349
|
if (display.length > live.messages.length)
|
|
1494
2350
|
live.messages = copyJson(display);
|
|
2351
|
+
// The anchor is the newest row the compression covered: never a waiting turn's own.
|
|
1495
2352
|
const visible = visibleMessagesForAnchor(live.messages);
|
|
1496
|
-
|
|
1497
|
-
live.
|
|
2353
|
+
const anchor = visible.findLast((m) => !waited(m._turn_id));
|
|
2354
|
+
live.compression_anchor_visible_idx = anchor ? visible.lastIndexOf(anchor) : null;
|
|
2355
|
+
live.compression_anchor_message_key = anchorMessageKey(anchor);
|
|
1498
2356
|
live.compression_anchor_summary = anchorSummary(summary, compressed);
|
|
1499
2357
|
live.compression_anchor_mode = 'manual';
|
|
1500
2358
|
// #4836: an intentional-shrink boundary, so append-only state.db reconciliation does not replay compressed rows.
|
|
@@ -1504,15 +2362,22 @@ export class SessionService {
|
|
|
1504
2362
|
this.markStateDbSeen(live, read, { boundary: true });
|
|
1505
2363
|
live.last_prompt_tokens = result.after_tokens;
|
|
1506
2364
|
live.post_compression_context_tokens_estimate = result.after_tokens;
|
|
1507
|
-
|
|
2365
|
+
// An automatic job is maintenance, not activity: it leaves the session's place in the list alone.
|
|
2366
|
+
await this.store.save(live, { touchUpdatedAt: !job.automatic });
|
|
1508
2367
|
// A backup from before the compression would restore the uncompressed context.
|
|
1509
2368
|
try {
|
|
1510
|
-
|
|
2369
|
+
await this.store.shrinkBackup.discard(sid);
|
|
1511
2370
|
}
|
|
1512
2371
|
catch { /* ignore */ }
|
|
1513
2372
|
return live;
|
|
1514
2373
|
});
|
|
1515
2374
|
committed = true;
|
|
2375
|
+
// The estimate covers the compressed rows only; a stopped turn's kept prompt is measured with them (a waiting turn
|
|
2376
|
+
// settles its own figures instead).
|
|
2377
|
+
if (keptTurnRows)
|
|
2378
|
+
await this.remeasureContext(sid);
|
|
2379
|
+
// Open chats refetch the session for its new context figures; the list order stays (no recency change).
|
|
2380
|
+
this.publish('session_compress', current.profile, sid);
|
|
1516
2381
|
// The cached turn agent still carries the uncompressed state; the next turn builds a fresh one (gateway parity).
|
|
1517
2382
|
// Awaited, so a turn sent right after the reply cannot reuse the old agent.
|
|
1518
2383
|
try {
|
|
@@ -1521,31 +2386,60 @@ export class SessionService {
|
|
|
1521
2386
|
catch (error) {
|
|
1522
2387
|
this.deps.log(`[webui] agent eviction after compression of ${sid} failed: ${error.message}`);
|
|
1523
2388
|
}
|
|
1524
|
-
return { ok: true, session: this.publicSession(current), summary, focus_topic: focusTopic };
|
|
2389
|
+
return { ok: true, session: (await this.publicSession(current)), summary, focus_topic: focusTopic };
|
|
1525
2390
|
}
|
|
1526
2391
|
finally {
|
|
1527
2392
|
// Second phase: the Agent's context-engine notification fires only for a result the session now holds.
|
|
1528
2393
|
if (result.commit_token) {
|
|
2394
|
+
const readOnly = committed ? (await this.readOnlyReason(s)) : null;
|
|
2395
|
+
if (readOnly)
|
|
2396
|
+
committed = false;
|
|
1529
2397
|
try {
|
|
1530
2398
|
await sidecar.call('chat.compress_finalize', { commit_token: result.commit_token, committed });
|
|
1531
2399
|
}
|
|
1532
2400
|
catch (error) {
|
|
1533
2401
|
this.deps.log(`[webui] compression finalize for ${sid} failed: ${error.message}`);
|
|
1534
2402
|
}
|
|
2403
|
+
if (readOnly)
|
|
2404
|
+
throw new HttpFailure(403, readOnly);
|
|
1535
2405
|
}
|
|
1536
2406
|
}
|
|
1537
2407
|
}
|
|
2408
|
+
/** Profile-scoped, best-effort lifecycle delivery; archive only notifies plugins. */
|
|
2409
|
+
async finalizeSession(sid, profile, reason, lineage) {
|
|
2410
|
+
const sidecar = this.deps.sidecar?.();
|
|
2411
|
+
if (!sidecar || this.deps.profileDeleting?.(profile))
|
|
2412
|
+
return [];
|
|
2413
|
+
const release = this.deps.profileActivity?.(profile);
|
|
2414
|
+
try {
|
|
2415
|
+
return (await sidecar.call('chat.finalize_session', {
|
|
2416
|
+
session_id: sid, profile_home: this.deps.profileHome(profile ?? this.deps.activeProfile()), reason,
|
|
2417
|
+
...(lineage ? { lineage } : {}),
|
|
2418
|
+
}, { timeoutMs: 12_000 })).messages;
|
|
2419
|
+
}
|
|
2420
|
+
catch {
|
|
2421
|
+
this.deps.log(`[webui] session lifecycle (${reason}) failed`);
|
|
2422
|
+
return [];
|
|
2423
|
+
}
|
|
2424
|
+
finally {
|
|
2425
|
+
release?.();
|
|
2426
|
+
}
|
|
2427
|
+
}
|
|
1538
2428
|
async clear(sid) {
|
|
1539
|
-
this.
|
|
1540
|
-
const s = this.mutationTarget(sid, 'modified');
|
|
1541
|
-
await this.
|
|
2429
|
+
await this.rejectViewOnly(sid, 'modified');
|
|
2430
|
+
const s = (await this.mutationTarget(sid, 'modified'));
|
|
2431
|
+
await this.withMutationLock(sid, async () => {
|
|
2432
|
+
const messages = await this.finalizeSession(sid, s.profile, 'clear');
|
|
2433
|
+
// The boundary covers every row this read saw, the session's finalization rows included.
|
|
2434
|
+
const read = await this.stateDbRead(s);
|
|
1542
2435
|
const hadMessages = s.messages.length > 0;
|
|
1543
2436
|
truncateSessionAtKeep(s, 0);
|
|
2437
|
+
s.messages = messages.map((content) => ({ role: 'local_notice', content, _notice_kind: 'session_finalize', timestamp: this.deps.now() }));
|
|
1544
2438
|
s.tool_calls = [];
|
|
1545
2439
|
if (s.parent_session_id) {
|
|
1546
2440
|
let parentIsSnapshot = false;
|
|
1547
2441
|
try {
|
|
1548
|
-
parentIsSnapshot = this.store.get(s.parent_session_id, { metadataOnly: true }).pre_compression_snapshot;
|
|
2442
|
+
parentIsSnapshot = (await this.store.get(s.parent_session_id, { metadataOnly: true })).pre_compression_snapshot;
|
|
1549
2443
|
}
|
|
1550
2444
|
catch {
|
|
1551
2445
|
parentIsSnapshot = false;
|
|
@@ -1563,22 +2457,23 @@ export class SessionService {
|
|
|
1563
2457
|
s.pending_user_source = null;
|
|
1564
2458
|
s.clear_generation = hadMessages ? randomUUID().replace(/-/g, '') : null;
|
|
1565
2459
|
applySessionTitleRename(s, 'Untitled');
|
|
1566
|
-
this.markStateDbSeen(s,
|
|
1567
|
-
this.store.save(s);
|
|
2460
|
+
this.markStateDbSeen(s, read, { boundary: true });
|
|
2461
|
+
await this.store.save(s);
|
|
1568
2462
|
if (hadMessages) {
|
|
1569
2463
|
try {
|
|
1570
|
-
|
|
2464
|
+
await this.store.shrinkBackup.discard(sid);
|
|
1571
2465
|
}
|
|
1572
2466
|
catch { /* ignore */ }
|
|
1573
2467
|
}
|
|
1574
2468
|
});
|
|
1575
|
-
this.deps.runtime.evictAgent(sid, true);
|
|
1576
|
-
return { ok: true, session: this.wireRow(s) };
|
|
2469
|
+
await this.deps.runtime.evictAgent(sid, true);
|
|
2470
|
+
return { ok: true, session: (await this.wireRow(s)) };
|
|
1577
2471
|
}
|
|
1578
2472
|
async retry(sid) {
|
|
1579
|
-
this.
|
|
1580
|
-
return this.
|
|
1581
|
-
const s = this.mutationTarget(sid, 'modified');
|
|
2473
|
+
await this.rejectViewOnly(sid, 'modified');
|
|
2474
|
+
return this.withMutationLock(sid, async () => {
|
|
2475
|
+
const s = (await this.mutationTarget(sid, 'modified'));
|
|
2476
|
+
const read = await this.stateDbRead(s);
|
|
1582
2477
|
const history = s.messages;
|
|
1583
2478
|
const lastUser = findLastUserIndex(history);
|
|
1584
2479
|
if (lastUser === null)
|
|
@@ -1594,15 +2489,16 @@ export class SessionService {
|
|
|
1594
2489
|
return { error: 'The last message has nothing to resend.' };
|
|
1595
2490
|
const removed = history.length - lastUser;
|
|
1596
2491
|
shrinkTo(s, lastUser);
|
|
1597
|
-
this.markStateDbSeen(s,
|
|
1598
|
-
this.store.save(s);
|
|
2492
|
+
this.markStateDbSeen(s, read, { boundary: true });
|
|
2493
|
+
await this.store.save(s);
|
|
1599
2494
|
return { ok: true, last_user_text: lastUserText, last_user_prompt: lastUserPrompt, last_user_attachments: lastUserAttachments, removed_count: removed };
|
|
1600
2495
|
});
|
|
1601
2496
|
}
|
|
1602
2497
|
async undo(sid) {
|
|
1603
|
-
this.
|
|
1604
|
-
return this.
|
|
1605
|
-
const s = this.mutationTarget(sid, 'modified');
|
|
2498
|
+
await this.rejectViewOnly(sid, 'modified');
|
|
2499
|
+
return this.withMutationLock(sid, async () => {
|
|
2500
|
+
const s = (await this.mutationTarget(sid, 'modified'));
|
|
2501
|
+
const read = await this.stateDbRead(s);
|
|
1606
2502
|
const history = s.messages;
|
|
1607
2503
|
const lastUser = findLastUserIndex(history);
|
|
1608
2504
|
if (lastUser === null)
|
|
@@ -1610,15 +2506,15 @@ export class SessionService {
|
|
|
1610
2506
|
const removedText = extractText(history[lastUser]?.content);
|
|
1611
2507
|
const removed = history.length - lastUser;
|
|
1612
2508
|
shrinkTo(s, lastUser);
|
|
1613
|
-
this.markStateDbSeen(s,
|
|
1614
|
-
this.store.save(s);
|
|
2509
|
+
this.markStateDbSeen(s, read, { boundary: true });
|
|
2510
|
+
await this.store.save(s);
|
|
1615
2511
|
const preview = removedText.length > 40 ? `${removedText.slice(0, 40)}...` : removedText;
|
|
1616
2512
|
return { ok: true, removed_count: removed, removed_preview: preview };
|
|
1617
2513
|
});
|
|
1618
2514
|
}
|
|
1619
|
-
duplicate(sid) {
|
|
1620
|
-
this.
|
|
1621
|
-
const session = this.store.load(sid);
|
|
2515
|
+
async duplicate(sid) {
|
|
2516
|
+
await this.rejectViewOnly(sid, 'duplicated');
|
|
2517
|
+
const session = (await this.store.load(sid));
|
|
1622
2518
|
if (!session)
|
|
1623
2519
|
throw new HttpFailure(404, 'Session not found');
|
|
1624
2520
|
const now = this.deps.now();
|
|
@@ -1626,7 +2522,7 @@ export class SessionService {
|
|
|
1626
2522
|
const watermark = session.truncation_watermark_compressed ? { truncation_watermark: session.truncation_watermark, truncation_boundary: session.truncation_boundary, truncation_watermark_compressed: true } : {};
|
|
1627
2523
|
const copied = new Session({
|
|
1628
2524
|
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,
|
|
2525
|
+
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
2526
|
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
2527
|
personality: session.personality, enabled_toolsets: session.enabled_toolsets, context_length: session.context_length, threshold_tokens: session.threshold_tokens,
|
|
1632
2528
|
...watermark, context_messages: copyJson(session.context_messages),
|
|
@@ -1634,16 +2530,15 @@ export class SessionService {
|
|
|
1634
2530
|
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
2531
|
created_at: now, updated_at: now,
|
|
1636
2532
|
}, { workspace: session.workspace, model: session.model });
|
|
1637
|
-
this.store.touch(copied);
|
|
1638
|
-
this.store.save(copied);
|
|
2533
|
+
await this.store.touch(copied);
|
|
2534
|
+
await this.store.save(copied);
|
|
1639
2535
|
this.publish('session_duplicate', copied.profile, copied.session_id);
|
|
1640
|
-
return { session: this.publicSession(copied) };
|
|
2536
|
+
return { session: (await this.publicSession(copied)) };
|
|
1641
2537
|
}
|
|
1642
|
-
branch(sid, body) {
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
if (this.isReadOnly(source)) {
|
|
2538
|
+
async branch(sid, body) {
|
|
2539
|
+
await this.rejectViewOnly(sid, 'branched');
|
|
2540
|
+
const source = (await this.get404(sid));
|
|
2541
|
+
if ((await this.isReadOnly(source))) {
|
|
1647
2542
|
if (str(source.source_tag || source.raw_source).trim().toLowerCase() !== 'cron')
|
|
1648
2543
|
throw new HttpFailure(403, 'Read-only sessions cannot be branched from WebUI');
|
|
1649
2544
|
source.branchSourceReadonly = true;
|
|
@@ -1659,46 +2554,46 @@ export class SessionService {
|
|
|
1659
2554
|
const customTitle = body.title ? str(body.title).trim().slice(0, 80) || null : null;
|
|
1660
2555
|
if (!source.branchSourceReadonly) {
|
|
1661
2556
|
try {
|
|
1662
|
-
this.store.save(source);
|
|
2557
|
+
(await this.store.save(source));
|
|
1663
2558
|
}
|
|
1664
2559
|
catch { /* ignore */ }
|
|
1665
2560
|
}
|
|
1666
2561
|
// Python: `keep_count` indexes the merged display transcript, never the raw sidecar array.
|
|
1667
|
-
const
|
|
2562
|
+
const stateRows = await this.stateDbRows(source);
|
|
2563
|
+
const sourceMessages = this.mergedTranscript(source, source.messages, stateRows);
|
|
1668
2564
|
const forked = keepCount !== null ? sourceMessages.slice(0, keepCount) : [...sourceMessages];
|
|
1669
2565
|
const title = customTitle ?? `${source.title || 'Untitled'} (fork)`;
|
|
1670
2566
|
const forkKeep = keepCount ?? sourceMessages.length;
|
|
1671
2567
|
// The branch's model context must carry the retained state.db rows too: the new id has no state.db rows of its
|
|
1672
2568
|
// own, and the runner prefers a non-empty context over the displayed messages.
|
|
1673
|
-
const
|
|
1674
|
-
const sourceContext = stateRows.length ? this.mergedTranscript(source, source.context_messages.length ? source.context_messages : source.messages) : source.context_messages;
|
|
2569
|
+
const sourceContext = stateRows.length ? this.mergedTranscript(source, source.context_messages.length ? source.context_messages : source.messages, stateRows) : source.context_messages;
|
|
1675
2570
|
const forkedContext = copyJson(truncateContextForDisplayKeep(sourceContext, sourceMessages, forkKeep));
|
|
1676
2571
|
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,
|
|
2572
|
+
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
2573
|
personality: source.personality, enabled_toolsets: source.enabled_toolsets, context_length: source.context_length, threshold_tokens: source.threshold_tokens,
|
|
1679
2574
|
context_messages: forkedContext, gateway_routing: copyJson(source.gateway_routing), context_engine: source.context_engine, context_engine_state: copyJson(source.context_engine_state),
|
|
1680
2575
|
parent_session_id: source.session_id, session_source: 'fork',
|
|
1681
2576
|
}, { workspace: source.workspace, model: source.model });
|
|
1682
|
-
this.store.touch(branch);
|
|
2577
|
+
await this.store.touch(branch);
|
|
1683
2578
|
if (forked.length) {
|
|
1684
|
-
this.store.save(branch);
|
|
2579
|
+
await this.store.save(branch);
|
|
1685
2580
|
this.publish('session_branch', branch.profile, branch.session_id);
|
|
1686
2581
|
}
|
|
1687
2582
|
return { session_id: branch.session_id, title, parent_session_id: source.session_id };
|
|
1688
2583
|
}
|
|
1689
2584
|
/** Python `read_session_lineage_report` on the active profile's state.db. */
|
|
1690
2585
|
lineageReport(sid) {
|
|
1691
|
-
return stateDbLineageReport
|
|
2586
|
+
return stateDbSessionReadAsync(emptyLineageReport(sid.trim()), 'stateDbLineageReport', this.stateDbPath(null), sid);
|
|
1692
2587
|
}
|
|
1693
2588
|
/** 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 };
|
|
2589
|
+
async recoveryDeps() {
|
|
2590
|
+
return { store: this.store, stateDbPath: this.stateDbPath(null), profile: this.deps.activeProfile(), stampingSince: await this.deps.recoveryStampingSince(), isReadOnly: async (sid, profile) => Boolean((await this.ownerLocked([{ session_id: sid, profile }]))[0]?.read_only), log: this.deps.log };
|
|
1696
2591
|
}
|
|
1697
|
-
recoveryAudit() {
|
|
1698
|
-
return auditSessionRecovery(this.recoveryDeps());
|
|
2592
|
+
async recoveryAudit() {
|
|
2593
|
+
return (await auditSessionRecovery(await this.recoveryDeps()));
|
|
1699
2594
|
}
|
|
1700
|
-
recoveryRepairSafe() {
|
|
1701
|
-
return repairSafeSessionRecovery(this.recoveryDeps());
|
|
2595
|
+
async recoveryRepairSafe() {
|
|
2596
|
+
return repairSafeSessionRecovery(await this.recoveryDeps());
|
|
1702
2597
|
}
|
|
1703
2598
|
/**
|
|
1704
2599
|
* Python `_handle_session_compression_recovery_start`: open a compression-exhausted session's focused continuation, an
|
|
@@ -1706,18 +2601,18 @@ export class SessionService {
|
|
|
1706
2601
|
* already started from it in the same profile; the source's lock makes concurrent starts converge on one child.
|
|
1707
2602
|
*/
|
|
1708
2603
|
async compressionRecoveryStart(sid) {
|
|
1709
|
-
if (this.isSubagentViewOnly(sid))
|
|
2604
|
+
if ((await this.isSubagentViewOnly(sid)))
|
|
1710
2605
|
throw new HttpFailure(400, 'Subagent sessions are view-only and cannot start compression recovery from WebUI');
|
|
1711
2606
|
const action = COMPRESSION_RECOVERY_ACTION_START_FOCUSED;
|
|
1712
2607
|
let created = false;
|
|
1713
|
-
const child = await this.
|
|
2608
|
+
const child = await this.withMutationLock(sid, async () => {
|
|
1714
2609
|
// Validated under the lock: a delete that won it leaves no source to fork.
|
|
1715
|
-
const source = this.store.get(sid);
|
|
2610
|
+
const source = (await this.store.get(sid));
|
|
1716
2611
|
if (!this.visibleToActiveProfile(source.profile))
|
|
1717
2612
|
throw new HttpFailure(404, 'Session not found');
|
|
1718
2613
|
if (!compressionRecoveryPayload(source))
|
|
1719
2614
|
throw new HttpFailure(409, 'Session does not have a compression recovery action.');
|
|
1720
|
-
const existing = this.compressionRecoveryChild(sid, action, source.profile);
|
|
2615
|
+
const existing = (await this.compressionRecoveryChild(sid, action, source.profile));
|
|
1721
2616
|
if (existing)
|
|
1722
2617
|
return existing;
|
|
1723
2618
|
const base = (source.title || 'Untitled').trim() || 'Untitled';
|
|
@@ -1726,54 +2621,55 @@ export class SessionService {
|
|
|
1726
2621
|
project_id: source.project_id, profile: source.profile, session_source: 'fork', personality: source.personality, enabled_toolsets: copyJson(source.enabled_toolsets),
|
|
1727
2622
|
context_length: source.context_length, threshold_tokens: source.threshold_tokens, gateway_routing: copyJson(source.gateway_routing), gateway_routing_history: copyJson(source.gateway_routing_history),
|
|
1728
2623
|
parent_session_id: source.session_id, worktree_path: source.worktree_path, worktree_branch: source.worktree_branch, worktree_repo_root: source.worktree_repo_root, worktree_created_at: source.worktree_created_at,
|
|
2624
|
+
...(source.extra[WORKTREE_RETAINED_KEY] === true ? { [WORKTREE_RETAINED_KEY]: true } : {}),
|
|
1729
2625
|
compression_recovery_source_session_id: sid, compression_recovery_action: action,
|
|
1730
2626
|
// An empty model-facing transcript: the focused follow-up must not replay the exhausted context.
|
|
1731
2627
|
messages: [], context_messages: [], composer_draft: { text: '', files: [] },
|
|
1732
2628
|
}, { workspace: source.workspace, model: source.model });
|
|
1733
2629
|
try {
|
|
1734
|
-
this.store.save(fresh);
|
|
2630
|
+
await this.store.save(fresh);
|
|
1735
2631
|
}
|
|
1736
2632
|
catch (error) {
|
|
1737
2633
|
throw new HttpFailure(500, `Failed to start compression recovery: ${sanitizePaths(error)}`);
|
|
1738
2634
|
}
|
|
1739
|
-
this.store.touch(fresh);
|
|
2635
|
+
await this.store.touch(fresh);
|
|
1740
2636
|
created = true;
|
|
1741
2637
|
return fresh;
|
|
1742
|
-
});
|
|
2638
|
+
}, { allowReadOnly: true });
|
|
1743
2639
|
if (created)
|
|
1744
2640
|
this.publish('session_compression_recovery', child.profile, child.session_id);
|
|
1745
2641
|
return {
|
|
1746
|
-
ok: true, session: this.publicSession(child), source_session_id: sid, recommended_recovery_action: action,
|
|
2642
|
+
ok: true, session: (await this.publicSession(child)), source_session_id: sid, recommended_recovery_action: action,
|
|
1747
2643
|
message: created ? 'Started a focused continuation. Describe the next narrow task to continue.' : 'Opened the existing focused continuation for this exhausted session.',
|
|
1748
2644
|
};
|
|
1749
2645
|
}
|
|
1750
2646
|
/** Python `find_compression_recovery_session`: the oldest same-profile continuation started from `sid`, cached or persisted. */
|
|
1751
|
-
compressionRecoveryChild(sid, action, profile) {
|
|
2647
|
+
async compressionRecoveryChild(sid, action, profile) {
|
|
1752
2648
|
const matches = (s) => s.compression_recovery_source_session_id === sid && s.compression_recovery_action === action && this.deps.profilesMatch(s.profile, profile);
|
|
1753
2649
|
const found = new Map();
|
|
1754
2650
|
for (const s of this.store.sessions.values())
|
|
1755
2651
|
if (matches(s))
|
|
1756
2652
|
found.set(s.session_id, s);
|
|
1757
|
-
for (const id of this.store.persistedIds()) {
|
|
2653
|
+
for (const id of (await this.store.persistedIds())) {
|
|
1758
2654
|
if (found.has(id) || this.store.sessions.has(id))
|
|
1759
2655
|
continue;
|
|
1760
2656
|
try {
|
|
1761
|
-
const meta = this.store.get(id, { metadataOnly: true, promote: false, cacheOnMiss: false });
|
|
2657
|
+
const meta = (await this.store.get(id, { metadataOnly: true, promote: false, cacheOnMiss: false }));
|
|
1762
2658
|
if (matches(meta))
|
|
1763
2659
|
found.set(id, meta);
|
|
1764
2660
|
}
|
|
1765
2661
|
catch { /* unreadable: not a match */ }
|
|
1766
2662
|
}
|
|
1767
2663
|
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;
|
|
2664
|
+
return oldest ? (await this.store.get(oldest.session_id)) : null;
|
|
1769
2665
|
}
|
|
1770
2666
|
/** 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) {
|
|
2667
|
+
async activeRunBlocking(sid) {
|
|
1772
2668
|
const live = this.deps.runtime.activeRunStream(sid);
|
|
1773
2669
|
if (live)
|
|
1774
2670
|
return live;
|
|
1775
2671
|
try {
|
|
1776
|
-
const candidate = str(this.store.get(sid, { metadataOnly: true, promote: false, cacheOnMiss: false }).active_stream_id).trim();
|
|
2672
|
+
const candidate = str((await this.store.get(sid, { metadataOnly: true, promote: false, cacheOnMiss: false })).active_stream_id).trim();
|
|
1777
2673
|
if (candidate && this.deps.runtime.activeStreamIds.has(candidate))
|
|
1778
2674
|
return candidate;
|
|
1779
2675
|
}
|
|
@@ -1785,116 +2681,142 @@ export class SessionService {
|
|
|
1785
2681
|
throw new HttpFailure(400, 'session_id is required');
|
|
1786
2682
|
if (!isSafeSessionId(sid))
|
|
1787
2683
|
throw new HttpFailure(400, 'Invalid session_id');
|
|
2684
|
+
const reads = this.sessionGateReads(sid);
|
|
2685
|
+
await this.rejectViewOnly(sid, 'deleted', reads);
|
|
1788
2686
|
// Python: a read-only import (persisted sidecar flag, sidebar metadata, or a foreign state.db owner such as a
|
|
1789
2687
|
// 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))
|
|
2688
|
+
const orphaned = (await this.isOrphanedImport(sid, reads));
|
|
2689
|
+
if (!orphaned && (await this.isReadOnlyImport(sid, reads)))
|
|
1792
2690
|
throw new HttpFailure(400, 'Read-only imported sessions cannot be deleted from WebUI');
|
|
1793
|
-
if (this.isSubagentViewOnly(sid))
|
|
2691
|
+
if ((await this.isSubagentViewOnly(sid, reads)))
|
|
1794
2692
|
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 }));
|
|
2693
|
+
const retained = (await (async () => { try {
|
|
2694
|
+
return worktreeRetainedPayload((await this.store.get(sid, { metadataOnly: true })));
|
|
1797
2695
|
}
|
|
1798
2696
|
catch {
|
|
1799
2697
|
return {};
|
|
1800
|
-
} })();
|
|
2698
|
+
} })());
|
|
1801
2699
|
let eventProfile = null;
|
|
1802
|
-
let hadSidecar = true;
|
|
1803
2700
|
try {
|
|
1804
|
-
eventProfile = this.store.get(sid, { metadataOnly: true }).profile;
|
|
2701
|
+
eventProfile = (await this.store.get(sid, { metadataOnly: true })).profile;
|
|
1805
2702
|
}
|
|
1806
2703
|
catch {
|
|
1807
2704
|
eventProfile = null;
|
|
1808
|
-
hadSidecar = false;
|
|
1809
2705
|
}
|
|
1810
2706
|
// 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
|
-
|
|
2707
|
+
const isMessaging = (await this.isMessagingSession(sid, reads));
|
|
2708
|
+
const knownIds = !isMessaging && !orphaned ? await stateDbSessionReadAsync([sid], 'stateDbCompressionLineage', this.stateDbPath(eventProfile), sid) : [sid];
|
|
2709
|
+
const assertIdle = async () => { for (const id of knownIds)
|
|
2710
|
+
if (await this.activeRunBlocking(id))
|
|
2711
|
+
throw new HttpFailure(409, 'Session has an active run; stop it before deleting'); };
|
|
2712
|
+
await assertIdle();
|
|
2713
|
+
let stateDbCleanupFailed = false;
|
|
2714
|
+
let removedIds = [sid];
|
|
1814
2715
|
try {
|
|
1815
|
-
await this.
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
//
|
|
1819
|
-
|
|
1820
|
-
|
|
2716
|
+
await this.withMutationLock(sid, async () => {
|
|
2717
|
+
await assertIdle();
|
|
2718
|
+
// TAL-792: state.db goes first, so another Agent process running or compressing the session refuses the delete
|
|
2719
|
+
// before anything of Talaria's is removed. Python: the Agent/CLI transcript goes too (else a claimable CLI row
|
|
2720
|
+
// resurfaces in the sidebar), but a messaging channel's memory is never erased from the WebUI.
|
|
2721
|
+
if (!isMessaging && !orphaned) {
|
|
2722
|
+
const result = await this.deleteStateDbRows(eventProfile, sid);
|
|
2723
|
+
stateDbCleanupFailed = !result.ok;
|
|
2724
|
+
removedIds = [...new Set([sid, ...(result.deleted_ids ?? (result.ok ? knownIds : []))])];
|
|
1821
2725
|
}
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
2726
|
+
if (removedIds.some((id) => !isSafeSessionId(id)))
|
|
2727
|
+
throw new HttpFailure(503, 'Session cleanup could not finish because the Agent returned invalid session IDs.');
|
|
2728
|
+
removedIds = (await filterAsync(removedIds, async (id) => {
|
|
2729
|
+
try {
|
|
2730
|
+
return this.deps.profilesMatch((await this.store.get(id, { metadataOnly: true })).profile, eventProfile ?? this.deps.activeProfile());
|
|
2731
|
+
}
|
|
2732
|
+
catch {
|
|
2733
|
+
return true;
|
|
2734
|
+
}
|
|
2735
|
+
}));
|
|
2736
|
+
for (const id of removedIds) {
|
|
2737
|
+
// Revoke before removing each persisted alias; UI cleanup follows the Agent's committed target set.
|
|
2738
|
+
try {
|
|
2739
|
+
const session = (await this.store.get(id, { metadataOnly: true }));
|
|
2740
|
+
if (!Object.keys(retained).length)
|
|
2741
|
+
Object.assign(retained, worktreeRetainedPayload(session));
|
|
2742
|
+
await this.deps.shares.revoke(session);
|
|
2743
|
+
await this.deps.clearRelayCompletions?.(id, session.profile);
|
|
2744
|
+
}
|
|
2745
|
+
catch (error) {
|
|
2746
|
+
if (!(error instanceof SessionNotFound))
|
|
2747
|
+
throw error;
|
|
2748
|
+
}
|
|
2749
|
+
const finalize = id === sid && !(await this.store.wasDeleted(id));
|
|
2750
|
+
if (!(await this.store.delete(id)))
|
|
2751
|
+
throw new HttpFailure(500, 'Failed to delete session data');
|
|
2752
|
+
if (finalize)
|
|
2753
|
+
await this.finalizeSession(sid, eventProfile, 'delete');
|
|
1825
2754
|
}
|
|
1826
|
-
|
|
1827
|
-
throw new HttpFailure(500, 'Failed to delete session data');
|
|
1828
|
-
}, { timeoutMs: 5000 });
|
|
2755
|
+
}, { timeoutMs: 5000, allowReadOnly: orphaned });
|
|
1829
2756
|
}
|
|
1830
2757
|
catch (error) {
|
|
1831
2758
|
if (error instanceof SessionBusy)
|
|
1832
2759
|
throw new HttpFailure(503, 'Session busy, try again');
|
|
1833
2760
|
throw error;
|
|
1834
2761
|
}
|
|
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 = [];
|
|
2762
|
+
for (const id of removedIds) {
|
|
2763
|
+
await this.deps.runtime.evictAgent(id, true);
|
|
1852
2764
|
try {
|
|
1853
|
-
|
|
2765
|
+
rmSync(this.deps.attachmentDir(id), { recursive: true, force: true });
|
|
1854
2766
|
}
|
|
1855
|
-
catch {
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
try {
|
|
1860
|
-
if (!(await this.deps.runtime.deleteCliSession(eventProfile, id)))
|
|
1861
|
-
stateDbCleanupFailed = true;
|
|
1862
|
-
}
|
|
1863
|
-
catch {
|
|
1864
|
-
stateDbCleanupFailed = true;
|
|
1865
|
-
}
|
|
2767
|
+
catch { /* ignore */ }
|
|
2768
|
+
// Python `delete_run_journal` (#3802): a deleted session leaves no replayable run journal behind.
|
|
2769
|
+
try {
|
|
2770
|
+
(await this.deps.journal?.deleteSession(id));
|
|
1866
2771
|
}
|
|
2772
|
+
catch { /* ignore */ }
|
|
2773
|
+
this.deps.runtime.closeTerminal(id);
|
|
1867
2774
|
}
|
|
1868
2775
|
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
2776
|
return { ok: true, state_db_cleanup_failed: stateDbCleanupFailed, ...retained };
|
|
1873
2777
|
}
|
|
1874
|
-
cleanup
|
|
2778
|
+
/** The committed Agent deletion targets and cleanup outcome; throws 409 `refused` while an Agent process holds a row. */
|
|
2779
|
+
async deleteStateDbRows(profile, sid) {
|
|
2780
|
+
// TAL-529: the compression continuations go too; the Agent's delete keeps and detaches them otherwise. TAL-792: the
|
|
2781
|
+
// sidecar resolves them inside its delete transaction, so a guard on any of them refuses before anything is removed.
|
|
2782
|
+
let result;
|
|
2783
|
+
try {
|
|
2784
|
+
result = await this.deps.runtime.deleteCliSession(profile, sid);
|
|
2785
|
+
}
|
|
2786
|
+
catch {
|
|
2787
|
+
return { ok: false };
|
|
2788
|
+
}
|
|
2789
|
+
if (result.refused) {
|
|
2790
|
+
const message = result.refused === 'runtime_history_unsupported'
|
|
2791
|
+
? '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.'
|
|
2792
|
+
: 'In use by another Agent process. Try again later.';
|
|
2793
|
+
throw new HttpFailure(409, message, { refused: result.refused });
|
|
2794
|
+
}
|
|
2795
|
+
return result;
|
|
2796
|
+
}
|
|
2797
|
+
async cleanup(zeroOnly) {
|
|
1875
2798
|
let cleaned = 0;
|
|
1876
2799
|
const removed = new Set();
|
|
1877
|
-
for (const s of this.store.scanAll()) {
|
|
2800
|
+
for (const s of (await this.store.scanAll())) {
|
|
1878
2801
|
const shouldDelete = zeroOnly ? s.messages.length === 0 : s.title === 'Untitled' && s.messages.length === 0;
|
|
1879
|
-
if (!shouldDelete)
|
|
2802
|
+
if (!shouldDelete || (await this.isReadOnly(s)))
|
|
1880
2803
|
continue;
|
|
1881
2804
|
// A cleared session keeps its share; keep the session (and its revoke route) if the share cannot be revoked.
|
|
1882
2805
|
try {
|
|
1883
|
-
this.deps.shares.revoke(s);
|
|
2806
|
+
(await this.deps.shares.revoke(s));
|
|
1884
2807
|
}
|
|
1885
2808
|
catch {
|
|
1886
2809
|
continue;
|
|
1887
2810
|
}
|
|
1888
2811
|
this.store.sessions.delete(s.session_id);
|
|
1889
|
-
|
|
2812
|
+
await this.store.delete(s.session_id, { tombstone: false });
|
|
1890
2813
|
cleaned += 1;
|
|
1891
2814
|
removed.add(s.session_id);
|
|
1892
2815
|
}
|
|
1893
|
-
this.store.invalidatePersistedIds();
|
|
1894
2816
|
let rewroteIndex = false;
|
|
1895
2817
|
try {
|
|
1896
|
-
const rows = this.store.readIndexEntries();
|
|
1897
|
-
const live = this.store.persistedIds();
|
|
2818
|
+
const rows = (await this.store.readIndexEntries());
|
|
2819
|
+
const live = (await this.store.persistedIds());
|
|
1898
2820
|
const survivors = rows.filter((entry) => {
|
|
1899
2821
|
const id = str(entry.session_id);
|
|
1900
2822
|
if (!id || live.has(id) || this.store.sessions.has(id))
|
|
@@ -1905,20 +2827,20 @@ export class SessionService {
|
|
|
1905
2827
|
return false;
|
|
1906
2828
|
});
|
|
1907
2829
|
if (survivors.length < rows.length) {
|
|
1908
|
-
this.store.writeIndex();
|
|
2830
|
+
await this.store.writeIndex();
|
|
1909
2831
|
rewroteIndex = true;
|
|
1910
2832
|
}
|
|
1911
2833
|
}
|
|
1912
2834
|
catch { /* corrupt index is rebuilt below */ }
|
|
1913
2835
|
if (removed.size && !rewroteIndex) {
|
|
1914
2836
|
try {
|
|
1915
|
-
this.store.writeIndex();
|
|
2837
|
+
(await this.store.writeIndex());
|
|
1916
2838
|
}
|
|
1917
2839
|
catch { /* ignore */ }
|
|
1918
2840
|
}
|
|
1919
2841
|
return { ok: true, cleaned };
|
|
1920
2842
|
}
|
|
1921
|
-
import(body) {
|
|
2843
|
+
async import(body) {
|
|
1922
2844
|
const messages = stripPublicInternalFields(body.messages, { messageRecords: true });
|
|
1923
2845
|
if (!Array.isArray(messages))
|
|
1924
2846
|
throw new HttpFailure(400, 'JSON must contain a "messages" array');
|
|
@@ -1927,48 +2849,48 @@ export class SessionService {
|
|
|
1927
2849
|
throw new HttpFailure(400, 'JSON "tool_calls" must be an array');
|
|
1928
2850
|
let workspace;
|
|
1929
2851
|
try {
|
|
1930
|
-
workspace = this.deps.workspaces.resolveTrusted(typeof body.workspace === 'string' ? body.workspace : null);
|
|
2852
|
+
workspace = (await this.deps.workspaces.resolveTrusted(typeof body.workspace === 'string' ? body.workspace : null));
|
|
1931
2853
|
}
|
|
1932
2854
|
catch (error) {
|
|
1933
2855
|
throw new HttpFailure(400, error.message);
|
|
1934
2856
|
}
|
|
1935
|
-
const defaults = this.store.deps.defaults(this.deps.activeProfile());
|
|
2857
|
+
const defaults = (await this.store.deps.defaults(this.deps.activeProfile()));
|
|
1936
2858
|
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
2859
|
s.pinned = Boolean(body.pinned);
|
|
1938
|
-
this.store.touch(s);
|
|
1939
|
-
this.store.save(s);
|
|
2860
|
+
await this.store.touch(s);
|
|
2861
|
+
await this.store.save(s);
|
|
1940
2862
|
this.publish('session_import');
|
|
1941
|
-
return { ok: true, session: this.publicSession(s) };
|
|
2863
|
+
return { ok: true, session: (await this.publicSession(s)) };
|
|
1942
2864
|
}
|
|
1943
2865
|
// ── drafts ───────────────────────────────────────────────────────────────
|
|
1944
|
-
readDraft(sid) {
|
|
2866
|
+
async readDraft(sid) {
|
|
1945
2867
|
if (!sid)
|
|
1946
2868
|
throw new HttpFailure(400, 'session_id is required');
|
|
1947
|
-
let hasSidecar;
|
|
1948
2869
|
try {
|
|
1949
|
-
|
|
2870
|
+
this.deps.drafts.validate(sid);
|
|
1950
2871
|
}
|
|
1951
2872
|
catch {
|
|
1952
2873
|
throw new HttpFailure(400, 'Invalid session_id');
|
|
1953
2874
|
}
|
|
2875
|
+
const hasSidecar = await this.deps.drafts.exists(sid);
|
|
1954
2876
|
if (hasSidecar) {
|
|
1955
2877
|
const cached = this.store.sessions.get(sid);
|
|
1956
|
-
if (!cached && !existsSessionFile(this.store, sid))
|
|
2878
|
+
if (!cached && !(await existsSessionFile(this.store, sid)))
|
|
1957
2879
|
throw new HttpFailure(404, 'Session not found');
|
|
1958
|
-
const [draft, version] = this.deps.drafts.readState(sid);
|
|
2880
|
+
const [draft, version] = (await this.deps.drafts.readState(sid));
|
|
1959
2881
|
if (cached)
|
|
1960
2882
|
cached.composer_draft = draft;
|
|
1961
2883
|
return { draft, draft_version: version };
|
|
1962
2884
|
}
|
|
1963
|
-
const s = this.get404(sid, { metadataOnly: true });
|
|
1964
|
-
const [draft, version] = this.deps.drafts.readState(sid, s.composer_draft);
|
|
2885
|
+
const s = (await this.get404(sid, { metadataOnly: true }));
|
|
2886
|
+
const [draft, version] = (await this.deps.drafts.readState(sid, s.composer_draft));
|
|
1965
2887
|
return { draft, draft_version: version };
|
|
1966
2888
|
}
|
|
1967
2889
|
async writeDraft(body) {
|
|
1968
2890
|
const sid = str(body.session_id);
|
|
1969
2891
|
if (!sid)
|
|
1970
2892
|
throw new HttpFailure(400, 'Missing required field(s): session_id');
|
|
1971
|
-
if (this.isSubagentViewOnly(sid))
|
|
2893
|
+
if ((await this.isSubagentViewOnly(sid)))
|
|
1972
2894
|
throw new HttpFailure(400, 'Subagent sessions are view-only and cannot store a draft from WebUI');
|
|
1973
2895
|
let text = body.text;
|
|
1974
2896
|
let files = body.files;
|
|
@@ -1981,7 +2903,7 @@ export class SessionService {
|
|
|
1981
2903
|
if (Array.isArray(files) && files.length > 50)
|
|
1982
2904
|
files = files.slice(0, 50);
|
|
1983
2905
|
try {
|
|
1984
|
-
this.deps.drafts.
|
|
2906
|
+
this.deps.drafts.validate(sid);
|
|
1985
2907
|
}
|
|
1986
2908
|
catch {
|
|
1987
2909
|
throw new HttpFailure(400, 'Invalid session_id');
|
|
@@ -1998,18 +2920,18 @@ export class SessionService {
|
|
|
1998
2920
|
if (BigInt(draftVersion) > maxVersion)
|
|
1999
2921
|
throw new HttpFailure(400, 'draft_version is too far in the future');
|
|
2000
2922
|
}
|
|
2001
|
-
return this.store.withLock(sid, () => {
|
|
2923
|
+
return this.store.withLock(sid, async () => {
|
|
2002
2924
|
let s = this.store.sessions.get(sid) ?? null;
|
|
2003
2925
|
if (s && s.session_id !== sid)
|
|
2004
2926
|
s = null;
|
|
2005
|
-
const sessionExists = existsSessionFile(this.store, sid);
|
|
2927
|
+
const sessionExists = (await existsSessionFile(this.store, sid));
|
|
2006
2928
|
if (!s) {
|
|
2007
2929
|
if (!sessionExists)
|
|
2008
2930
|
throw new HttpFailure(404, 'Session not found');
|
|
2009
|
-
if (!this.deps.drafts.exists(sid))
|
|
2010
|
-
s = this.get404(sid, { metadataOnly: true });
|
|
2931
|
+
if (!(await this.deps.drafts.exists(sid)))
|
|
2932
|
+
s = (await this.get404(sid, { metadataOnly: true }));
|
|
2011
2933
|
}
|
|
2012
|
-
const [currentDraft, currentVersion] = this.deps.drafts.readState(sid, s?.composer_draft);
|
|
2934
|
+
const [currentDraft, currentVersion] = (await this.deps.drafts.readState(sid, s?.composer_draft));
|
|
2013
2935
|
const next = { ...currentDraft };
|
|
2014
2936
|
if (text !== null && text !== undefined)
|
|
2015
2937
|
next.text = text;
|
|
@@ -2032,10 +2954,10 @@ export class SessionService {
|
|
|
2032
2954
|
throw new HttpFailure(404, 'Session not found');
|
|
2033
2955
|
if (!sessionExists && s) {
|
|
2034
2956
|
s.composer_draft = next;
|
|
2035
|
-
this.store.save(s, { touchUpdatedAt: false });
|
|
2957
|
+
await this.store.save(s, { touchUpdatedAt: false });
|
|
2036
2958
|
}
|
|
2037
2959
|
try {
|
|
2038
|
-
saved = this.deps.drafts.write(sid, next, draftVersion);
|
|
2960
|
+
saved = (await this.deps.drafts.write(sid, next, draftVersion));
|
|
2039
2961
|
}
|
|
2040
2962
|
catch (error) {
|
|
2041
2963
|
if (error instanceof DraftVersionConflict)
|
|
@@ -2060,7 +2982,7 @@ export class SessionService {
|
|
|
2060
2982
|
throw new HttpFailure(400, 'Missing required field(s): scene');
|
|
2061
2983
|
let s;
|
|
2062
2984
|
try {
|
|
2063
|
-
s = this.getForMutation(sid);
|
|
2985
|
+
s = (await this.getForMutation(sid));
|
|
2064
2986
|
}
|
|
2065
2987
|
catch (error) {
|
|
2066
2988
|
if (error instanceof SessionNotFound)
|
|
@@ -2071,7 +2993,7 @@ export class SessionService {
|
|
|
2071
2993
|
}
|
|
2072
2994
|
if (!this.visibleToActiveProfile(s.profile))
|
|
2073
2995
|
throw new HttpFailure(404, 'Session not found');
|
|
2074
|
-
return this.
|
|
2996
|
+
return this.withMutationLock(sid, async () => {
|
|
2075
2997
|
let placement;
|
|
2076
2998
|
try {
|
|
2077
2999
|
placement = storeAnchorScene(s, body, this.deps.now());
|
|
@@ -2081,34 +3003,34 @@ export class SessionService {
|
|
|
2081
3003
|
}
|
|
2082
3004
|
if (!placement)
|
|
2083
3005
|
throw new HttpFailure(404, 'Assistant message not found');
|
|
2084
|
-
this.store.save(s, { touchUpdatedAt: false, skipIndex: true });
|
|
3006
|
+
await this.store.save(s, { touchUpdatedAt: false, skipIndex: true });
|
|
2085
3007
|
return { ok: true, ...placement };
|
|
2086
3008
|
});
|
|
2087
3009
|
}
|
|
2088
3010
|
/** The session and the full transcript its detail is built from; a state.db-only session gives its synthesized one. */
|
|
2089
|
-
fullTranscript(sid) {
|
|
3011
|
+
async fullTranscript(sid) {
|
|
2090
3012
|
try {
|
|
2091
|
-
let session = this.store.get(sid);
|
|
3013
|
+
let session = (await this.store.get(sid));
|
|
2092
3014
|
if (session.loadedMetadataOnly)
|
|
2093
|
-
session = this.store.load(sid) ?? session;
|
|
3015
|
+
session = (await this.store.load(sid)) ?? session;
|
|
2094
3016
|
if (!this.visibleToActiveProfile(session.profile))
|
|
2095
3017
|
throw new HttpFailure(404, 'Session not found');
|
|
2096
|
-
return { session, transcript: this.mergedTranscript(session), stored: true };
|
|
3018
|
+
return { session, transcript: this.mergedTranscript(session, session.messages, await this.stateDbRows(session)), stored: true };
|
|
2097
3019
|
}
|
|
2098
3020
|
catch (error) {
|
|
2099
3021
|
if (error instanceof HttpFailure)
|
|
2100
3022
|
throw error;
|
|
2101
|
-
const session = this.foreignSession(sid).synth;
|
|
3023
|
+
const session = (await this.foreignSession(sid)).synth;
|
|
2102
3024
|
return { session, transcript: session.messages, stored: false };
|
|
2103
3025
|
}
|
|
2104
3026
|
}
|
|
2105
|
-
readAnchorScene(query) {
|
|
3027
|
+
async readAnchorScene(query) {
|
|
2106
3028
|
const sid = str(query.session_id).trim();
|
|
2107
3029
|
const messageRef = normalizeAnchorSceneMessageRef(query.message_ref);
|
|
2108
3030
|
const messageIndex = anchorSceneIntOrNull(query.message_index);
|
|
2109
3031
|
if (!sid || (!messageRef && messageIndex === null))
|
|
2110
3032
|
throw new HttpFailure(400, 'session_id and message_ref or message_index are required');
|
|
2111
|
-
const { session, transcript, stored } = this.fullTranscript(sid);
|
|
3033
|
+
const { session, transcript, stored } = (await this.fullTranscript(sid));
|
|
2112
3034
|
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
3035
|
if (!result)
|
|
2114
3036
|
throw new HttpFailure(404, 'Anchor activity scene not found');
|
|
@@ -2117,26 +3039,26 @@ export class SessionService {
|
|
|
2117
3039
|
const redacted = redactValue(result, enabled);
|
|
2118
3040
|
const rows = withSceneToolDisplay(result.rows, redacted.rows, enabled);
|
|
2119
3041
|
// `/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 };
|
|
3042
|
+
return { ...redacted, rows: stored ? withSceneRowMedia(rows, (await this.mediaProjector(session))) : rows };
|
|
2121
3043
|
}
|
|
2122
3044
|
/** TAL-331: one tool call's whole result, which a limited response clipped (`result_truncated`), redacted like the detail. */
|
|
2123
|
-
readToolResult(query) {
|
|
3045
|
+
async readToolResult(query) {
|
|
2124
3046
|
const sid = query.session_id.trim();
|
|
2125
3047
|
const id = query.tool_call_id.trim();
|
|
2126
3048
|
if (!sid || !id)
|
|
2127
3049
|
throw new HttpFailure(400, 'session_id and tool_call_id are required');
|
|
2128
|
-
const full = fullToolResult(this.fullTranscript(sid).transcript, id);
|
|
3050
|
+
const full = fullToolResult((await this.fullTranscript(sid)).transcript, id);
|
|
2129
3051
|
if (full === null)
|
|
2130
3052
|
throw new HttpFailure(404, 'Tool result not found');
|
|
2131
3053
|
return { tool_call_id: id, ...redactValue(full, this.deps.redactEnabled()) };
|
|
2132
3054
|
}
|
|
2133
3055
|
// ── shares ───────────────────────────────────────────────────────────────
|
|
2134
|
-
createShare(sid) {
|
|
3056
|
+
async createShare(sid) {
|
|
2135
3057
|
this.rejectClaudeCode(sid, 'shared');
|
|
2136
|
-
let s = this.get404(sid);
|
|
3058
|
+
let s = (await this.get404(sid));
|
|
2137
3059
|
if (!this.visibleToActiveProfile(s.profile))
|
|
2138
3060
|
throw new HttpFailure(404, 'Session not found');
|
|
2139
|
-
s = this.store.ensureFull(sid, s);
|
|
3061
|
+
s = (await this.store.ensureFull(sid, s));
|
|
2140
3062
|
let snapshot;
|
|
2141
3063
|
try {
|
|
2142
3064
|
snapshot = buildShareSnapshot(s, s.messages, { hermesHome: this.deps.hermesHome, attachmentRoot: this.deps.attachmentDir('').replace(/\/[^/]*$/, ''), home: this.deps.home });
|
|
@@ -2144,31 +3066,31 @@ export class SessionService {
|
|
|
2144
3066
|
catch (error) {
|
|
2145
3067
|
throw new HttpFailure(400, error.message);
|
|
2146
3068
|
}
|
|
2147
|
-
const meta = this.deps.shares.createOrRefresh(s, snapshot);
|
|
3069
|
+
const meta = (await this.deps.shares.createOrRefresh(s, snapshot));
|
|
2148
3070
|
s.share_token = meta.share_token;
|
|
2149
3071
|
s.share_created_at = meta.share_created_at;
|
|
2150
|
-
this.store.save(s, { touchUpdatedAt: false });
|
|
3072
|
+
await this.store.save(s, { touchUpdatedAt: false });
|
|
2151
3073
|
this.publish('session_share_create', s.profile, sid);
|
|
2152
3074
|
return {
|
|
2153
3075
|
ok: true,
|
|
2154
3076
|
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),
|
|
3077
|
+
session: (await this.publicSession(s)),
|
|
2156
3078
|
};
|
|
2157
3079
|
}
|
|
2158
|
-
revokeShare(sid) {
|
|
2159
|
-
let s = this.get404(sid);
|
|
3080
|
+
async revokeShare(sid) {
|
|
3081
|
+
let s = (await this.get404(sid));
|
|
2160
3082
|
if (!this.visibleToActiveProfile(s.profile))
|
|
2161
3083
|
throw new HttpFailure(404, 'Session not found');
|
|
2162
|
-
s = this.store.ensureFull(sid, s);
|
|
2163
|
-
this.deps.shares.revoke(s);
|
|
3084
|
+
s = (await this.store.ensureFull(sid, s));
|
|
3085
|
+
await this.deps.shares.revoke(s);
|
|
2164
3086
|
s.share_token = null;
|
|
2165
3087
|
s.share_created_at = null;
|
|
2166
|
-
this.store.save(s, { touchUpdatedAt: false });
|
|
3088
|
+
await this.store.save(s, { touchUpdatedAt: false });
|
|
2167
3089
|
this.publish('session_share_revoke', s.profile, sid);
|
|
2168
|
-
return { ok: true, session: this.publicSession(s) };
|
|
3090
|
+
return { ok: true, session: (await this.publicSession(s)) };
|
|
2169
3091
|
}
|
|
2170
|
-
loadShare(token) {
|
|
2171
|
-
const share = this.deps.shares.load(token);
|
|
3092
|
+
async loadShare(token) {
|
|
3093
|
+
const share = (await this.deps.shares.load(token));
|
|
2172
3094
|
if (!share)
|
|
2173
3095
|
throw new HttpFailure(404, 'Shared conversation not found');
|
|
2174
3096
|
return { share };
|
|
@@ -2179,15 +3101,16 @@ export class SessionService {
|
|
|
2179
3101
|
throw new HttpFailure(400, 'Missing session_id');
|
|
2180
3102
|
return { yolo_enabled: this.deps.yolo.isEnabled(sid) };
|
|
2181
3103
|
}
|
|
2182
|
-
setYolo(sid, enabled) {
|
|
3104
|
+
async setYolo(sid, enabled) {
|
|
2183
3105
|
if (!sid)
|
|
2184
3106
|
throw new HttpFailure(400, 'Missing required field(s): session_id');
|
|
3107
|
+
await this.assertWritable(sid);
|
|
2185
3108
|
this.deps.yolo.set(sid, enabled);
|
|
2186
3109
|
return { ok: true, yolo_enabled: this.deps.yolo.isEnabled(sid) };
|
|
2187
3110
|
}
|
|
2188
3111
|
}
|
|
2189
|
-
function existsSessionFile(store, sid) {
|
|
2190
|
-
return store.persistedIds().has(sid);
|
|
3112
|
+
async function existsSessionFile(store, sid) {
|
|
3113
|
+
return (await store.persistedIds()).has(sid);
|
|
2191
3114
|
}
|
|
2192
3115
|
import { createHash } from 'node:crypto';
|
|
2193
3116
|
function hashRevision(signature) {
|
|
@@ -2218,6 +3141,18 @@ function findLastUserIndex(history) {
|
|
|
2218
3141
|
export function sanitizePaths(error) {
|
|
2219
3142
|
return str(error?.message ?? error).replace(/(?:(?:\/[a-zA-Z0-9_.-]+)+|(?:[A-Z]:\\[^\s]+))/g, '<path>');
|
|
2220
3143
|
}
|
|
3144
|
+
/** A failed state.db read says nothing (TAL-493). */
|
|
3145
|
+
function failedStateDbRead() {
|
|
3146
|
+
return { rows: [], idCapable: false, ok: false, reason: null, generation: null };
|
|
3147
|
+
}
|
|
3148
|
+
/** TAL-529: after a compression rotation the Agent writes to the continuation, so a read follows the recorded lineage. */
|
|
3149
|
+
function stateDbReadLineage(s) {
|
|
3150
|
+
return s.state_db_lineage?.[0] === s.session_id ? s.state_db_lineage : null;
|
|
3151
|
+
}
|
|
3152
|
+
/** A delegated child's view, which never merges state.db rows. */
|
|
3153
|
+
function isSubagentView(s) {
|
|
3154
|
+
return str(s.source_tag || s.raw_source || s.session_source).trim().toLowerCase() === 'subagent';
|
|
3155
|
+
}
|
|
2221
3156
|
/** TAL-536: a dead run's own state.db rows: from its prompt to the next prompt (tool results and applied steers stay in it). */
|
|
2222
3157
|
function runStateDbRows(rows, prompt) {
|
|
2223
3158
|
const at = rows.findIndex((m) => looksLikeCurrentUserTurn(m, prompt));
|
|
@@ -2231,13 +3166,24 @@ function runStateDbRows(rows, prompt) {
|
|
|
2231
3166
|
function settledIdentity(m) {
|
|
2232
3167
|
return messageIdentity(m, Infinity) ?? JSON.stringify([m.role ?? null, m.content ?? null, reasoningFieldsText(m)]);
|
|
2233
3168
|
}
|
|
2234
|
-
/** `rows` minus one occurrence of each row
|
|
3169
|
+
/** `rows` minus one occurrence of each base row, retaining its numeric provenance on the Agent's history copy. */
|
|
2235
3170
|
function withoutRows(rows, base) {
|
|
2236
3171
|
const left = new Map();
|
|
2237
|
-
for (const m of base)
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
3172
|
+
for (const m of base) {
|
|
3173
|
+
const key = settledIdentity(m);
|
|
3174
|
+
const entry = left.get(key) ?? { rows: [], at: 0 };
|
|
3175
|
+
entry.rows.push(m);
|
|
3176
|
+
left.set(key, entry);
|
|
3177
|
+
}
|
|
3178
|
+
return rows.filter((m) => {
|
|
3179
|
+
const entry = left.get(settledIdentity(m));
|
|
3180
|
+
if (!entry || entry.at === entry.rows.length)
|
|
3181
|
+
return true;
|
|
3182
|
+
const original = entry.rows[entry.at++];
|
|
3183
|
+
if (!('_state_db_generation' in m) && '_state_db_generation' in original)
|
|
3184
|
+
m._state_db_generation = original._state_db_generation;
|
|
3185
|
+
return false;
|
|
3186
|
+
});
|
|
2241
3187
|
}
|
|
2242
3188
|
/**
|
|
2243
3189
|
* TAL-493: what the state.db marker was recorded under: the boundary fields every boundary writer sets (an older release
|