mixdog 1.2.0 → 1.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -5
- package/package.json +1 -1
- package/scripts/gate-local.mjs +19 -3
- package/scripts/model-catalog-audit.mjs +1 -15
- package/src/agents/reviewer/AGENT.md +3 -3
- package/src/agents/reviewer/agent.json +1 -1
- package/src/defaults/shipped-history.json +5 -0
- package/src/defaults/skills/pptx/SKILL.md +6 -6
- package/src/defaults/skills/pptx/references/review.md +6 -4
- package/src/defaults/skills/pptx/scripts/source-extract.mjs +1 -1
- package/src/defaults/skills/setup/references/actions.md +13 -3
- package/src/defaults/skills/visualize/SKILL.md +12 -9
- package/src/repl/slash-commands.mjs +16 -1
- package/src/rules/shared/60-verification.md +3 -1
- package/src/rules/shared/70-delivery.md +4 -2
- package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +29 -4
- package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +10 -0
- package/src/runtime/agent/orchestrator/cache-mtime.mjs +26 -5
- package/src/runtime/agent/orchestrator/config.mjs +10 -4
- package/src/runtime/agent/orchestrator/context/collect.mjs +2 -1
- package/src/runtime/agent/orchestrator/context/deferred-tools.mjs +11 -5
- package/src/runtime/agent/orchestrator/context/skill-catalog.mjs +74 -21
- package/src/runtime/agent/orchestrator/mcp/client-config.mjs +122 -15
- package/src/runtime/agent/orchestrator/mcp/client-handshake.mjs +54 -11
- package/src/runtime/agent/orchestrator/mcp/client-transport.mjs +104 -9
- package/src/runtime/agent/orchestrator/mcp/client.mjs +358 -140
- package/src/runtime/agent/orchestrator/mcp/lazy-output-validator.mjs +25 -0
- package/src/runtime/agent/orchestrator/mcp/oauth-callback.mjs +103 -0
- package/src/runtime/agent/orchestrator/mcp/oauth-credentials.mjs +150 -0
- package/src/runtime/agent/orchestrator/mcp/oauth-login.mjs +111 -0
- package/src/runtime/agent/orchestrator/mcp/oauth-probe.mjs +87 -0
- package/src/runtime/agent/orchestrator/mcp/oauth-provider.mjs +95 -0
- package/src/runtime/agent/orchestrator/mcp/oauth-request.mjs +122 -0
- package/src/runtime/agent/orchestrator/mcp/oauth-shared.mjs +9 -0
- package/src/runtime/agent/orchestrator/mcp/oauth.mjs +229 -0
- package/src/runtime/agent/orchestrator/mcp/plugin-placeholders.mjs +54 -0
- package/src/runtime/agent/orchestrator/providers/account-pool.mjs +294 -44
- package/src/runtime/agent/orchestrator/providers/anthropic-api-recovery.mjs +2 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-api-transport.mjs +94 -31
- package/src/runtime/agent/orchestrator/providers/anthropic-error-contract.mjs +96 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-fast-mode.mjs +39 -18
- package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens-recovery.mjs +55 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens.mjs +25 -21
- package/src/runtime/agent/orchestrator/providers/anthropic-messages.mjs +3 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-midstream-recovery.mjs +18 -4
- package/src/runtime/agent/orchestrator/providers/anthropic-model-resolve.mjs +13 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-client-version.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-midstream.mjs +10 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-recovery.mjs +23 -9
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-request/initial-status.mjs +46 -20
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-request.mjs +14 -9
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +73 -35
- package/src/runtime/agent/orchestrator/providers/anthropic-overload-fallback.mjs +69 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-server-fallback.mjs +4 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +8 -1
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +53 -18
- package/src/runtime/agent/orchestrator/providers/antigravity-oauth-catalog.mjs +30 -9
- package/src/runtime/agent/orchestrator/providers/antigravity-oauth.mjs +7 -0
- package/src/runtime/agent/orchestrator/providers/antigravity-request.mjs +30 -14
- package/src/runtime/agent/orchestrator/providers/antigravity-response.mjs +2 -1
- package/src/runtime/agent/orchestrator/providers/antigravity-retry-policy.mjs +93 -0
- package/src/runtime/agent/orchestrator/providers/antigravity-transport.mjs +82 -9
- package/src/runtime/agent/orchestrator/providers/auto-effort.mjs +9 -0
- package/src/runtime/agent/orchestrator/providers/command-code-oauth-login.mjs +337 -0
- package/src/runtime/agent/orchestrator/providers/command-code-oauth-tokens.mjs +159 -0
- package/src/runtime/agent/orchestrator/providers/command-code-oauth.mjs +447 -0
- package/src/runtime/agent/orchestrator/providers/cursor-auth.mjs +38 -5
- package/src/runtime/agent/orchestrator/providers/cursor-client-version.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/cursor-wire-guards.mjs +97 -2
- package/src/runtime/agent/orchestrator/providers/cursor-wire-stream-bridge.mjs +102 -23
- package/src/runtime/agent/orchestrator/providers/cursor-wire-transport.mjs +103 -33
- package/src/runtime/agent/orchestrator/providers/cursor-wire.mjs +29 -6
- package/src/runtime/agent/orchestrator/providers/cursor.mjs +18 -1
- package/src/runtime/agent/orchestrator/providers/custom-provider.mjs +7 -4
- package/src/runtime/agent/orchestrator/providers/effort-configuration.mjs +86 -19
- package/src/runtime/agent/orchestrator/providers/gemini-cache-policy.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +29 -8
- package/src/runtime/agent/orchestrator/providers/gemini-error-contract.mjs +191 -0
- package/src/runtime/agent/orchestrator/providers/gemini-family.mjs +9 -0
- package/src/runtime/agent/orchestrator/providers/gemini-history-signatures.mjs +9 -4
- package/src/runtime/agent/orchestrator/providers/gemini-response.mjs +3 -1
- package/src/runtime/agent/orchestrator/providers/gemini-retry-policy.mjs +180 -0
- package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +21 -82
- package/src/runtime/agent/orchestrator/providers/gemini-sdk-request.mjs +4 -1
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +71 -12
- package/src/runtime/agent/orchestrator/providers/gemini-thinking.mjs +21 -20
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +97 -51
- package/src/runtime/agent/orchestrator/providers/grok-oauth-tokens.mjs +3 -0
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +68 -19
- package/src/runtime/agent/orchestrator/providers/lib/gemini-model-catalog.mjs +7 -1
- package/src/runtime/agent/orchestrator/providers/mimo-oauth.mjs +3 -0
- package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +2 -1
- package/src/runtime/agent/orchestrator/providers/model-proxy-bridge.mjs +1080 -0
- package/src/runtime/agent/orchestrator/providers/model-proxy-channel.d.mts +15 -0
- package/src/runtime/agent/orchestrator/providers/model-proxy-channel.mjs +35 -0
- package/src/runtime/agent/orchestrator/providers/muse-code-oauth.mjs +4 -9
- package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +12 -0
- package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +6 -5
- package/src/runtime/agent/orchestrator/providers/openai-compat-chat-send.mjs +5 -3
- package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream/stream-result.mjs +22 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream.mjs +1 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-error-contract.mjs +50 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-models.mjs +6 -2
- package/src/runtime/agent/orchestrator/providers/openai-compat-options.mjs +11 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-response-normalization.mjs +23 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-responses-events.mjs +16 -8
- package/src/runtime/agent/orchestrator/providers/openai-compat-responses-state.mjs +3 -2
- package/src/runtime/agent/orchestrator/providers/openai-compat-responses.mjs +94 -13
- package/src/runtime/agent/orchestrator/providers/openai-compat-retry-profile.mjs +193 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream-common.mjs +66 -2
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +2 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +6 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-xai-send.mjs +6 -3
- package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +23 -14
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +26 -6
- package/src/runtime/agent/orchestrator/providers/openai-direct-request.mjs +42 -25
- package/src/runtime/agent/orchestrator/providers/openai-error-contract.mjs +274 -0
- package/src/runtime/agent/orchestrator/providers/openai-http-sse-request.mjs +72 -32
- package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-events/terminal-frames.mjs +18 -11
- package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-outcome.mjs +1 -2
- package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-stamps.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-state.mjs +2 -3
- package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/openai-http-sse-watchdogs.mjs +1 -3
- package/src/runtime/agent/orchestrator/providers/openai-oauth-dispatch.mjs +34 -3
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +5 -1
- package/src/runtime/agent/orchestrator/providers/openai-oauth-tokens.mjs +22 -2
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +5 -0
- package/src/runtime/agent/orchestrator/providers/openai-responses-input.mjs +5 -9
- package/src/runtime/agent/orchestrator/providers/openai-retry-policy.mjs +9 -0
- package/src/runtime/agent/orchestrator/providers/openai-ws-handshake.mjs +16 -8
- package/src/runtime/agent/orchestrator/providers/openai-ws-send-attempts/attempt-context.mjs +1 -2
- package/src/runtime/agent/orchestrator/providers/openai-ws-send-attempts/policy.mjs +25 -14
- package/src/runtime/agent/orchestrator/providers/openai-ws-terminal.mjs +18 -11
- package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +35 -24
- package/src/runtime/agent/orchestrator/providers/provider-error-kind.mjs +581 -0
- package/src/runtime/agent/orchestrator/providers/registry.mjs +35 -9
- package/src/runtime/agent/orchestrator/providers/retry-backoff.mjs +46 -7
- package/src/runtime/agent/orchestrator/providers/retry-classification.mjs +118 -219
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +2 -0
- package/src/runtime/agent/orchestrator/providers/retry-executor.mjs +148 -45
- package/src/runtime/agent/orchestrator/providers/retry-midstream.mjs +81 -16
- package/src/runtime/agent/orchestrator/runtime-core/config-helpers.mjs +3 -0
- package/src/runtime/agent/orchestrator/runtime-core/effort.mjs +3 -0
- package/src/runtime/agent/orchestrator/runtime-core/extension-scopes.mjs +15 -55
- package/src/runtime/agent/orchestrator/runtime-core/goal-control.mjs +15 -2
- package/src/runtime/agent/orchestrator/runtime-core/goal-reminder.mjs +0 -18
- package/src/runtime/agent/orchestrator/runtime-core/model-capabilities.mjs +57 -28
- package/src/runtime/agent/orchestrator/runtime-core/orchestration.mjs +19 -16
- package/src/runtime/agent/orchestrator/runtime-core/plugin-mcp.mjs +268 -181
- package/src/runtime/agent/orchestrator/runtime-core/skill-tool-loading.mjs +4 -1
- package/src/runtime/agent/orchestrator/runtime-core/tool-catalog-schema.mjs +21 -0
- package/src/runtime/agent/orchestrator/runtime-core/tool-catalog.mjs +54 -3
- package/src/runtime/agent/orchestrator/session/catalog-db/index.mjs +284 -0
- package/src/runtime/agent/orchestrator/session/catalog-db/schema.mjs +270 -0
- package/src/runtime/agent/orchestrator/session/catalog-db/sqlite-gate.mjs +27 -0
- package/src/runtime/agent/orchestrator/session/catalog-db/worker.mjs +628 -0
- package/src/runtime/agent/orchestrator/session/compact/messages.mjs +6 -2
- package/src/runtime/agent/orchestrator/session/conversation-import/claude.mjs +184 -0
- package/src/runtime/agent/orchestrator/session/conversation-import/codex.mjs +136 -0
- package/src/runtime/agent/orchestrator/session/conversation-import/convert.mjs +128 -0
- package/src/runtime/agent/orchestrator/session/conversation-import/imported-session.mjs +127 -0
- package/src/runtime/agent/orchestrator/session/conversation-import/turns.mjs +101 -0
- package/src/runtime/agent/orchestrator/session/explicit-skills.mjs +83 -5
- package/src/runtime/agent/orchestrator/session/imported-message.mjs +11 -0
- package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +2 -1
- package/src/runtime/agent/orchestrator/session/loop/loop-state.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/loop/no-tool-turn.mjs +3 -2
- package/src/runtime/agent/orchestrator/session/loop/send-phase.mjs +10 -4
- package/src/runtime/agent/orchestrator/session/loop/steering.mjs +4 -3
- package/src/runtime/agent/orchestrator/session/loop/step-auto-effort.mjs +102 -18
- package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +3 -0
- package/src/runtime/agent/orchestrator/session/loop/tool-phase.mjs +3 -1
- package/src/runtime/agent/orchestrator/session/manager/ask-queue.mjs +42 -5
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +14 -3
- package/src/runtime/agent/orchestrator/session/manager/ask-turn-auto-effort.mjs +20 -12
- package/src/runtime/agent/orchestrator/session/manager/ask-turn-execute.mjs +50 -23
- package/src/runtime/agent/orchestrator/session/manager/ask-turn-loop-options.mjs +4 -0
- package/src/runtime/agent/orchestrator/session/manager/pending-message-entry.mjs +22 -14
- package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +18 -7
- package/src/runtime/agent/orchestrator/session/manager.mjs +0 -1
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +4 -3
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +155 -29
- package/src/runtime/agent/orchestrator/session/store/listing.mjs +6 -2
- package/src/runtime/agent/orchestrator/session/store/save-worker.mjs +6 -3
- package/src/runtime/agent/orchestrator/session/store/summary-cache.mjs +6 -0
- package/src/runtime/agent/orchestrator/session/store/write-guards.mjs +16 -0
- package/src/runtime/agent/orchestrator/session/store-agent-worker-rows.mjs +22 -10
- package/src/runtime/agent/orchestrator/session/store-summary-catalog.mjs +819 -0
- package/src/runtime/agent/orchestrator/session/store-summary-fields.mjs +12 -0
- package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +22 -12
- package/src/runtime/agent/orchestrator/session/store-summary-reader-catalog-fixture.mjs +158 -0
- package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +183 -374
- package/src/runtime/agent/orchestrator/session/synthetic-user-envelope.mjs +3 -1
- package/src/runtime/agent/orchestrator/session/tool-batch/pre-dispatch.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/transcript-restore/restore.mjs +41 -3
- package/src/runtime/agent/orchestrator/session/transcript-restore/user-text.mjs +38 -17
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/deadline-plan.mjs +12 -15
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/invocation-plan.mjs +17 -1
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/spawn-env.mjs +4 -0
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +5 -5
- package/src/runtime/agent/orchestrator/tools/builtin/lib/list-helpers.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +6 -6
- package/src/runtime/agent/orchestrator/tools/builtin/read-range-index.mjs +173 -22
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-guards.mjs +8 -4
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +5 -4
- package/src/runtime/agent/orchestrator/tools/builtin/read-source-windows.mjs +15 -2
- package/src/runtime/agent/orchestrator/tools/builtin/search-glob-tool.mjs +13 -10
- package/src/runtime/agent/orchestrator/tools/builtin/search-grep-tool.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +17 -12
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch/root-resolution.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/patch/apply-patch/codex-batch.mjs +4 -4
- package/src/runtime/agent/orchestrator/tools/patch/apply-patch/route.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/patch/read-redirects.mjs +34 -30
- package/src/runtime/agent/orchestrator/tools/patch/sequence/units.mjs +7 -7
- package/src/runtime/agent/orchestrator/tools/patch/sequence.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +4 -0
- package/src/runtime/attachments/store.d.mts +3 -0
- package/src/runtime/channels/lib/owned-runtime/owned-start.mjs +1 -1
- package/src/runtime/channels/lib/webhook/session-dispatch.mjs +16 -4
- package/src/runtime/channels/lib/worker-main.mjs +17 -14
- package/src/runtime/computer-bridge/linux-readiness.mjs +0 -2
- package/src/runtime/effort-judge/judge-client.mjs +56 -18
- package/src/runtime/memory/lib/memory.mjs +1 -1
- package/src/runtime/memory/lib/pg/process.mjs +34 -24
- package/src/runtime/memory/lib/pg/supervisor.mjs +16 -13
- package/src/runtime/memory/lib/session-ingest.mjs +2 -2
- package/src/runtime/office/README.md +1 -1
- package/src/runtime/office/shared/values.mjs +0 -5
- package/src/runtime/shared/atomic-file.mjs +25 -4
- package/src/runtime/shared/background-tasks.mjs +92 -14
- package/src/runtime/shared/err-text.mjs +25 -0
- package/src/runtime/shared/error-presentation.mjs +159 -19
- package/src/runtime/shared/extension-scopes.mjs +5 -3
- package/src/runtime/shared/file-lock.mjs +37 -11
- package/src/runtime/shared/llm/anthropic-betas.mjs +4 -3
- package/src/runtime/shared/llm/anthropic-effort.mjs +134 -131
- package/src/runtime/shared/llm/anthropic-thinking-contract.mjs +50 -14
- package/src/runtime/shared/llm/model-catalog.mjs +53 -52
- package/src/runtime/shared/llm/openai-daybreak.mjs +4 -16
- package/src/runtime/shared/llm/provider-catalog-registry.mjs +17 -0
- package/src/runtime/shared/llm/provider-model-identities.mjs +13 -4
- package/src/runtime/shared/llm/usage-rollup.mjs +0 -1
- package/src/runtime/shared/model-display.mjs +13 -0
- package/src/runtime/shared/plugin-manifest.mjs +18 -9
- package/src/runtime/shared/pristine-execution-contract.json +9 -2
- package/src/runtime/shared/pristine-execution.mjs +28 -4
- package/src/runtime/shared/provider-accounts.mjs +124 -20
- package/src/runtime/shared/provider-api-key.mjs +12 -0
- package/src/runtime/shared/quota-resume-store.mjs +11 -0
- package/src/runtime/shared/session-baseline-store.mjs +960 -0
- package/src/runtime/shared/shell-display.mjs +9 -3
- package/src/runtime/shared/skill-mentions.d.mts +10 -0
- package/src/runtime/shared/skill-mentions.mjs +133 -0
- package/src/runtime/shared/skill-script-runtime.mjs +264 -0
- package/src/runtime/shared/statusline/statusline-format.mjs +4 -4
- package/src/runtime/shared/task-notification-envelope.mjs +2 -2
- package/src/runtime/shared/tool-primitives.mjs +35 -2
- package/src/runtime/shared/turn-changes.mjs +447 -219
- package/src/runtime/shared/turn-snapshot-store.mjs +182 -1
- package/src/runtime/shared/turn-snapshot.mjs +170 -48
- package/src/runtime/shared/turn-worktree-snapshot.mjs +160 -51
- package/src/runtime/shared/worker-requests.mjs +15 -2
- package/src/runtime/shared/worktrees/contract.mjs +0 -24
- package/src/runtime/shared/worktrees/test-support.mjs +19 -4
- package/src/session-runtime/automation-agents.mjs +8 -2
- package/src/session-runtime/boot/apis.mjs +61 -0
- package/src/session-runtime/boot/config.mjs +5 -2
- package/src/session-runtime/boot/facade.mjs +1 -0
- package/src/session-runtime/boot/infrastructure.mjs +35 -18
- package/src/session-runtime/conversation-import/discovery.mjs +698 -0
- package/src/session-runtime/conversation-import/service.mjs +369 -0
- package/src/session-runtime/cwd-plugins/cwd-apply.mjs +10 -5
- package/src/session-runtime/cwd-plugins/plugin-status.mjs +47 -42
- package/src/session-runtime/lifecycle/session-catalog.mjs +2 -0
- package/src/session-runtime/lifecycle/teardown.mjs +1 -1
- package/src/session-runtime/mcp-glue/connect.mjs +83 -16
- package/src/session-runtime/mcp-glue/plugin-servers.mjs +499 -0
- package/src/session-runtime/mcp-glue/server-catalog.mjs +135 -33
- package/src/session-runtime/mcp-glue.mjs +25 -2
- package/src/session-runtime/mcp-oauth-login.mjs +174 -0
- package/src/session-runtime/model-route/model-switch-target.mjs +34 -0
- package/src/session-runtime/model-route/set-route.mjs +83 -15
- package/src/session-runtime/model-route/web-search-route.mjs +1 -1
- package/src/session-runtime/native-web-search/routes.mjs +22 -3
- package/src/session-runtime/new-session-config.mjs +3 -2
- package/src/session-runtime/notification-bus.mjs +9 -2
- package/src/session-runtime/provider-auth/accounts.mjs +33 -15
- package/src/session-runtime/provider-models/model-meta.mjs +12 -3
- package/src/session-runtime/provider-models/web-search-catalog.mjs +5 -1
- package/src/session-runtime/provider-readiness.mjs +16 -10
- package/src/session-runtime/quick-model-rows/web-search-rows.mjs +13 -8
- package/src/session-runtime/quick-web-search-models.mjs +13 -38
- package/src/session-runtime/resource-extension-sync.mjs +24 -5
- package/src/session-runtime/resource-mcp-api.mjs +198 -78
- package/src/session-runtime/resource-plugins-api.mjs +29 -41
- package/src/session-runtime/resource-scope-decorate.mjs +6 -8
- package/src/session-runtime/resource-skills-api.mjs +23 -2
- package/src/session-runtime/runtime-review-api.mjs +13 -0
- package/src/session-runtime/schedule-session-run.mjs +1 -1
- package/src/session-runtime/services/agent-tool/job-task-reconcile.mjs +13 -1
- package/src/session-runtime/services/agent-tool/job-views/agent-list.mjs +7 -2
- package/src/session-runtime/services/agent-tool/send-flow.mjs +22 -4
- package/src/session-runtime/services/agent-tool/spawn-flow/job-start.mjs +2 -0
- package/src/session-runtime/services/agent-tool/spawn-flow/spawn-run.mjs +22 -5
- package/src/session-runtime/services/agent-tool/worker-index/row-liveness.mjs +8 -1
- package/src/session-runtime/services/agent-tool/worker-index/row-shape.mjs +2 -2
- package/src/session-runtime/services/agent-tool/worker-rows.mjs +17 -0
- package/src/session-runtime/services/channel-session-router.mjs +4 -1
- package/src/session-runtime/services/hook-bus/command-handler.mjs +4 -1
- package/src/session-runtime/services/hook-bus/handlers.mjs +7 -3
- package/src/session-runtime/services/provider-admin.mjs +64 -18
- package/src/session-runtime/services/usage-dashboard-model.mjs +3 -0
- package/src/session-runtime/settings-compaction-api.mjs +1 -0
- package/src/session-runtime/setup-tool/executor.mjs +12 -1
- package/src/session-runtime/setup-tool/extended-actions.mjs +1 -0
- package/src/session-runtime/setup-tool/settings-contract.mjs +1 -0
- package/src/session-runtime/setup-tool/tool-defs.mjs +7 -1
- package/src/session-runtime/skills-api.mjs +7 -3
- package/src/session-runtime/turn/turn-run/ask-callbacks.mjs +1 -0
- package/src/session-runtime/webhook-session-run.mjs +16 -4
- package/src/skill-script-entry.mjs +36 -0
- package/src/standalone/daemon-drain-policy.mjs +8 -0
- package/src/standalone/daemon-stored-session-views.mjs +11 -3
- package/src/standalone/daemon-telemetry.mjs +55 -0
- package/src/standalone/daemon.mjs +44 -11
- package/src/standalone/session-protocol.mjs +12 -0
- package/src/standalone/session-service/agent-tree/agent-cancel.mjs +33 -2
- package/src/standalone/session-service/agent-tree/agent-turns.mjs +26 -10
- package/src/standalone/session-service/agent-tree.mjs +12 -0
- package/src/standalone/session-service/quota-resume.mjs +8 -0
- package/src/standalone/session-service/retention/busy.mjs +15 -1
- package/src/standalone/session-service/session-calls/session-action.mjs +6 -1
- package/src/standalone/session-service-api.mjs +5 -0
- package/src/standalone/session-service.mjs +1 -0
- package/src/tui/App.jsx +9 -1
- package/src/tui/app/app-view/floating-panel.jsx +9 -1
- package/src/tui/app/app-view.jsx +7 -0
- package/src/tui/app/create-app-pickers.mjs +4 -0
- package/src/tui/app/extension-pickers/mcp-login.mjs +107 -0
- package/src/tui/app/extension-pickers/mcp-servers-picker.mjs +2 -1
- package/src/tui/app/extension-pickers/plugins-pickers.mjs +35 -16
- package/src/tui/app/extension-pickers.mjs +2 -1
- package/src/tui/app/model-options.mjs +13 -16
- package/src/tui/app/skill-mention-palette.mjs +88 -0
- package/src/tui/app/slash-commands.mjs +7 -2
- package/src/tui/app/slash-dispatch/panel-commands.mjs +16 -1
- package/src/tui/app/slash-palette.mjs +23 -7
- package/src/tui/app/transcript-row-estimate.mjs +11 -3
- package/src/tui/app/use-prompt-draft-flow.mjs +7 -2
- package/src/tui/app/use-prompt-state.mjs +5 -0
- package/src/tui/components/Message.jsx +22 -1
- package/src/tui/components/PromptInput.jsx +21 -3
- package/src/tui/components/SlashCommandPalette.jsx +12 -3
- package/src/tui/components/StatusLine.jsx +2 -0
- package/src/tui/components/TranscriptItem.jsx +9 -1
- package/src/tui/components/prompt-input/key-handler.mjs +21 -8
- package/src/tui/components/status-line/local-line.mjs +5 -2
- package/src/tui/components/status-line/use-statusline-text.mjs +7 -1
- package/src/tui/components/user-message-skills.mjs +38 -0
- package/src/tui/dist/index.mjs +1323 -214
- package/src/tui/session/queue-helpers.mjs +7 -2
- package/src/tui/session/quota-resume.mjs +46 -14
- package/src/tui/session/session-api/commands.mjs +33 -24
- package/src/tui/session/session-api/extensions.mjs +26 -2
- package/src/tui/session/session-api/integrations.mjs +7 -0
- package/src/tui/session/session-api/lifecycle.mjs +5 -1
- package/src/tui/session/session-api/routes.mjs +71 -7
- package/src/tui/session/session-api/settings.mjs +12 -14
- package/src/tui/session/session-flow/drain.mjs +21 -5
- package/src/tui/session/session-flow/failed-turn-rewind.mjs +10 -4
- package/src/tui/session/tui-steering-persist.mjs +25 -9
- package/src/tui/session/turn.mjs +55 -15
- package/src/tui/statusline-ansi-bridge.mjs +1 -1
- package/src/ui/statusline-agents.mjs +15 -4
- package/src/ui/statusline.mjs +7 -3
- package/src/ui/streaming-markdown-heal.mjs +34 -3
- package/src/vendor/statusline/src/gateway/claude-current.mjs +14 -4
- package/src/runtime/effort-judge/previous-human-request.mjs +0 -162
- package/src/runtime/effort-judge/verified-context-input.mjs +0 -10
- package/src/runtime/memory/lib/memory-session-merge.mjs +0 -32
- package/src/runtime/shared/llm/usage-session-history.mjs +0 -266
|
@@ -0,0 +1,819 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ordinary session catalog engine: summary index (base + delta log),
|
|
3
|
+
* session-file reconcile, `.hb` liveness and the background verification.
|
|
4
|
+
*
|
|
5
|
+
* Leaf by design (no store.mjs, no config, no workers): the cold reader
|
|
6
|
+
* (store-summary-reader.mjs) runs one instance on its own thread for the JSON
|
|
7
|
+
* path, and the session catalog DB worker (catalog-db/worker.mjs) runs another
|
|
8
|
+
* that feeds SQLite. Both therefore detect exactly the same changes.
|
|
9
|
+
*/
|
|
10
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
11
|
+
import { stat } from 'node:fs/promises';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
// Leaf helpers only: the three-way present/absent/unreadable classification
|
|
14
|
+
// and the strict record parser the authoritative store uses, so the catalog
|
|
15
|
+
// cannot disagree with it.
|
|
16
|
+
import { probePath, readTextFile, PROBE_PRESENT, PROBE_ABSENT } from './store/fs-probe.mjs';
|
|
17
|
+
import { readTopLevelLifecycleRecord, isLifecycleUnreadable } from './lifecycle-scan.mjs';
|
|
18
|
+
import { isAgentOnlySession, isRootLeadSession, sessionVisibility } from './store-summary-visibility.mjs';
|
|
19
|
+
import { applySummaryLogText, summaryLogPath } from './store-summary-log.mjs';
|
|
20
|
+
import { dataDir } from './store-summary-locations.mjs';
|
|
21
|
+
import {
|
|
22
|
+
desktopSession,
|
|
23
|
+
importKey,
|
|
24
|
+
inheritedFromSessionId,
|
|
25
|
+
isStoredSessionId,
|
|
26
|
+
positiveNumber,
|
|
27
|
+
workspaceSummary,
|
|
28
|
+
} from './store-summary-fields.mjs';
|
|
29
|
+
import { byActivityThenId } from './catalog-db/schema.mjs';
|
|
30
|
+
import {
|
|
31
|
+
sessionStampOf,
|
|
32
|
+
statSessionStamp,
|
|
33
|
+
sameSessionStamp,
|
|
34
|
+
isSettledSessionStamp,
|
|
35
|
+
} from './store/canonical-reader.mjs';
|
|
36
|
+
|
|
37
|
+
export const STAMP_ABSENT_CODES = new Set(['ENOENT', 'ENOTDIR']);
|
|
38
|
+
|
|
39
|
+
/** The one catalog order of every listing path (JSON and DB): newest
|
|
40
|
+
* conversation activity first (see normalizedRow's lastUsedAt note), ties by
|
|
41
|
+
* id, so a fallback and the DB never disagree on equal activity times. */
|
|
42
|
+
export const byCatalogOrder = byActivityThenId;
|
|
43
|
+
|
|
44
|
+
// ── Stamp-keyed strict derivations ──────────────────────────────────────────
|
|
45
|
+
// Session metadata and catalog rows are small values derived from a strict
|
|
46
|
+
// parse of the whole record. They are reused only while the file still has
|
|
47
|
+
// the FULL stamp { dev, ino, size, mtimeNs, ctimeNs } the parsed bytes were
|
|
48
|
+
// proven to come from (stat → read → stat) and that stamp is past the racy
|
|
49
|
+
// window. Any other file — changed, replaced by another writer, unreadable —
|
|
50
|
+
// is read and strictly parsed again.
|
|
51
|
+
const STAMPED_DERIVATION_LIMIT = 2048;
|
|
52
|
+
const stampedDerivations = new Map(); // `${kind}|${path}` → { stamp, value }
|
|
53
|
+
|
|
54
|
+
export function observeStamp(path) {
|
|
55
|
+
try {
|
|
56
|
+
return statSessionStamp(path);
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** `{ state, value }`: `derive(text)` for the record at `path`, or its cached
|
|
63
|
+
* result while the file is provably unchanged. */
|
|
64
|
+
export function stampedDerivation(kind, path, derive) {
|
|
65
|
+
const key = `${kind}|${path}`;
|
|
66
|
+
const before = observeStamp(path);
|
|
67
|
+
const cached = stampedDerivations.get(key);
|
|
68
|
+
if (before && cached && sameSessionStamp(cached.stamp, before)) {
|
|
69
|
+
stampedDerivations.delete(key);
|
|
70
|
+
stampedDerivations.set(key, cached);
|
|
71
|
+
return { state: PROBE_PRESENT, value: cached.value };
|
|
72
|
+
}
|
|
73
|
+
stampedDerivations.delete(key);
|
|
74
|
+
const read = readTextFile(path);
|
|
75
|
+
if (read.state !== PROBE_PRESENT) return { state: read.state, value: null };
|
|
76
|
+
const value = derive(read.text);
|
|
77
|
+
if (isSettledSessionStamp(before) && sameSessionStamp(before, observeStamp(path))) {
|
|
78
|
+
stampedDerivations.set(key, { stamp: before, value });
|
|
79
|
+
if (stampedDerivations.size > STAMPED_DERIVATION_LIMIT) {
|
|
80
|
+
stampedDerivations.delete(stampedDerivations.keys().next().value);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return { state: PROBE_PRESENT, value };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const SESSION_SUMMARY_INDEX_VERSION = 2;
|
|
87
|
+
|
|
88
|
+
// Mirror of lifecycle-api.mjs listLeadSessions visibility: the cold catalog
|
|
89
|
+
// must never surface worker/agent dispatches (memory ingest chunks, judges,
|
|
90
|
+
// spawned agents) — the authoritative engine excludes them, so a click on
|
|
91
|
+
// such a row dead-ends in "Session is not available." (user report).
|
|
92
|
+
const LEAD_OWNERS = new Set(['cli', 'user', 'mixdog']);
|
|
93
|
+
|
|
94
|
+
export function isLeadVisibleRow(row) {
|
|
95
|
+
const owner = String(row.owner || 'user')
|
|
96
|
+
.trim()
|
|
97
|
+
.toLowerCase();
|
|
98
|
+
if (isAgentOnlySession(row)) return false;
|
|
99
|
+
if (!isRootLeadSession(row) && owner && !LEAD_OWNERS.has(owner)) return false;
|
|
100
|
+
// Mirror listLeadSessions: a previewless zero-message row is an unusable
|
|
101
|
+
// scratch (desktop boot leftovers, crashed first turns) — resuming it
|
|
102
|
+
// shows an empty conversation, so the catalog hides it.
|
|
103
|
+
if (!row.preview && row.messageCount === 0) return false;
|
|
104
|
+
const sourceType = String(row.sourceType || '')
|
|
105
|
+
.trim()
|
|
106
|
+
.toLowerCase();
|
|
107
|
+
// Mirror listLeadSessions: one-shot `mixdog exec` runs stay out of Recent.
|
|
108
|
+
if (sourceType === 'exec') return false;
|
|
109
|
+
const sourceName = String(row.sourceName || '')
|
|
110
|
+
.trim()
|
|
111
|
+
.toLowerCase();
|
|
112
|
+
const agent = String(row.agent || '')
|
|
113
|
+
.trim()
|
|
114
|
+
.toLowerCase();
|
|
115
|
+
return (
|
|
116
|
+
agent === 'lead' ||
|
|
117
|
+
sourceType === 'lead' ||
|
|
118
|
+
sourceType === 'cli' ||
|
|
119
|
+
sourceType === 'schedule' ||
|
|
120
|
+
sourceType === 'webhook' ||
|
|
121
|
+
(!sourceType && !sourceName && owner !== 'agent')
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function cleanText(value, maximum = 240) {
|
|
126
|
+
return (
|
|
127
|
+
String(value || '')
|
|
128
|
+
.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/gi, ' ')
|
|
129
|
+
.replace(/<mcp-instructions>[\s\S]*?<\/mcp-instructions>/gi, ' ')
|
|
130
|
+
// Session-context envelope (mirror of session-text.mjs
|
|
131
|
+
// stripSessionDisplayEnvelope): the "# Session / Cwd / Model /
|
|
132
|
+
// Workflow" header must never become a Recent title.
|
|
133
|
+
.replace(/^\s*# Session\r?\n(?:(?:Cwd|Model|Workflow):[^\r\n]*(?:\r?\n|$))+(?:\r?\n)?/i, ' ')
|
|
134
|
+
.replace(/^\s*#\s*Session\s+Cwd:\s+\S+(?:\s+Model:[^\r\n]*?)?(?:\s+Workflow:\s+\S+)?\s*/i, ' ')
|
|
135
|
+
.replace(/\s+/g, ' ')
|
|
136
|
+
.trim()
|
|
137
|
+
.slice(0, maximum)
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function messageText(content) {
|
|
142
|
+
if (typeof content === 'string') return content;
|
|
143
|
+
if (!Array.isArray(content)) return '';
|
|
144
|
+
return content
|
|
145
|
+
.map((part) => (typeof part === 'string' ? part : String(part?.text || part?.content || '')))
|
|
146
|
+
.filter(Boolean)
|
|
147
|
+
.join(' ');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function normalizedRow(row, heartbeatAt = 0) {
|
|
151
|
+
if (!row || typeof row.id !== 'string' || !isStoredSessionId(row.id)) return null;
|
|
152
|
+
return {
|
|
153
|
+
id: row.id,
|
|
154
|
+
updatedAt: positiveNumber(row.updatedAt, 0),
|
|
155
|
+
// Conversation-activity timestamp (mirror of listLeadSessions):
|
|
156
|
+
// detach/resume bookkeeping bumps updatedAt in bulk on restarts, so
|
|
157
|
+
// Recent must order by lastUsedAt or every restart reshuffles rows.
|
|
158
|
+
lastUsedAt: positiveNumber(row.lastUsedAt, 0),
|
|
159
|
+
createdAt: positiveNumber(row.createdAt, 0),
|
|
160
|
+
lastHeartbeatAt: positiveNumber(row.lastHeartbeatAt, 0),
|
|
161
|
+
// Liveness comes from the .hb sidecar mtime alone: stored row fields
|
|
162
|
+
// (summary index / final session save) survive completion and must not
|
|
163
|
+
// keep the desktop working indicator on after the sidecar is deleted.
|
|
164
|
+
heartbeatAt: positiveNumber(heartbeatAt, 0),
|
|
165
|
+
closed: row.closed === true,
|
|
166
|
+
status: String(row.status || (row.closed === true ? 'closed' : 'idle')),
|
|
167
|
+
owner: row.owner || 'user',
|
|
168
|
+
agent: row.agent || null,
|
|
169
|
+
sourceType: row.sourceType || null,
|
|
170
|
+
sourceName: row.sourceName || null,
|
|
171
|
+
scopeKey: row.scopeKey || null,
|
|
172
|
+
ownerSessionId: row.ownerSessionId || row.parentSessionId || null,
|
|
173
|
+
visibility: sessionVisibility(row),
|
|
174
|
+
clientHostPid: positiveNumber(row.clientHostPid, 0) || null,
|
|
175
|
+
cwd: row.cwd || '',
|
|
176
|
+
desktopSession: desktopSession(row.desktopSession, row.cwd),
|
|
177
|
+
...(workspaceSummary(row.workspace) ? { workspace: workspaceSummary(row.workspace) } : {}),
|
|
178
|
+
provider: row.provider || null,
|
|
179
|
+
model: row.model || null,
|
|
180
|
+
agentTag: row.agentTag || null,
|
|
181
|
+
task_id: row.task_id || row.taskId || null,
|
|
182
|
+
permission: row.permission || null,
|
|
183
|
+
toolPermission: row.toolPermission || null,
|
|
184
|
+
messageCount: Math.max(0, Math.floor(Number(row.messageCount) || 0)),
|
|
185
|
+
title: cleanText(row.title, 100),
|
|
186
|
+
preview: cleanText(row.preview),
|
|
187
|
+
generation: typeof row.generation === 'number' ? row.generation : 0,
|
|
188
|
+
storageMtimeMs: positiveNumber(row.storageMtimeMs, 0),
|
|
189
|
+
storageSize: positiveNumber(row.storageSize, 0),
|
|
190
|
+
detachedReason: row.detachedReason || null,
|
|
191
|
+
importKey: importKey(row),
|
|
192
|
+
inheritedFromSessionId: inheritedFromSessionId(row),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Newest heartbeat of each Lead's live agents, from rows carrying `heartbeatAt`. */
|
|
197
|
+
export function agentHeartbeatsByOwner(rows) {
|
|
198
|
+
const agentHeartbeatByOwner = new Map();
|
|
199
|
+
for (const row of rows) {
|
|
200
|
+
const heartbeatAt = positiveNumber(row?.heartbeatAt, 0);
|
|
201
|
+
const ownerSessionId = String(row?.ownerSessionId || '').trim();
|
|
202
|
+
const owner = String(row?.owner || '')
|
|
203
|
+
.trim()
|
|
204
|
+
.toLowerCase();
|
|
205
|
+
const agent = String(row?.agent || '')
|
|
206
|
+
.trim()
|
|
207
|
+
.toLowerCase();
|
|
208
|
+
if (!heartbeatAt || !ownerSessionId || (owner !== 'agent' && (!agent || agent === 'lead'))) continue;
|
|
209
|
+
agentHeartbeatByOwner.set(ownerSessionId, Math.max(agentHeartbeatByOwner.get(ownerSessionId) || 0, heartbeatAt));
|
|
210
|
+
}
|
|
211
|
+
return agentHeartbeatByOwner;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function leadRowsWithAgentHeartbeat(rows) {
|
|
215
|
+
const agentHeartbeatByOwner = agentHeartbeatsByOwner(rows);
|
|
216
|
+
return rows
|
|
217
|
+
.filter((row) => row && isLeadVisibleRow(row))
|
|
218
|
+
.map((row) => {
|
|
219
|
+
const agentHeartbeatAt = agentHeartbeatByOwner.get(row.id) || 0;
|
|
220
|
+
return agentHeartbeatAt > 0 ? { ...row, agentHeartbeatAt } : row;
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function rowFromSession(session, heartbeatAt = 0) {
|
|
225
|
+
const messages = Array.isArray(session?.messages) ? session.messages : [];
|
|
226
|
+
const preview =
|
|
227
|
+
messages
|
|
228
|
+
.filter((message) => message?.role === 'user')
|
|
229
|
+
// Cold-path mirror of isSessionPreviewNoise's synthetic skips: compact
|
|
230
|
+
// handoffs and runtime notices must not become session titles.
|
|
231
|
+
.filter(
|
|
232
|
+
(message) =>
|
|
233
|
+
!/^\s*(?:a previous model worked on this task|re-attached after compaction\b|reference files:\s|\[mixdog-runtime\]|the async (?:agent|shell) task\b)/i.test(
|
|
234
|
+
messageText(message.content)
|
|
235
|
+
)
|
|
236
|
+
)
|
|
237
|
+
.map((message) => cleanText(messageText(message.content)))
|
|
238
|
+
.find(Boolean) || '';
|
|
239
|
+
return normalizedRow(
|
|
240
|
+
{
|
|
241
|
+
...session,
|
|
242
|
+
messageCount: messages.filter((message) => message?.role === 'user' || message?.role === 'assistant').length,
|
|
243
|
+
preview,
|
|
244
|
+
},
|
|
245
|
+
heartbeatAt
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ── Session file reconcile ──────────────────────────────────────────────────
|
|
250
|
+
// What one `sessions/<id>.json` name contributes to the catalog, with the full
|
|
251
|
+
// stamp { dev, ino, size, mtimeNs, ctimeNs } it was judged at (and the
|
|
252
|
+
// { mtimeMs, size } fingerprint the summary index records):
|
|
253
|
+
// indexed its summary-index row (the file matches the row's fingerprint)
|
|
254
|
+
// row a row strictly parsed from the file (the index does not describe it)
|
|
255
|
+
// none parsed, but yields no row (ambiguous or foreign record)
|
|
256
|
+
// unreadable present but not statable/readable: the index row is retained
|
|
257
|
+
const UNREADABLE_ENTRY = Object.freeze({ kind: 'unreadable', stamp: null, mtimeMs: 0, size: 0 });
|
|
258
|
+
|
|
259
|
+
/** `stat().mtimeMs` of a bigint stat: Node's own sec * 1e3 + nsec / 1e6. */
|
|
260
|
+
function stampMtimeMs(stamp) {
|
|
261
|
+
return Number(stamp.mtimeNs / 1_000_000_000n) * 1e3 + Number(stamp.mtimeNs % 1_000_000_000n) / 1e6;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** The entry for `<storageId>.json`, or null when the file is provably gone. */
|
|
265
|
+
function reconcileSessionFile(directory, storageId, retained, forceRead) {
|
|
266
|
+
const path = join(directory, `${storageId}.json`);
|
|
267
|
+
let stamp;
|
|
268
|
+
try {
|
|
269
|
+
stamp = statSessionStamp(path);
|
|
270
|
+
} catch (error) {
|
|
271
|
+
// Provably gone: the row legitimately disappears with the file.
|
|
272
|
+
return STAMP_ABSENT_CODES.has(error?.code) ? null : UNREADABLE_ENTRY;
|
|
273
|
+
}
|
|
274
|
+
const fingerprint = { stamp, mtimeMs: stampMtimeMs(stamp), size: Number(stamp.size) };
|
|
275
|
+
if (
|
|
276
|
+
!forceRead &&
|
|
277
|
+
retained &&
|
|
278
|
+
retained.storageMtimeMs > 0 &&
|
|
279
|
+
retained.storageSize > 0 &&
|
|
280
|
+
retained.storageMtimeMs === fingerprint.mtimeMs &&
|
|
281
|
+
retained.storageSize === fingerprint.size
|
|
282
|
+
) {
|
|
283
|
+
return { kind: 'indexed', ...fingerprint };
|
|
284
|
+
}
|
|
285
|
+
// A file the index does not describe (never saved by the store, or
|
|
286
|
+
// saved after the index was written) is parsed once per full stamp.
|
|
287
|
+
const read = stampedDerivation('row', path, (text) => {
|
|
288
|
+
// Same strict authority as the store: a duplicate/ambiguous top-level
|
|
289
|
+
// record or a foreign identity is not this file's session.
|
|
290
|
+
const record = readTopLevelLifecycleRecord(text);
|
|
291
|
+
if (isLifecycleUnreadable(record) || record.id !== storageId) return null;
|
|
292
|
+
return rowFromSession(record.doc, 0);
|
|
293
|
+
});
|
|
294
|
+
if (read.state === PROBE_ABSENT) return null;
|
|
295
|
+
// Exists and could not be read (EACCES/EIO): retain the last known row
|
|
296
|
+
// rather than let a transient IO error delete it from view.
|
|
297
|
+
if (read.state !== PROBE_PRESENT) return UNREADABLE_ENTRY;
|
|
298
|
+
if (!read.value) return { kind: 'none', ...fingerprint };
|
|
299
|
+
return {
|
|
300
|
+
kind: 'row',
|
|
301
|
+
row: deepFreeze({
|
|
302
|
+
...structuredClone(read.value),
|
|
303
|
+
storageMtimeMs: fingerprint.mtimeMs,
|
|
304
|
+
storageSize: fingerprint.size,
|
|
305
|
+
}),
|
|
306
|
+
...fingerprint,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Cached rows are shared by every listing that hands them out: frozen, so
|
|
311
|
+
* no caller can edit what the next caller sees. */
|
|
312
|
+
function deepFreeze(value) {
|
|
313
|
+
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
|
|
314
|
+
Object.freeze(value);
|
|
315
|
+
for (const key of Object.keys(value)) deepFreeze(value[key]);
|
|
316
|
+
}
|
|
317
|
+
return value;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** The session ids (`<id>.json`) and live sidecar ids (`<id>.hb`) of
|
|
321
|
+
* `directory`, or null when it cannot be enumerated. */
|
|
322
|
+
function listSessionNames(directory) {
|
|
323
|
+
let names;
|
|
324
|
+
try {
|
|
325
|
+
names = readdirSync(directory);
|
|
326
|
+
} catch {
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
const sessionIds = [];
|
|
330
|
+
const heartbeatIds = [];
|
|
331
|
+
for (const filename of names) {
|
|
332
|
+
if (filename.endsWith('.json')) sessionIds.push(filename.slice(0, -5));
|
|
333
|
+
else if (filename.endsWith('.hb') && isStoredSessionId(filename.slice(0, -3))) {
|
|
334
|
+
heartbeatIds.push(filename.slice(0, -3));
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return { sessionIds, heartbeatIds };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Every `.json` entry and `.hb` sidecar id of an enumerable `directory`, or
|
|
341
|
+
* null when it cannot be enumerated. */
|
|
342
|
+
function scanSessionEntries(directory, indexRowsById, forceRead) {
|
|
343
|
+
const names = listSessionNames(directory);
|
|
344
|
+
if (!names) return null;
|
|
345
|
+
const entries = new Map();
|
|
346
|
+
for (const storageId of names.sessionIds) {
|
|
347
|
+
const entry = reconcileSessionFile(directory, storageId, indexRowsById.get(storageId), forceRead);
|
|
348
|
+
if (entry) entries.set(storageId, entry);
|
|
349
|
+
}
|
|
350
|
+
return { entries, heartbeatIds: names.heartbeatIds };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** The row an entry contributes (before liveness overlays), or null. */
|
|
354
|
+
function entryRow(entry, id, indexRowsById) {
|
|
355
|
+
if (!entry || entry.kind === 'none') return null;
|
|
356
|
+
return (entry.kind === 'row' ? entry.row : indexRowsById.get(id)) || null;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Catalog rows of reconciled `entries`, in catalog order. */
|
|
360
|
+
function assembleCatalog(entries, indexRowsById, heartbeatMtimes) {
|
|
361
|
+
const rows = [];
|
|
362
|
+
for (const [id, entry] of entries) {
|
|
363
|
+
const row = entryRow(entry, id, indexRowsById);
|
|
364
|
+
if (row) rows.push({ ...row, heartbeatAt: positiveNumber(heartbeatMtimes.get(id), 0) });
|
|
365
|
+
}
|
|
366
|
+
return leadRowsWithAgentHeartbeat(rows).sort(byCatalogOrder);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Read-only scan of the authoritative session files.
|
|
371
|
+
* Returns `null` when the directory itself is NOT enumerable (unreadable
|
|
372
|
+
* stat / readdir): that is not "no sessions", and the caller must keep the
|
|
373
|
+
* authority it already has (the index rows) instead of publishing an empty
|
|
374
|
+
* catalog. `indexRowsById` carries those rows so unchanged files can reuse the
|
|
375
|
+
* durable summary and a single UNREADABLE file retains its last known row
|
|
376
|
+
* instead of vanishing.
|
|
377
|
+
*/
|
|
378
|
+
export function scanSessionFiles(heartbeatMtimes, indexRowsById, forceRead) {
|
|
379
|
+
const directory = join(dataDir(), 'sessions');
|
|
380
|
+
const dirProbe = probePath(directory);
|
|
381
|
+
if (dirProbe.state === PROBE_ABSENT) return [];
|
|
382
|
+
if (dirProbe.state !== PROBE_PRESENT) return null;
|
|
383
|
+
const scan = scanSessionEntries(directory, indexRowsById, forceRead);
|
|
384
|
+
return scan && assembleCatalog(scan.entries, indexRowsById, heartbeatMtimes);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Listing from the index rows alone, or null without a valid index. */
|
|
388
|
+
export function indexListing(index, heartbeatMtimes) {
|
|
389
|
+
return (
|
|
390
|
+
index.rows &&
|
|
391
|
+
leadRowsWithAgentHeartbeat(
|
|
392
|
+
index.rows.map((row) => ({ ...row, heartbeatAt: positiveNumber(heartbeatMtimes.get(row.id), 0) }))
|
|
393
|
+
).sort(byCatalogOrder)
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// ── Summary index ───────────────────────────────────────────────────────────
|
|
398
|
+
// Base + delta log, parsed once per full stamp of the pair. Stamps are taken
|
|
399
|
+
// BEFORE the reads: a write landing mid-read changes them, and the next call
|
|
400
|
+
// reads again. Every session update appends to the log; when the log has only
|
|
401
|
+
// grown — its bytes up to what was applied last are byte-for-byte unchanged
|
|
402
|
+
// and the base stamp is the same — only the appended lines are applied. A
|
|
403
|
+
// compaction (new base, truncated log) or any other rewrite fails that check
|
|
404
|
+
// and reloads both. Only newline-terminated lines are applied: a line still
|
|
405
|
+
// being appended is picked up whole by the next call.
|
|
406
|
+
const NO_INDEX_ROWS = new Map();
|
|
407
|
+
const NEWLINE = 0x0a;
|
|
408
|
+
let summaryIndexSerial = 0;
|
|
409
|
+
|
|
410
|
+
function sameOptionalStamp(left, right) {
|
|
411
|
+
return (!left && !right) || sameSessionStamp(left, right);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** `{ bytes, failed }`: the log, empty when absent; `failed` when it exists
|
|
415
|
+
* and could not be read (EACCES/EIO), which is never cached. */
|
|
416
|
+
function readLogBytes(logPath) {
|
|
417
|
+
try {
|
|
418
|
+
return { bytes: readFileSync(logPath), failed: false };
|
|
419
|
+
} catch (error) {
|
|
420
|
+
return { bytes: Buffer.alloc(0), failed: !STAMP_ABSENT_CODES.has(error?.code) };
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function freshIndex(indexPath, baseStamp, logStamp, logBytes) {
|
|
425
|
+
return {
|
|
426
|
+
serial: ++summaryIndexSerial,
|
|
427
|
+
// The index this one extends by appended log lines, and the ids they touched.
|
|
428
|
+
parentSerial: 0,
|
|
429
|
+
touchedIds: null,
|
|
430
|
+
indexPath,
|
|
431
|
+
baseStamp,
|
|
432
|
+
logStamp,
|
|
433
|
+
// The log prefix applied: every byte up to and including its last newline.
|
|
434
|
+
logBytes: logBytes.subarray(0, logBytes.lastIndexOf(NEWLINE) + 1),
|
|
435
|
+
// `rows` null = missing, unreadable or malformed: the files are the authority.
|
|
436
|
+
rows: null,
|
|
437
|
+
rowsById: NO_INDEX_ROWS,
|
|
438
|
+
// JSON of every row of base + log by id. Without a valid base the log's
|
|
439
|
+
// rows are still recorded here: they say which sessions an index-updating
|
|
440
|
+
// writer touched, even though they are not used as rows.
|
|
441
|
+
signatures: new Map(),
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** The summary index, extending `cached` by appended log lines when it can. */
|
|
446
|
+
function readSummaryIndex(cached) {
|
|
447
|
+
const indexPath = join(dataDir(), 'session-summaries.json');
|
|
448
|
+
const baseStamp = observeStamp(indexPath);
|
|
449
|
+
const logStamp = observeStamp(summaryLogPath(indexPath));
|
|
450
|
+
if (cached?.indexPath !== indexPath) cached = null;
|
|
451
|
+
if (cached && sameOptionalStamp(cached.baseStamp, baseStamp) && sameOptionalStamp(cached.logStamp, logStamp)) {
|
|
452
|
+
return { index: cached, cacheable: true };
|
|
453
|
+
}
|
|
454
|
+
// The delta log (per-session updates since the last compaction) is read
|
|
455
|
+
// before the base; see store-summary-log.mjs.
|
|
456
|
+
const log = readLogBytes(summaryLogPath(indexPath));
|
|
457
|
+
const index = freshIndex(indexPath, baseStamp, logStamp, log.bytes);
|
|
458
|
+
const appliedBytes = cached?.logBytes.length ?? 0;
|
|
459
|
+
if (
|
|
460
|
+
!log.failed &&
|
|
461
|
+
cached?.rows &&
|
|
462
|
+
sameOptionalStamp(cached.baseStamp, baseStamp) &&
|
|
463
|
+
index.logBytes.length >= appliedBytes &&
|
|
464
|
+
index.logBytes.compare(cached.logBytes, 0, appliedBytes, 0, appliedBytes) === 0
|
|
465
|
+
) {
|
|
466
|
+
const rowsById = new Map(cached.rowsById);
|
|
467
|
+
const touched = new Set();
|
|
468
|
+
for (const line of index.logBytes.toString('utf8', appliedBytes).split('\n')) {
|
|
469
|
+
if (!line) continue;
|
|
470
|
+
let op;
|
|
471
|
+
try {
|
|
472
|
+
op = JSON.parse(line);
|
|
473
|
+
} catch {
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
// Same semantics as applySummaryLogText: last writer wins per id.
|
|
477
|
+
if (op?.u && typeof op.u.id === 'string') {
|
|
478
|
+
const row = normalizedRow(op.u, 0);
|
|
479
|
+
if (row) rowsById.set(row.id, deepFreeze(row));
|
|
480
|
+
else rowsById.delete(op.u.id);
|
|
481
|
+
touched.add(op.u.id);
|
|
482
|
+
} else if (Array.isArray(op?.r)) {
|
|
483
|
+
for (const id of op.r) {
|
|
484
|
+
rowsById.delete(id);
|
|
485
|
+
touched.add(id);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
index.parentSerial = cached.serial;
|
|
490
|
+
index.touchedIds = touched;
|
|
491
|
+
index.rows = [...rowsById.values()];
|
|
492
|
+
index.rowsById = rowsById;
|
|
493
|
+
index.signatures = new Map(cached.signatures);
|
|
494
|
+
for (const id of touched) {
|
|
495
|
+
const row = rowsById.get(id);
|
|
496
|
+
if (row) index.signatures.set(id, JSON.stringify(row));
|
|
497
|
+
else index.signatures.delete(id);
|
|
498
|
+
}
|
|
499
|
+
return { index, cacheable: true };
|
|
500
|
+
}
|
|
501
|
+
const indexRead = readTextFile(indexPath);
|
|
502
|
+
let baseRows = null;
|
|
503
|
+
if (indexRead.state === PROBE_PRESENT) {
|
|
504
|
+
try {
|
|
505
|
+
const raw = JSON.parse(indexRead.text);
|
|
506
|
+
if (Number(raw?.version) === SESSION_SUMMARY_INDEX_VERSION) baseRows = Array.isArray(raw.rows) ? raw.rows : [];
|
|
507
|
+
} catch {
|
|
508
|
+
/* malformed sidecar: the files are the authority */
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
const rows = applySummaryLogText({ rows: baseRows ?? [] }, index.logBytes.toString('utf8'))
|
|
512
|
+
.rows.map((row) => normalizedRow(row, 0))
|
|
513
|
+
.filter(Boolean)
|
|
514
|
+
.map(deepFreeze);
|
|
515
|
+
for (const row of rows) index.signatures.set(row.id, JSON.stringify(row));
|
|
516
|
+
if (baseRows) {
|
|
517
|
+
index.rows = rows;
|
|
518
|
+
index.rowsById = new Map(rows.map((row) => [row.id, row]));
|
|
519
|
+
}
|
|
520
|
+
// A read that failed (EACCES/EIO) is not cached: the next call reads again.
|
|
521
|
+
return {
|
|
522
|
+
index,
|
|
523
|
+
cacheable: !log.failed && (indexRead.state === PROBE_PRESENT || indexRead.state === PROBE_ABSENT),
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** Ids whose index row differs between two loads (added, changed or removed). */
|
|
528
|
+
function changedIndexIds(previous, next) {
|
|
529
|
+
const ids = [];
|
|
530
|
+
const candidates = next.parentSerial === previous.serial ? next.touchedIds : next.signatures.keys();
|
|
531
|
+
for (const id of candidates) if (previous.signatures.get(id) !== next.signatures.get(id)) ids.push(id);
|
|
532
|
+
if (next.parentSerial !== previous.serial) {
|
|
533
|
+
for (const id of previous.signatures.keys()) if (!next.signatures.has(id)) ids.push(id);
|
|
534
|
+
}
|
|
535
|
+
return ids;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function sameMtimes(left, right) {
|
|
539
|
+
if (!right || left.size !== right.size) return false;
|
|
540
|
+
for (const [id, mtimeMs] of left) if (right.get(id) !== mtimeMs) return false;
|
|
541
|
+
return true;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// ── Ordinary catalog ────────────────────────────────────────────────────────
|
|
545
|
+
// The ordinary listing keeps its last reconcile and answers an unchanged call
|
|
546
|
+
// from cheap stamps alone: the index pair (above), the sessions DIRECTORY and
|
|
547
|
+
// the handful of live `.hb` sidecars — no per-session-file filesystem work.
|
|
548
|
+
//
|
|
549
|
+
// The directory stamp changes on every create, delete and rename in it
|
|
550
|
+
// (including a temp-file + rename replace), and NOT on an in-place write
|
|
551
|
+
// (measured on NTFS; POSIX directories behave the same). So:
|
|
552
|
+
// - directory stamp changed → one enumeration; names that appeared are
|
|
553
|
+
// reconciled, names that vanished are dropped, names present before and
|
|
554
|
+
// after keep their entry. A stamp still inside the racy window is
|
|
555
|
+
// re-enumerated on every call until it settles: on a coarse-clock
|
|
556
|
+
// filesystem a create/delete in the same tick would leave it unchanged;
|
|
557
|
+
// - index rows changed (this process, another process, a compaction) → only
|
|
558
|
+
// those ids are re-reconciled — this is what refreshes a session an
|
|
559
|
+
// index-updating writer replaced under its existing name;
|
|
560
|
+
// - a file replaced (rename) or rewritten in place by a writer that does not
|
|
561
|
+
// update the index keeps its name and its index row: a background pass
|
|
562
|
+
// (async stats, never blocking a listing) finds it, the next listing
|
|
563
|
+
// re-reconciles just that file, and subscribers are told to list again.
|
|
564
|
+
// A pass runs at most once per CATALOG_VERIFY_INTERVAL_MS and at the
|
|
565
|
+
// latest that long after a listing.
|
|
566
|
+
const CATALOG_VERIFY_INTERVAL_MS = 10_000;
|
|
567
|
+
const CATALOG_VERIFY_BATCH = 32;
|
|
568
|
+
|
|
569
|
+
function unlistableRefresh() {
|
|
570
|
+
return { rows: null, catalog: null, fresh: false, changedIds: null, absent: false };
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function newSessionCatalog(directory, dirStamp, index, scan) {
|
|
574
|
+
return {
|
|
575
|
+
directory,
|
|
576
|
+
dirStamp,
|
|
577
|
+
dirSettled: isSettledSessionStamp(dirStamp),
|
|
578
|
+
index,
|
|
579
|
+
entries: scan.entries,
|
|
580
|
+
heartbeatIds: scan.heartbeatIds,
|
|
581
|
+
heartbeatMtimes: null,
|
|
582
|
+
rows: null,
|
|
583
|
+
dirty: new Set(),
|
|
584
|
+
verifiedAt: Date.now(),
|
|
585
|
+
verifying: null,
|
|
586
|
+
rearm: false,
|
|
587
|
+
timer: null,
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** Brings an existing `catalog` up to date with the index, the directory and
|
|
592
|
+
* the `hinted` ids: `{ changed, changedIds }`, or null when the directory
|
|
593
|
+
* can no longer be enumerated. */
|
|
594
|
+
function reconcileKnownCatalog(catalog, directory, dirStamp, index, hinted) {
|
|
595
|
+
let changed = false;
|
|
596
|
+
const changedIds = new Set();
|
|
597
|
+
// Files the background pass found with a new full stamp: their index row
|
|
598
|
+
// can still match size + mtime (a same-length, mtime-preserving
|
|
599
|
+
// replacement), so they are re-derived from the file itself.
|
|
600
|
+
const forced = catalog.dirty;
|
|
601
|
+
catalog.dirty = new Set();
|
|
602
|
+
const ids = new Set([...forced, ...hinted]);
|
|
603
|
+
if (catalog.index !== index) {
|
|
604
|
+
for (const id of changedIndexIds(catalog.index, index)) ids.add(id);
|
|
605
|
+
catalog.index = index;
|
|
606
|
+
changed = true;
|
|
607
|
+
}
|
|
608
|
+
if (!catalog.dirSettled || !sameSessionStamp(catalog.dirStamp, dirStamp)) {
|
|
609
|
+
const names = listSessionNames(directory);
|
|
610
|
+
if (!names) return null;
|
|
611
|
+
const present = new Set(names.sessionIds);
|
|
612
|
+
for (const id of catalog.entries.keys()) {
|
|
613
|
+
if (present.has(id)) continue;
|
|
614
|
+
catalog.entries.delete(id);
|
|
615
|
+
changedIds.add(id);
|
|
616
|
+
changed = true;
|
|
617
|
+
}
|
|
618
|
+
for (const id of names.sessionIds) if (!catalog.entries.has(id)) ids.add(id);
|
|
619
|
+
catalog.heartbeatIds = names.heartbeatIds;
|
|
620
|
+
catalog.dirStamp = dirStamp;
|
|
621
|
+
catalog.dirSettled = isSettledSessionStamp(dirStamp);
|
|
622
|
+
}
|
|
623
|
+
for (const id of ids) {
|
|
624
|
+
const entry = reconcileSessionFile(directory, id, index.rowsById.get(id), forced.has(id));
|
|
625
|
+
if (entry) catalog.entries.set(id, entry);
|
|
626
|
+
else catalog.entries.delete(id);
|
|
627
|
+
changedIds.add(id);
|
|
628
|
+
changed = true;
|
|
629
|
+
}
|
|
630
|
+
return { changed, changedIds };
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/** Liveness: `.hb` sidecars are rewritten in place, so their mtimes are
|
|
634
|
+
* probed every call (absent or unreadable contributes nothing). True when
|
|
635
|
+
* they differ from the previous probe. */
|
|
636
|
+
function refreshHeartbeatMtimes(catalog, directory) {
|
|
637
|
+
const heartbeatMtimes = new Map();
|
|
638
|
+
for (const id of catalog.heartbeatIds) {
|
|
639
|
+
const probe = probePath(join(directory, `${id}.hb`));
|
|
640
|
+
if (probe.state === PROBE_PRESENT && probe.mtimeMs > 0) heartbeatMtimes.set(id, probe.mtimeMs);
|
|
641
|
+
}
|
|
642
|
+
const differs = !sameMtimes(heartbeatMtimes, catalog.heartbeatMtimes);
|
|
643
|
+
catalog.heartbeatMtimes = heartbeatMtimes;
|
|
644
|
+
return differs;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/** Marks every entry of `snapshot` whose file no longer has the full stamp it
|
|
648
|
+
* was judged at dirty (a replacement brings a new inode and change time even
|
|
649
|
+
* when its size and mtime match). Stops early once `isCurrent()` is false. */
|
|
650
|
+
async function markChangedEntriesDirty(catalog, snapshot, isCurrent) {
|
|
651
|
+
for (let start = 0; start < snapshot.length; start += CATALOG_VERIFY_BATCH) {
|
|
652
|
+
if (!isCurrent()) return;
|
|
653
|
+
await Promise.all(
|
|
654
|
+
snapshot.slice(start, start + CATALOG_VERIFY_BATCH).map(async ([id, entry]) => {
|
|
655
|
+
let changed;
|
|
656
|
+
try {
|
|
657
|
+
const info = await stat(join(catalog.directory, `${id}.json`), { bigint: true });
|
|
658
|
+
changed = entry.kind === 'unreadable' || !sameSessionStamp(sessionStampOf(info), entry.stamp);
|
|
659
|
+
} catch (error) {
|
|
660
|
+
changed = entry.kind !== 'unreadable' || STAMP_ABSENT_CODES.has(error?.code);
|
|
661
|
+
}
|
|
662
|
+
if (changed && catalog.entries.get(id) === entry) catalog.dirty.add(id);
|
|
663
|
+
})
|
|
664
|
+
);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* One ordinary catalog with its own index cache, reconcile state, background
|
|
670
|
+
* pass and change listeners.
|
|
671
|
+
*/
|
|
672
|
+
export function createOrdinaryCatalog() {
|
|
673
|
+
let summaryIndexCache = null;
|
|
674
|
+
let sessionCatalog = null;
|
|
675
|
+
// Ids a writer of this process just changed on disk (storage hints): the
|
|
676
|
+
// next refresh re-reconciles them against the index row and the file.
|
|
677
|
+
let hintedIds = new Set();
|
|
678
|
+
const listeners = new Set();
|
|
679
|
+
|
|
680
|
+
function loadIndex() {
|
|
681
|
+
const { index, cacheable } = readSummaryIndex(summaryIndexCache);
|
|
682
|
+
if (cacheable) summaryIndexCache = index;
|
|
683
|
+
return index;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function dropSessionCatalog() {
|
|
687
|
+
if (sessionCatalog?.timer) clearTimeout(sessionCatalog.timer);
|
|
688
|
+
sessionCatalog = null;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Reconciles the catalog against storage: `{ rows, catalog, fresh,
|
|
693
|
+
* changedIds, absent }`. `rows` are the listing (assembled only when
|
|
694
|
+
* `assemble`), [] when the sessions directory is absent, null when it
|
|
695
|
+
* cannot be enumerated. `fresh` means the catalog was (re)built from a full
|
|
696
|
+
* scan; otherwise `changedIds` are the ids whose entry was dropped or
|
|
697
|
+
* re-reconciled by this call.
|
|
698
|
+
*/
|
|
699
|
+
function refresh(index, { assemble = true } = {}) {
|
|
700
|
+
const directory = join(dataDir(), 'sessions');
|
|
701
|
+
let dirStamp;
|
|
702
|
+
try {
|
|
703
|
+
dirStamp = statSessionStamp(directory);
|
|
704
|
+
} catch (error) {
|
|
705
|
+
dropSessionCatalog();
|
|
706
|
+
const absent = STAMP_ABSENT_CODES.has(error?.code);
|
|
707
|
+
return { rows: absent ? [] : null, catalog: null, fresh: false, changedIds: null, absent };
|
|
708
|
+
}
|
|
709
|
+
let catalog = sessionCatalog?.directory === directory ? sessionCatalog : null;
|
|
710
|
+
const fresh = !catalog;
|
|
711
|
+
let changed = true;
|
|
712
|
+
let changedIds = new Set();
|
|
713
|
+
const hinted = hintedIds;
|
|
714
|
+
hintedIds = new Set();
|
|
715
|
+
if (fresh) {
|
|
716
|
+
const scan = scanSessionEntries(directory, index.rowsById, false);
|
|
717
|
+
if (!scan) return unlistableRefresh();
|
|
718
|
+
catalog = newSessionCatalog(directory, dirStamp, index, scan);
|
|
719
|
+
sessionCatalog = catalog;
|
|
720
|
+
} else {
|
|
721
|
+
const reconciled = reconcileKnownCatalog(catalog, directory, dirStamp, index, hinted);
|
|
722
|
+
if (!reconciled) {
|
|
723
|
+
dropSessionCatalog();
|
|
724
|
+
return unlistableRefresh();
|
|
725
|
+
}
|
|
726
|
+
({ changed, changedIds } = reconciled);
|
|
727
|
+
}
|
|
728
|
+
if (refreshHeartbeatMtimes(catalog, directory)) changed = true;
|
|
729
|
+
if (changed) catalog.rows = null;
|
|
730
|
+
if (assemble) rowsOf(catalog);
|
|
731
|
+
armCatalogVerify(catalog);
|
|
732
|
+
// Rows (and everything in them) are frozen: callers share them read-only.
|
|
733
|
+
return { rows: assemble ? catalog.rows.slice() : null, catalog, fresh, changedIds, absent: false };
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/** The (frozen) listing of a reconciled catalog, assembled on demand. */
|
|
737
|
+
function rowsOf(catalog) {
|
|
738
|
+
catalog.rows ||= assembleCatalog(catalog.entries, catalog.index.rowsById, catalog.heartbeatMtimes).map(
|
|
739
|
+
Object.freeze
|
|
740
|
+
);
|
|
741
|
+
return catalog.rows;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/** Schedules the next background pass: CATALOG_VERIFY_INTERVAL_MS after the
|
|
745
|
+
* last one started, unless one is already scheduled or running. */
|
|
746
|
+
function armCatalogVerify(catalog) {
|
|
747
|
+
if (catalog.verifying) {
|
|
748
|
+
// A listing during a pass: the next pass is scheduled when this one ends.
|
|
749
|
+
catalog.rearm = true;
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
if (catalog.timer) return;
|
|
753
|
+
const delay = Math.max(0, catalog.verifiedAt + CATALOG_VERIFY_INTERVAL_MS - Date.now());
|
|
754
|
+
catalog.timer = setTimeout(() => {
|
|
755
|
+
catalog.timer = null;
|
|
756
|
+
if (sessionCatalog === catalog) startCatalogVerify(catalog);
|
|
757
|
+
}, delay);
|
|
758
|
+
catalog.timer.unref?.();
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function startCatalogVerify(catalog) {
|
|
762
|
+
if (catalog.verifying) return catalog.verifying;
|
|
763
|
+
if (catalog.timer) clearTimeout(catalog.timer);
|
|
764
|
+
catalog.timer = null;
|
|
765
|
+
catalog.verifiedAt = Date.now();
|
|
766
|
+
const verifying = verifyCatalogEntries(catalog, [...catalog.entries]).finally(() => {
|
|
767
|
+
if (catalog.verifying !== verifying) return;
|
|
768
|
+
catalog.verifying = null;
|
|
769
|
+
if (!catalog.rearm) return;
|
|
770
|
+
catalog.rearm = false;
|
|
771
|
+
if (sessionCatalog === catalog) armCatalogVerify(catalog);
|
|
772
|
+
});
|
|
773
|
+
catalog.verifying = verifying;
|
|
774
|
+
return verifying;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/** Background pass: marks changed entries dirty and tells the listeners. */
|
|
778
|
+
async function verifyCatalogEntries(catalog, snapshot) {
|
|
779
|
+
await markChangedEntriesDirty(catalog, snapshot, () => sessionCatalog === catalog);
|
|
780
|
+
if (sessionCatalog !== catalog || catalog.dirty.size === 0) return;
|
|
781
|
+
for (const listener of listeners) {
|
|
782
|
+
try {
|
|
783
|
+
listener();
|
|
784
|
+
} catch {
|
|
785
|
+
/* a listener's failure is its own */
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
return {
|
|
791
|
+
loadIndex,
|
|
792
|
+
refresh,
|
|
793
|
+
rowsOf,
|
|
794
|
+
/** The row `id` contributes to the current catalog (before overlays). */
|
|
795
|
+
entryRow: (catalog, id) => entryRow(catalog.entries.get(id), id, catalog.index.rowsById),
|
|
796
|
+
current: () => sessionCatalog,
|
|
797
|
+
/** `listener()` runs when a background pass finds a session file that
|
|
798
|
+
* changed without a directory or index change. Returns the unsubscribe. */
|
|
799
|
+
subscribe(listener) {
|
|
800
|
+
listeners.add(listener);
|
|
801
|
+
return () => listeners.delete(listener);
|
|
802
|
+
},
|
|
803
|
+
/** Run a background verification now (or join the running one). */
|
|
804
|
+
verify: () => (sessionCatalog ? startCatalogVerify(sessionCatalog) : Promise.resolve()),
|
|
805
|
+
/** Storage hints: `ids` changed on disk (a save landed, a delete removed
|
|
806
|
+
* the file). The next refresh stats them and re-reads a file its index
|
|
807
|
+
* row does not describe; a gone file drops its entry. Without a catalog
|
|
808
|
+
* the next refresh is a full scan anyway. */
|
|
809
|
+
hint(ids) {
|
|
810
|
+
if (sessionCatalog) for (const id of ids) hintedIds.add(id);
|
|
811
|
+
},
|
|
812
|
+
/** Forget the catalog and the parsed summary index. */
|
|
813
|
+
clear() {
|
|
814
|
+
dropSessionCatalog();
|
|
815
|
+
summaryIndexCache = null;
|
|
816
|
+
hintedIds = new Set();
|
|
817
|
+
},
|
|
818
|
+
};
|
|
819
|
+
}
|