@vellumai/assistant 0.6.4 → 0.6.6
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/.prettierignore +5 -0
- package/AGENTS.md +9 -1
- package/ARCHITECTURE.md +43 -49
- package/Dockerfile +17 -3
- package/README.md +3 -4
- package/__tests__/permissions/gateway-threshold-reader.test.ts +283 -0
- package/bun.lock +8 -3
- package/docs/architecture/integrations.md +33 -59
- package/docs/architecture/memory.md +25 -30
- package/docs/architecture/security.md +19 -18
- package/docs/browser-use-architecture-phase2.md +63 -20
- package/docs/error-handling.md +111 -0
- package/docs/plugins.md +761 -0
- package/docs/skills.md +10 -10
- package/docs/stt-provider-onboarding.md +2 -1
- package/examples/plugins/echo/README.md +132 -0
- package/examples/plugins/echo/package.json +17 -0
- package/examples/plugins/echo/register.ts +187 -0
- package/knip.json +9 -2
- package/node_modules/@vellumai/ces-contracts/package.json +2 -1
- package/node_modules/@vellumai/ces-contracts/src/__tests__/trust-rules.test.ts +471 -0
- package/node_modules/@vellumai/ces-contracts/src/trust-rules.ts +398 -4
- package/node_modules/@vellumai/credential-storage/bun.lock +2 -2
- package/node_modules/@vellumai/credential-storage/package.json +2 -2
- package/node_modules/@vellumai/credential-storage/src/oauth-runtime.ts +20 -2
- package/node_modules/@vellumai/egress-proxy/bun.lock +2 -2
- package/node_modules/@vellumai/egress-proxy/package.json +2 -2
- package/node_modules/@vellumai/egress-proxy/src/types.ts +19 -0
- package/openapi.yaml +334 -78
- package/package.json +6 -3
- package/scripts/generate-openapi.ts +50 -11
- package/src/__tests__/agent-loop-callsite-precedence.test.ts +318 -0
- package/src/__tests__/agent-loop-sentry-hygiene.test.ts +137 -0
- package/src/__tests__/agent-loop.test.ts +112 -1
- package/src/__tests__/anthropic-error-formatting.test.ts +98 -0
- package/src/__tests__/anthropic-provider.test.ts +171 -2
- package/src/__tests__/app-compiler.test.ts +57 -0
- package/src/__tests__/approval-cascade.test.ts +36 -10
- package/src/__tests__/approval-routes-http.test.ts +134 -10
- package/src/__tests__/assistant-attachments.test.ts +44 -0
- package/src/__tests__/assistant-feature-flags-integration.test.ts +29 -0
- package/src/__tests__/auto-analysis-end-to-end.test.ts +1 -0
- package/src/__tests__/avatar-generator.test.ts +4 -2
- package/src/__tests__/browser-fill-credential.test.ts +1 -1
- package/src/__tests__/browser-identifier-parity-guard.test.ts +53 -0
- package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +23 -33
- package/src/__tests__/browser-skill-endstate.test.ts +51 -182
- package/src/__tests__/btw-routes.test.ts +47 -1
- package/src/__tests__/bundled-asset.test.ts +6 -6
- package/src/__tests__/call-controller.test.ts +1 -2
- package/src/__tests__/call-site-routing-provider.test.ts +214 -0
- package/src/__tests__/catalog-cache.test.ts +96 -4
- package/src/__tests__/channel-approval-routes.test.ts +4 -4
- package/src/__tests__/channel-reply-delivery.test.ts +300 -2
- package/src/__tests__/checker.test.ts +870 -655
- package/src/__tests__/circuit-breaker-pipeline.test.ts +406 -0
- package/src/__tests__/cli-command-risk-guard.test.ts +30 -33
- package/src/__tests__/compaction-events.test.ts +501 -0
- package/src/__tests__/compaction-pipeline.test.ts +210 -0
- package/src/__tests__/compaction-strip-metadata-clear.test.ts +181 -0
- package/src/__tests__/compaction-timeout-recovery.test.ts +262 -0
- package/src/__tests__/compaction.benchmark.test.ts +1 -1
- package/src/__tests__/config-analysis.test.ts +11 -28
- package/src/__tests__/config-loader-backfill.test.ts +174 -0
- package/src/__tests__/config-loader-corrupt.test.ts +183 -0
- package/src/__tests__/config-loader-quarantine-bulletin.test.ts +202 -0
- package/src/__tests__/config-model-image-provider.test.ts +110 -0
- package/src/__tests__/config-schema-cmd.test.ts +11 -5
- package/src/__tests__/config-schema.test.ts +440 -114
- package/src/__tests__/config-watcher-cleanup-throttle.test.ts +0 -4
- package/src/__tests__/config-watcher.test.ts +2 -2
- package/src/__tests__/contact-store-user-file.test.ts +72 -73
- package/src/__tests__/contacts-tools.test.ts +26 -0
- package/src/__tests__/contacts-write.test.ts +4 -4
- package/src/__tests__/context-overflow-policy.test.ts +7 -7
- package/src/__tests__/context-token-estimator.test.ts +191 -1
- package/src/__tests__/context-window-manager.test.ts +883 -4
- package/src/__tests__/conversation-abort-tool-results.test.ts +32 -15
- package/src/__tests__/conversation-agent-loop-overflow.test.ts +86 -46
- package/src/__tests__/conversation-agent-loop.test.ts +435 -216
- package/src/__tests__/conversation-attachments.test.ts +1 -1
- package/src/__tests__/conversation-confirmation-signals.test.ts +36 -10
- package/src/__tests__/conversation-error.test.ts +37 -6
- package/src/__tests__/conversation-history-web-search.test.ts +7 -0
- package/src/__tests__/conversation-init.benchmark.test.ts +34 -12
- package/src/__tests__/conversation-lifecycle.test.ts +336 -0
- package/src/__tests__/conversation-load-history-repair.test.ts +27 -10
- package/src/__tests__/conversation-pairing.test.ts +174 -10
- package/src/__tests__/conversation-pre-run-repair.test.ts +32 -15
- package/src/__tests__/conversation-process-callsite.test.ts +309 -0
- package/src/__tests__/conversation-provider-retry-repair.test.ts +44 -21
- package/src/__tests__/conversation-queue.test.ts +68 -38
- package/src/__tests__/conversation-routes-disk-view.test.ts +36 -7
- package/src/__tests__/conversation-routes-slash-commands.test.ts +31 -3
- package/src/__tests__/conversation-runtime-assembly.test.ts +2877 -152
- package/src/__tests__/conversation-runtime-workspace.test.ts +35 -50
- package/src/__tests__/conversation-seed-composer.test.ts +2 -2
- package/src/__tests__/conversation-skill-tools.test.ts +12 -146
- package/src/__tests__/conversation-slash-queue.test.ts +39 -19
- package/src/__tests__/conversation-slash-unknown.test.ts +53 -16
- package/src/__tests__/conversation-speed-override.test.ts +36 -12
- package/src/__tests__/conversation-surfaces-standalone-payloads.test.ts +1035 -0
- package/src/__tests__/conversation-surfaces-standalone.test.ts +630 -0
- package/src/__tests__/conversation-title-service.test.ts +118 -2
- package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +41 -2
- package/src/__tests__/conversation-tool-setup-batch-authorized.test.ts +1 -1
- package/src/__tests__/conversation-unread-route.test.ts +2 -2
- package/src/__tests__/conversation-usage.test.ts +4 -2
- package/src/__tests__/conversation-workspace-cache-state.test.ts +33 -9
- package/src/__tests__/conversation-workspace-injection.test.ts +46 -15
- package/src/__tests__/conversation-workspace-tool-tracking.test.ts +46 -15
- package/src/__tests__/credential-broker-browser-fill.test.ts +110 -0
- package/src/__tests__/credential-health-service.test.ts +78 -9
- package/src/__tests__/credential-security-invariants.test.ts +5 -2
- package/src/__tests__/credential-storage-oauth-compat.test.ts +18 -0
- package/src/__tests__/credential-storage-static-compat.test.ts +28 -0
- package/src/__tests__/credential-vault-unit.test.ts +135 -19
- package/src/__tests__/credentials-cli.test.ts +1 -9
- package/src/__tests__/cross-provider-web-search.test.ts +84 -0
- package/src/__tests__/daemon-server-persist-and-process-callsite.test.ts +92 -0
- package/src/__tests__/db-schedule-syntax-migration.test.ts +1 -0
- package/src/__tests__/delete-propagation.test.ts +437 -0
- package/src/__tests__/dm-backfill.test.ts +417 -0
- package/src/__tests__/dm-persistence.test.ts +227 -0
- package/src/__tests__/edit-propagation.test.ts +280 -0
- package/src/__tests__/empty-response-pipeline.test.ts +305 -0
- package/src/__tests__/ephemeral-permissions.test.ts +93 -3
- package/src/__tests__/estimator-calibration-integration.test.ts +208 -0
- package/src/__tests__/estimator-calibration.test.ts +213 -0
- package/src/__tests__/extension-id-sync-guard.test.ts +29 -10
- package/src/__tests__/file-write-tool.test.ts +151 -1
- package/src/__tests__/filing-service.test.ts +255 -0
- package/src/__tests__/first-greeting.test.ts +247 -5
- package/src/__tests__/gemini-provider.test.ts +0 -3
- package/src/__tests__/guardian-grant-minting.test.ts +8 -0
- package/src/__tests__/headless-browser-interactions.test.ts +1 -1
- package/src/__tests__/headless-browser-mode.test.ts +57 -0
- package/src/__tests__/heartbeat-service.test.ts +96 -15
- package/src/__tests__/history-repair-pipeline.test.ts +399 -0
- package/src/__tests__/host-browser-e2e-cloud.test.ts +307 -0
- package/src/__tests__/host-browser-e2e-self-hosted.test.ts +3 -3
- package/src/__tests__/host-proxy-interface.test.ts +36 -2
- package/src/__tests__/host-shell-tool.test.ts +124 -18
- package/src/__tests__/http-user-message-parity.test.ts +29 -1
- package/src/__tests__/image-credentials.test.ts +137 -0
- package/src/__tests__/image-service-dispatcher.test.ts +186 -0
- package/src/__tests__/inbound-slack-persistence.test.ts +340 -0
- package/src/__tests__/injector-chain.test.ts +526 -0
- package/src/__tests__/intent-routing.test.ts +1 -66
- package/src/__tests__/llm-call-pipeline.test.ts +285 -0
- package/src/__tests__/llm-catalog-parity.test.ts +174 -0
- package/src/__tests__/llm-context-normalization.test.ts +121 -0
- package/src/__tests__/llm-resolver.test.ts +214 -0
- package/src/__tests__/llm-schema.test.ts +223 -0
- package/src/__tests__/managed-proxy-context.test.ts +6 -2
- package/src/__tests__/media-generate-image.test.ts +119 -13
- package/src/__tests__/memory-retrieval-pipeline.test.ts +401 -0
- package/src/__tests__/memory-upsert-concurrency.test.ts +1 -0
- package/src/__tests__/messaging-skill-split.test.ts +3 -34
- package/src/__tests__/migration-import-from-url.test.ts +621 -0
- package/src/__tests__/model-intents.test.ts +11 -83
- package/src/__tests__/notification-broadcaster.test.ts +3 -3
- package/src/__tests__/notification-decision-fallback.test.ts +0 -10
- package/src/__tests__/notification-decision-identity.test.ts +0 -9
- package/src/__tests__/notification-decision-recipient-context.test.ts +0 -9
- package/src/__tests__/notification-decision-strategy.test.ts +0 -11
- package/src/__tests__/notification-schedule-notify-dedup.test.ts +108 -0
- package/src/__tests__/oauth-apps-routes.test.ts +1 -1
- package/src/__tests__/oauth-cli.test.ts +14 -12
- package/src/__tests__/oauth-connect-orchestrator.test.ts +4 -13
- package/src/__tests__/oauth-provider-serializer.test.ts +6 -4
- package/src/__tests__/oauth-provider-visibility.test.ts +3 -5
- package/src/__tests__/oauth-providers-routes.test.ts +3 -2
- package/src/__tests__/oauth-store.test.ts +46 -78
- package/src/__tests__/oauth2-gateway-transport.test.ts +8 -3
- package/src/__tests__/oauth2-refresh-retry.test.ts +279 -0
- package/src/__tests__/onboarding-template-contract.test.ts +16 -64
- package/src/__tests__/openai-image-service.test.ts +368 -0
- package/src/__tests__/openai-provider.test.ts +7 -0
- package/src/__tests__/openai-responses-provider.test.ts +396 -0
- package/src/__tests__/openrouter-provider-only.test.ts +135 -0
- package/src/__tests__/outbound-slack-persistence.test.ts +293 -0
- package/src/__tests__/overflow-reduce-pipeline.test.ts +676 -0
- package/src/__tests__/permission-checker-host-gate.test.ts +1 -25
- package/src/__tests__/permission-mode.test.ts +16 -0
- package/src/__tests__/permission-types.test.ts +0 -1
- package/src/__tests__/persist-onboarding-artifacts.test.ts +266 -0
- package/src/__tests__/persistence-pipeline.test.ts +377 -0
- package/src/__tests__/persona-resolver.test.ts +13 -13
- package/src/__tests__/pipeline-runner.test.ts +565 -0
- package/src/__tests__/pkb-autoinject.test.ts +37 -1
- package/src/__tests__/platform-bash-auto-approve.test.ts +1 -1
- package/src/__tests__/platform.test.ts +5 -2
- package/src/__tests__/plugin-bootstrap.test.ts +483 -0
- package/src/__tests__/plugin-registry.test.ts +273 -0
- package/src/__tests__/plugin-route-contribution.test.ts +288 -0
- package/src/__tests__/plugin-skill-contribution.test.ts +367 -0
- package/src/__tests__/plugin-tool-contribution.test.ts +286 -0
- package/src/__tests__/plugin-types.test.ts +320 -0
- package/src/__tests__/pricing.test.ts +93 -14
- package/src/__tests__/profiler-routes.test.ts +1 -1
- package/src/__tests__/provider-commit-message-generator.test.ts +14 -84
- package/src/__tests__/provider-env-vars-scope.test.ts +52 -0
- package/src/__tests__/provider-error-scenarios.test.ts +135 -6
- package/src/__tests__/provider-managed-proxy-integration.test.ts +42 -11
- package/src/__tests__/provider-registry-ollama.test.ts +1 -2
- package/src/__tests__/proxy-approval-callback.test.ts +69 -9
- package/src/__tests__/reaction-persistence.test.ts +561 -0
- package/src/__tests__/regenerate-fire-and-forget-trace.test.ts +1 -0
- package/src/__tests__/registry.test.ts +0 -2
- package/src/__tests__/relay-server.test.ts +1 -1
- package/src/__tests__/require-fresh-approval.test.ts +1 -1
- package/src/__tests__/retry-openrouter-only-normalization.test.ts +136 -0
- package/src/__tests__/retry-thinking-tool-choice.test.ts +226 -0
- package/src/__tests__/risk-classifier-parity.test.ts +230 -0
- package/src/__tests__/sanitize-config-for-transfer.test.ts +78 -1
- package/src/__tests__/schedule-routes.test.ts +131 -1
- package/src/__tests__/scheduler-recurrence.test.ts +14 -70
- package/src/__tests__/scheduler-reuse-conversation.test.ts +10 -50
- package/src/__tests__/secret-detection-handler.test.ts +0 -10
- package/src/__tests__/secret-ingress-http.test.ts +28 -0
- package/src/__tests__/secret-prompter-channel-fallback.test.ts +125 -0
- package/src/__tests__/secret-routes-managed-proxy.test.ts +2 -3
- package/src/__tests__/secret-scanner-executor.test.ts +1 -1
- package/src/__tests__/send-endpoint-busy.test.ts +29 -1
- package/src/__tests__/server-history-render.test.ts +31 -0
- package/src/__tests__/shell-identity.test.ts +0 -134
- package/src/__tests__/shell-parser-property.test.ts +13 -13
- package/src/__tests__/skill-cache-store.test.ts +182 -0
- package/src/__tests__/skills.test.ts +19 -33
- package/src/__tests__/slack-app-setup-skill-regression.test.ts +3 -1
- package/src/__tests__/slack-skill.test.ts +3 -8
- package/src/__tests__/starter-bundle.test.ts +35 -0
- package/src/__tests__/subagent-call-site-routing.test.ts +280 -0
- package/src/__tests__/suggestion-routes.test.ts +259 -3
- package/src/__tests__/system-prompt.test.ts +22 -35
- package/src/__tests__/task-memory-cleanup.test.ts +1 -0
- package/src/__tests__/task-runner.test.ts +3 -1
- package/src/__tests__/task-scheduler.test.ts +3 -15
- package/src/__tests__/tcc-sandbox-deny.test.ts +198 -0
- package/src/__tests__/terminal-tools.test.ts +8 -0
- package/src/__tests__/test-preload.ts +11 -0
- package/src/__tests__/test-support/browser-skill-harness.ts +2 -52
- package/src/__tests__/thread-backfill.test.ts +941 -0
- package/src/__tests__/title-generate-pipeline.test.ts +224 -0
- package/src/__tests__/token-estimate-pipeline.test.ts +431 -0
- package/src/__tests__/tool-error-pipeline.test.ts +244 -0
- package/src/__tests__/tool-execute-pipeline.test.ts +431 -0
- package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +2 -8
- package/src/__tests__/tool-executor-lifecycle-events.test.ts +2 -2
- package/src/__tests__/tool-executor-shell-integration.test.ts +7 -10
- package/src/__tests__/tool-executor.test.ts +201 -94
- package/src/__tests__/tool-result-truncate-pipeline.test.ts +356 -0
- package/src/__tests__/tool-result-truncation.test.ts +0 -110
- package/src/__tests__/trust-store.test.ts +442 -109
- package/src/__tests__/update-bulletin-job.test.ts +389 -0
- package/src/__tests__/usage-cache-backfill-migration.test.ts +3 -1
- package/src/__tests__/user-plugin-loader.test.ts +191 -0
- package/src/__tests__/verification-control-plane-policy.test.ts +1 -22
- package/src/__tests__/voice-session-bridge.test.ts +39 -0
- package/src/__tests__/volume-security-guard.test.ts +3 -2
- package/src/__tests__/web-search-history.test.ts +337 -0
- package/src/__tests__/workspace-migration-039-drop-legacy-llm-keys.test.ts +343 -0
- package/src/__tests__/workspace-migration-043-release-notes-latex-rendering.test.ts +202 -0
- package/src/__tests__/workspace-migration-045-release-notes-meet-avatar.test.ts +210 -0
- package/src/__tests__/workspace-migration-046-seed-conversation-starters-callsite.test.ts +185 -0
- package/src/__tests__/workspace-migration-049-release-notes-default-sonnet.test.ts +100 -0
- package/src/__tests__/workspace-migration-050-seed-main-agent-opus-callsite.test.ts +171 -0
- package/src/__tests__/workspace-migration-051-seed-conversation-summarization-callsite.test.ts +252 -0
- package/src/__tests__/workspace-migration-drop-user-md.test.ts +11 -11
- package/src/__tests__/workspace-migration-remove-hooks.test.ts +99 -0
- package/src/__tests__/workspace-migration-unify-llm-callsite-configs.test.ts +841 -0
- package/src/__tests__/workspace-policy.test.ts +22 -16
- package/src/acp/client-handler.ts +1 -2
- package/src/agent/loop.ts +545 -115
- package/src/approvals/__tests__/guardian-feed-event.test.ts +304 -0
- package/src/approvals/guardian-request-resolvers.ts +80 -0
- package/src/avatar/resvg-lazy.test.ts +136 -0
- package/src/avatar/resvg-lazy.ts +82 -9
- package/src/avatar/traits-png-sync.ts +21 -1
- package/src/backup/__tests__/backup-worker.test.ts +2 -13
- package/src/backup/backup-worker.ts +3 -15
- package/src/browser/__tests__/operations.test.ts +163 -0
- package/src/browser/identifiers.ts +51 -0
- package/src/browser/operations.ts +660 -0
- package/src/browser/types.ts +81 -0
- package/src/bundler/app-compiler.ts +84 -1
- package/src/calls/call-state.ts +2 -2
- package/src/calls/guardian-question-copy.ts +2 -2
- package/src/calls/telephony-stt-routing.ts +1 -1
- package/src/calls/voice-session-bridge.ts +1 -0
- package/src/channels/__tests__/types.test.ts +3 -3
- package/src/channels/types.ts +6 -4
- package/src/cli/AGENTS.md +1 -1
- package/src/cli/__tests__/notifications.test.ts +87 -211
- package/src/cli/commands/__tests__/attachment.test.ts +438 -0
- package/src/cli/commands/__tests__/backup.test.ts +1 -1
- package/src/cli/commands/__tests__/browser.test.ts +554 -0
- package/src/cli/commands/__tests__/cache.test.ts +623 -0
- package/src/cli/commands/__tests__/email-list.test.ts +6 -0
- package/src/cli/commands/__tests__/email-send.test.ts +93 -1
- package/src/cli/commands/__tests__/image-generation.test.ts +886 -0
- package/src/cli/commands/__tests__/inference-send.test.ts +463 -0
- package/src/cli/commands/__tests__/stt-transcribe.test.ts +454 -0
- package/src/cli/commands/__tests__/task.test.ts +913 -0
- package/src/cli/commands/__tests__/tts-synthesize.test.ts +606 -0
- package/src/cli/commands/__tests__/ui-confirm.test.ts +650 -0
- package/src/cli/commands/__tests__/ui.test.ts +1215 -0
- package/src/cli/commands/__tests__/watchers.test.ts +716 -0
- package/src/cli/commands/attachment.ts +182 -0
- package/src/cli/commands/backup.ts +2 -2
- package/src/cli/commands/browser.ts +350 -0
- package/src/cli/commands/cache.ts +341 -0
- package/src/cli/commands/clients.ts +138 -0
- package/src/cli/commands/completions.ts +2 -12
- package/src/cli/commands/config.ts +6 -6
- package/src/cli/commands/conversations-import.ts +347 -0
- package/src/cli/commands/conversations.ts +69 -8
- package/src/cli/commands/email.ts +234 -194
- package/src/cli/commands/image-generation.ts +299 -0
- package/src/cli/commands/inference.ts +200 -0
- package/src/cli/commands/memory.ts +127 -17
- package/src/cli/commands/notifications.ts +68 -103
- package/src/cli/commands/oauth/__tests__/providers-register.test.ts +1 -1
- package/src/cli/commands/oauth/__tests__/providers-update.test.ts +1 -1
- package/src/cli/commands/oauth/connect.ts +2 -2
- package/src/cli/commands/oauth/providers.ts +176 -8
- package/src/cli/commands/oauth/status.ts +46 -36
- package/src/cli/commands/platform/__tests__/callback-routes-list.test.ts +0 -1
- package/src/cli/commands/platform/__tests__/connect.test.ts +0 -1
- package/src/cli/commands/platform/__tests__/disconnect.test.ts +0 -1
- package/src/cli/commands/platform/__tests__/status.test.ts +0 -1
- package/src/cli/commands/skills.ts +3 -4
- package/src/cli/commands/stt.ts +339 -0
- package/src/cli/commands/task.ts +795 -0
- package/src/cli/commands/trust.ts +50 -19
- package/src/cli/commands/tts.ts +273 -0
- package/src/cli/commands/ui.ts +670 -0
- package/src/cli/commands/watchers.ts +509 -0
- package/src/cli/lib/daemon-credential-client.ts +0 -19
- package/src/cli/program.ts +39 -24
- package/src/cli.ts +0 -37
- package/src/config/__tests__/backup-schema.test.ts +7 -2
- package/src/config/bundled-skills/app-builder/SKILL.md +2 -2
- package/src/config/bundled-skills/app-builder/references/WIDGETS.md +10 -10
- package/src/config/bundled-skills/contacts/tools/contact-merge.ts +66 -87
- package/src/config/bundled-skills/contacts/tools/contact-search.ts +28 -51
- package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +22 -40
- package/src/config/bundled-skills/image-studio/SKILL.md +2 -1
- package/src/config/bundled-skills/image-studio/TOOLS.json +2 -1
- package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +23 -39
- package/src/config/bundled-skills/media-processing/services/reduce.ts +1 -1
- package/src/config/bundled-skills/messaging/SKILL.md +5 -5
- package/src/config/bundled-skills/messaging/TOOLS.json +4 -0
- package/src/config/bundled-skills/messaging/tools/__tests__/messaging-feed-events.test.ts +207 -0
- package/src/config/bundled-skills/messaging/tools/messaging-archive-by-sender.ts +20 -1
- package/src/config/bundled-skills/messaging/tools/messaging-read.ts +15 -1
- package/src/config/bundled-skills/messaging/tools/messaging-search.ts +21 -1
- package/src/config/bundled-skills/messaging/tools/messaging-send.ts +69 -12
- package/src/config/bundled-skills/phone-calls/references/CONFIG.md +9 -8
- package/src/config/bundled-skills/schedule/SKILL.md +8 -3
- package/src/config/bundled-skills/schedule/TOOLS.json +15 -7
- package/src/config/bundled-skills/schedule/references/SCRIPT_MODE_PATTERNS.md +59 -0
- package/src/config/bundled-skills/settings/TOOLS.json +3 -3
- package/src/config/bundled-tool-registry.ts +0 -190
- package/src/config/env.ts +7 -2
- package/src/config/feature-flag-registry.json +42 -10
- package/src/config/llm-resolver.ts +128 -0
- package/src/config/loader.ts +194 -10
- package/src/config/raw-config-utils.ts +30 -2
- package/src/config/sanitize-for-transfer.ts +35 -0
- package/src/config/schema.ts +49 -41
- package/src/config/schemas/analysis.ts +3 -22
- package/src/config/schemas/backup.ts +1 -1
- package/src/config/schemas/calls.ts +0 -4
- package/src/config/schemas/conversations.ts +16 -0
- package/src/config/schemas/filing.ts +2 -7
- package/src/config/schemas/heartbeat.ts +0 -5
- package/src/config/schemas/inference.ts +3 -23
- package/src/config/schemas/llm.ts +317 -0
- package/src/config/schemas/memory-processing.ts +1 -9
- package/src/config/schemas/notifications.ts +4 -11
- package/src/config/schemas/platform.ts +3 -9
- package/src/config/schemas/security.ts +33 -0
- package/src/config/schemas/services.ts +9 -4
- package/src/config/schemas/stt.ts +1 -0
- package/src/config/schemas/tts.ts +64 -0
- package/src/config/schemas/updates.ts +1 -1
- package/src/config/schemas/workspace-git.ts +3 -40
- package/src/config/skill-state.ts +6 -2
- package/src/config/skills.ts +96 -7
- package/src/context/__tests__/compact-prompt.test.ts +63 -0
- package/src/context/__tests__/microcompact.test.ts +805 -0
- package/src/context/estimator-calibration.ts +136 -0
- package/src/context/microcompact.ts +443 -0
- package/src/context/prompts/compact.md +26 -0
- package/src/context/token-estimator.ts +61 -3
- package/src/context/tool-result-truncation.ts +3 -63
- package/src/context/window-manager.ts +417 -39
- package/src/credential-execution/approval-bridge.ts +0 -1
- package/src/credential-execution/executable-discovery.ts +19 -8
- package/src/credential-execution/process-manager.test.ts +109 -0
- package/src/credential-execution/process-manager.ts +65 -2
- package/src/credential-health/credential-health-service.ts +19 -6
- package/src/daemon/__tests__/conversation-feed-event.test.ts +317 -0
- package/src/daemon/__tests__/conversation-lifecycle-auto-analyze.test.ts +4 -12
- package/src/daemon/__tests__/conversation-tool-setup.test.ts +14 -15
- package/src/daemon/approval-generators.ts +29 -4
- package/src/daemon/assistant-attachments.ts +24 -13
- package/src/daemon/classifier.ts +2 -2
- package/src/daemon/config-watcher.ts +0 -3
- package/src/daemon/context-overflow-policy.ts +4 -13
- package/src/daemon/context-overflow-reducer.ts +4 -1
- package/src/daemon/conversation-agent-loop-handlers.ts +162 -34
- package/src/daemon/conversation-agent-loop.ts +1282 -599
- package/src/daemon/conversation-attachments.ts +2 -6
- package/src/daemon/conversation-error.ts +36 -1
- package/src/daemon/conversation-history.ts +10 -19
- package/src/daemon/conversation-lifecycle.ts +59 -17
- package/src/daemon/conversation-messaging.ts +73 -4
- package/src/daemon/conversation-notifiers.ts +2 -110
- package/src/daemon/conversation-process.ts +24 -11
- package/src/daemon/conversation-queue-manager.ts +3 -0
- package/src/daemon/conversation-runtime-assembly.ts +1063 -211
- package/src/daemon/conversation-slash.ts +2 -2
- package/src/daemon/conversation-surfaces.ts +389 -1
- package/src/daemon/conversation-tool-setup.ts +51 -9
- package/src/daemon/conversation-usage.ts +1 -1
- package/src/daemon/conversation.ts +197 -64
- package/src/daemon/external-plugins-bootstrap.ts +478 -0
- package/src/daemon/external-skills-bootstrap.ts +41 -0
- package/src/daemon/first-greeting.ts +191 -14
- package/src/daemon/guardian-action-generators.ts +34 -14
- package/src/daemon/handlers/config-model.test.ts +86 -0
- package/src/daemon/handlers/config-model.ts +65 -12
- package/src/daemon/handlers/conversations.ts +9 -2
- package/src/daemon/handlers/shared.ts +39 -11
- package/src/daemon/handlers/skills.ts +7 -3
- package/src/daemon/handlers/slack-channel-oauth-install.ts +197 -0
- package/src/daemon/lifecycle.ts +109 -82
- package/src/daemon/message-types/computer-use.ts +2 -34
- package/src/daemon/message-types/conversations.ts +63 -0
- package/src/daemon/message-types/messages.ts +21 -1
- package/src/daemon/message-types/trust.ts +0 -2
- package/src/daemon/parse-actual-tokens-from-error.test.ts +57 -1
- package/src/daemon/parse-actual-tokens-from-error.ts +66 -0
- package/src/daemon/pkb-context-tracker.test.ts +169 -0
- package/src/daemon/pkb-context-tracker.ts +125 -0
- package/src/daemon/pkb-reminder-builder.test.ts +70 -0
- package/src/daemon/pkb-reminder-builder.ts +31 -0
- package/src/daemon/providers-setup.ts +6 -0
- package/src/daemon/server.ts +122 -12
- package/src/daemon/shutdown-handlers.ts +2 -12
- package/src/daemon/tool-side-effects.ts +14 -65
- package/src/daemon/web-search-history.ts +126 -0
- package/src/events/domain-events.ts +0 -1
- package/src/filing/filing-service.ts +9 -10
- package/src/heartbeat/__tests__/heartbeat-feed-event.test.ts +160 -0
- package/src/heartbeat/heartbeat-service.ts +99 -28
- package/src/home/__tests__/feed-population-integration.test.ts +312 -0
- package/src/home/__tests__/feed-scheduler.test.ts +39 -11
- package/src/home/__tests__/rollup-producer.test.ts +44 -0
- package/src/home/assistant-feed-authoring.ts +4 -0
- package/src/home/emit-feed-event.ts +11 -0
- package/src/home/feed-scheduler.ts +20 -4
- package/src/home/feed-types.ts +97 -4
- package/src/home/relationship-state-writer.ts +2 -2
- package/src/home/rewrite-command-preview.ts +66 -0
- package/src/home/rollup-producer.ts +34 -5
- package/src/home/suggested-prompts.ts +101 -0
- package/src/ipc/__tests__/attachment-ipc.test.ts +213 -0
- package/src/ipc/__tests__/browser-ipc.test.ts +339 -0
- package/src/ipc/__tests__/cache-ipc.test.ts +266 -0
- package/src/ipc/__tests__/socket-path.test.ts +34 -0
- package/src/ipc/__tests__/task-ipc.test.ts +577 -0
- package/src/ipc/__tests__/ui-request-route.test.ts +495 -0
- package/src/ipc/__tests__/watcher-ipc.test.ts +295 -0
- package/src/ipc/cli-client.ts +2 -1
- package/src/ipc/cli-server.ts +26 -8
- package/src/ipc/gateway-client.ts +6 -3
- package/src/ipc/routes/attachment.ts +114 -0
- package/src/ipc/routes/browser-context.ts +63 -0
- package/src/ipc/routes/browser.ts +97 -0
- package/src/ipc/routes/cache.ts +96 -0
- package/src/ipc/routes/get-contact.ts +16 -0
- package/src/ipc/routes/index.ts +31 -1
- package/src/ipc/routes/list-clients.ts +31 -0
- package/src/ipc/routes/merge-contacts.ts +17 -0
- package/src/ipc/routes/notification.ts +133 -0
- package/src/ipc/routes/rename-conversation.ts +59 -0
- package/src/ipc/routes/search-contacts.ts +19 -0
- package/src/ipc/routes/task-queue.ts +226 -0
- package/src/ipc/routes/task.ts +173 -0
- package/src/ipc/routes/ui-request.ts +50 -0
- package/src/ipc/routes/upsert-contact.ts +25 -0
- package/src/ipc/routes/watcher.ts +203 -0
- package/src/ipc/socket-path.ts +76 -0
- package/src/media/app-icon-generator.ts +23 -46
- package/src/media/avatar-router.ts +26 -41
- package/src/media/gemini-image-service.ts +8 -41
- package/src/media/image-credentials.ts +73 -0
- package/src/media/image-service.ts +85 -0
- package/src/media/openai-image-service.ts +131 -0
- package/src/media/types.ts +46 -0
- package/src/memory/__tests__/conversation-analyze-job.test.ts +9 -8
- package/src/memory/__tests__/conversation-group-migration.test.ts +99 -0
- package/src/memory/admin.ts +18 -0
- package/src/memory/conversation-analyze-job.ts +14 -13
- package/src/memory/conversation-attention-store.ts +13 -6
- package/src/memory/conversation-crud.ts +133 -3
- package/src/memory/conversation-group-migration.ts +38 -6
- package/src/memory/conversation-queries.ts +57 -4
- package/src/memory/conversation-title-service.ts +32 -4
- package/src/memory/db-init.ts +10 -0
- package/src/memory/embedding-backend.ts +1 -1
- package/src/memory/embedding-gemini.test.ts +41 -2
- package/src/memory/embedding-gemini.ts +6 -1
- package/src/memory/graph/bootstrap.test.ts +282 -0
- package/src/memory/graph/bootstrap.ts +8 -5
- package/src/memory/graph/compaction.ts +299 -0
- package/src/memory/graph/consolidation.ts +4 -4
- package/src/memory/graph/conversation-graph-memory.ts +89 -29
- package/src/memory/graph/extraction.test.ts +272 -2
- package/src/memory/graph/extraction.ts +183 -53
- package/src/memory/graph/graph-search.test.ts +93 -0
- package/src/memory/graph/graph-search.ts +4 -1
- package/src/memory/graph/inspect.ts +2 -2
- package/src/memory/graph/narrative.ts +2 -2
- package/src/memory/graph/pattern-scan.ts +2 -2
- package/src/memory/graph/retriever.test.ts +459 -0
- package/src/memory/graph/retriever.ts +237 -48
- package/src/memory/graph/store.ts +41 -0
- package/src/memory/graph/tool-handlers.ts +27 -0
- package/src/memory/graph/tools.ts +6 -1
- package/src/memory/indexer.ts +5 -5
- package/src/memory/job-handlers/conversation-starters.ts +23 -20
- package/src/memory/job-handlers/summarization.ts +2 -2
- package/src/memory/job-utils.ts +7 -1
- package/src/memory/jobs/embed-pkb-file.test.ts +168 -0
- package/src/memory/jobs/embed-pkb-file.ts +54 -0
- package/src/memory/jobs-store.ts +44 -3
- package/src/memory/jobs-worker.ts +4 -0
- package/src/memory/migrations/041-approval-prompt-ts-tracker.ts +26 -0
- package/src/memory/migrations/140-backfill-usage-cache-accounting.ts +1 -1
- package/src/memory/migrations/149-oauth-tables.ts +1 -0
- package/src/memory/migrations/220-normalize-user-file-by-principal.ts +2 -2
- package/src/memory/migrations/222-strip-placeholder-sentinels-from-messages.ts +82 -0
- package/src/memory/migrations/223-schedule-script-column.ts +11 -0
- package/src/memory/migrations/224-oauth-providers-managed-service-is-paid.ts +24 -0
- package/src/memory/migrations/225-oauth-providers-available-scopes.ts +13 -0
- package/src/memory/migrations/index.ts +5 -0
- package/src/memory/pkb/pkb-index.test.ts +369 -0
- package/src/memory/pkb/pkb-index.ts +255 -0
- package/src/memory/pkb/pkb-reconcile.test.ts +252 -0
- package/src/memory/pkb/pkb-reconcile.ts +148 -0
- package/src/memory/pkb/pkb-search.test.ts +499 -0
- package/src/memory/pkb/pkb-search.ts +159 -0
- package/src/memory/pkb/types.ts +53 -0
- package/src/memory/qdrant-client.test.ts +60 -0
- package/src/memory/qdrant-client.ts +147 -1
- package/src/memory/schema/infrastructure.ts +1 -0
- package/src/memory/schema/oauth.ts +4 -1
- package/src/memory/slack-thread-store.ts +37 -0
- package/src/messaging/providers/gmail/adapter.ts +6 -16
- package/src/messaging/providers/gmail/client.ts +22 -0
- package/src/messaging/providers/gmail/types.ts +7 -0
- package/src/messaging/providers/slack/adapter.ts +14 -2
- package/src/messaging/providers/slack/backfill.test.ts +257 -0
- package/src/messaging/providers/slack/backfill.ts +101 -0
- package/src/messaging/providers/slack/message-metadata.test.ts +316 -0
- package/src/messaging/providers/slack/message-metadata.ts +123 -0
- package/src/messaging/providers/slack/render-transcript.test.ts +1421 -0
- package/src/messaging/providers/slack/render-transcript.ts +501 -0
- package/src/messaging/style-analyzer.ts +5 -2
- package/src/notifications/README.md +9 -5
- package/src/notifications/conversation-pairing.ts +78 -19
- package/src/notifications/copy-composer.ts +0 -5
- package/src/notifications/decision-engine.ts +3 -9
- package/src/notifications/emit-signal.ts +1 -1
- package/src/notifications/preference-extractor.ts +2 -6
- package/src/notifications/signal.ts +1 -2
- package/src/oauth/AGENTS.md +1 -1
- package/src/oauth/__tests__/identity-verifier.test.ts +2 -1
- package/src/oauth/connect-orchestrator.ts +8 -34
- package/src/oauth/connect-types.ts +6 -10
- package/src/oauth/manual-token-connection.ts +23 -0
- package/src/oauth/oauth-store.ts +31 -14
- package/src/oauth/platform-connection.test.ts +47 -0
- package/src/oauth/platform-connection.ts +15 -5
- package/src/oauth/provider-serializer.ts +6 -1
- package/src/oauth/seed-providers.ts +56 -106
- package/src/outbound-proxy/http-forwarder.ts +9 -0
- package/src/permissions/approval-policy.test.ts +1223 -0
- package/src/permissions/approval-policy.ts +309 -0
- package/src/permissions/arg-parser.test.ts +161 -0
- package/src/permissions/arg-parser.ts +141 -0
- package/src/permissions/bash-risk-classifier.test.ts +1620 -0
- package/src/permissions/bash-risk-classifier.ts +950 -0
- package/src/permissions/checker.ts +348 -711
- package/src/permissions/command-registry.test.ts +774 -0
- package/src/permissions/command-registry.ts +1005 -0
- package/src/permissions/defaults.ts +28 -79
- package/src/permissions/file-risk-classifier.test.ts +535 -0
- package/src/permissions/file-risk-classifier.ts +274 -0
- package/src/permissions/gateway-threshold-reader.ts +196 -0
- package/src/permissions/prompter.ts +4 -0
- package/src/permissions/risk-types.ts +262 -0
- package/src/permissions/schedule-risk-classifier.test.ts +129 -0
- package/src/permissions/schedule-risk-classifier.ts +85 -0
- package/src/permissions/secret-prompter.ts +53 -2
- package/src/permissions/shell-identity.ts +2 -42
- package/src/permissions/skill-risk-classifier.test.ts +311 -0
- package/src/permissions/skill-risk-classifier.ts +214 -0
- package/src/permissions/trust-client.ts +52 -25
- package/src/permissions/trust-store-interface.ts +1 -6
- package/src/permissions/trust-store.ts +161 -62
- package/src/permissions/types.ts +25 -14
- package/src/permissions/web-risk-classifier.test.ts +170 -0
- package/src/permissions/web-risk-classifier.ts +89 -0
- package/src/permissions/workspace-policy.ts +9 -19
- package/src/platform/client.ts +19 -1
- package/src/plugins/defaults/circuit-breaker.ts +146 -0
- package/src/plugins/defaults/compaction.ts +145 -0
- package/src/plugins/defaults/empty-response.ts +126 -0
- package/src/plugins/defaults/history-repair.ts +85 -0
- package/src/plugins/defaults/index.ts +116 -0
- package/src/plugins/defaults/injectors.ts +491 -0
- package/src/plugins/defaults/llm-call.ts +82 -0
- package/src/plugins/defaults/memory-retrieval.ts +226 -0
- package/src/plugins/defaults/overflow-reduce.ts +181 -0
- package/src/plugins/defaults/persistence.ts +129 -0
- package/src/plugins/defaults/title-generate.ts +95 -0
- package/src/plugins/defaults/token-estimate.ts +104 -0
- package/src/plugins/defaults/tool-error.ts +126 -0
- package/src/plugins/defaults/tool-execute.ts +89 -0
- package/src/plugins/defaults/tool-result-truncate.ts +88 -0
- package/src/plugins/pipeline.ts +316 -0
- package/src/plugins/plugin-skill-contributions.ts +292 -0
- package/src/plugins/registry.ts +241 -0
- package/src/plugins/types.ts +1134 -0
- package/src/plugins/user-loader.ts +177 -0
- package/src/prompts/persona-resolver.ts +3 -3
- package/src/prompts/system-prompt.ts +19 -20
- package/src/prompts/templates/BOOTSTRAP.md +27 -77
- package/src/prompts/templates/SOUL.md +2 -2
- package/src/prompts/update-bulletin-job.ts +190 -0
- package/src/providers/__tests__/context-overflow-error.test.ts +328 -0
- package/src/providers/__tests__/provider-env-vars.test.ts +102 -0
- package/src/providers/__tests__/retry-callsite.test.ts +424 -0
- package/src/providers/anthropic/client.ts +183 -14
- package/src/providers/call-site-routing.ts +71 -0
- package/src/providers/gemini/client.ts +65 -2
- package/src/providers/managed-proxy/constants.ts +2 -1
- package/src/providers/model-catalog.ts +524 -33
- package/src/providers/model-intents.ts +4 -4
- package/src/providers/openai/chat-completions-provider.ts +57 -1
- package/src/providers/openai/responses-provider.ts +86 -9
- package/src/providers/openrouter/client.ts +80 -9
- package/src/providers/provider-env-vars.ts +56 -0
- package/src/providers/provider-send-message.ts +22 -5
- package/src/providers/ratelimit.ts +4 -0
- package/src/providers/registry.ts +19 -8
- package/src/providers/retry.ts +174 -39
- package/src/providers/speech-to-text/__tests__/resolve.test.ts +55 -0
- package/src/providers/speech-to-text/deepgram-realtime.test.ts +61 -0
- package/src/providers/speech-to-text/deepgram-realtime.ts +57 -0
- package/src/providers/speech-to-text/google-gemini-live-stream.ts +4 -4
- package/src/providers/speech-to-text/provider-catalog.ts +17 -0
- package/src/providers/speech-to-text/resolve.ts +7 -0
- package/src/providers/speech-to-text/xai-realtime.test.ts +646 -0
- package/src/providers/speech-to-text/xai-realtime.ts +821 -0
- package/src/providers/speech-to-text/xai.test.ts +155 -0
- package/src/providers/speech-to-text/xai.ts +97 -0
- package/src/providers/types.ts +93 -3
- package/src/runtime/AGENTS.md +27 -18
- package/src/runtime/__tests__/agent-wake.test.ts +43 -2
- package/src/runtime/__tests__/browser-extension-pair-routes.test.ts +3 -3
- package/src/runtime/__tests__/client-registry.test.ts +293 -0
- package/src/runtime/__tests__/interactive-ui.test.ts +673 -0
- package/src/runtime/agent-wake.ts +63 -22
- package/src/runtime/auth/route-policy.ts +4 -0
- package/src/runtime/btw-sidechain.ts +13 -3
- package/src/runtime/channel-reply-delivery.ts +106 -2
- package/src/runtime/client-registry.ts +261 -0
- package/src/runtime/decision-token.ts +116 -0
- package/src/runtime/gateway-client.ts +2 -2
- package/src/runtime/http-router.ts +32 -0
- package/src/runtime/http-server.ts +129 -9
- package/src/runtime/http-types.ts +23 -3
- package/src/runtime/interactive-ui.ts +362 -0
- package/src/runtime/invite-instruction-generator.ts +2 -2
- package/src/runtime/migrations/__tests__/gcs-signed-url.test.ts +176 -0
- package/src/runtime/migrations/__tests__/vbundle-metadata-merge-integration.test.ts +390 -0
- package/src/runtime/migrations/__tests__/vbundle-metadata-merge.test.ts +221 -0
- package/src/runtime/migrations/__tests__/vbundle-streaming-importer.test.ts +1540 -0
- package/src/runtime/migrations/__tests__/vbundle-streaming-validator.test.ts +453 -0
- package/src/runtime/migrations/__tests__/vbundle-tar-stream.test.ts +222 -0
- package/src/runtime/migrations/gcs-signed-url.ts +162 -0
- package/src/runtime/migrations/vbundle-builder.ts +1 -22
- package/src/runtime/migrations/vbundle-importer.ts +154 -9
- package/src/runtime/migrations/vbundle-metadata-merge.ts +124 -0
- package/src/runtime/migrations/vbundle-streaming-importer.ts +2522 -0
- package/src/runtime/migrations/vbundle-streaming-validator.ts +244 -0
- package/src/runtime/migrations/vbundle-tar-stream.ts +217 -0
- package/src/runtime/migrations/vbundle-validator.ts +15 -6
- package/src/runtime/routes/__tests__/home-feed-routes.test.ts +111 -0
- package/src/runtime/routes/__tests__/migration-import-credential-filter.test.ts +114 -75
- package/src/runtime/routes/__tests__/migration-vellum-metadata-reconcile.test.ts +246 -0
- package/src/runtime/routes/approval-prompt-ts-tracker.ts +78 -0
- package/src/runtime/routes/approval-routes.ts +29 -17
- package/src/runtime/routes/approval-strategies/guardian-callback-strategy.ts +9 -0
- package/src/runtime/routes/avatar-routes.ts +20 -4
- package/src/runtime/routes/browser-extension-pair-routes.ts +27 -8
- package/src/runtime/routes/btw-routes.ts +1 -4
- package/src/runtime/routes/conversation-management-routes.ts +20 -2
- package/src/runtime/routes/conversation-routes.ts +351 -138
- package/src/runtime/routes/debug-routes.ts +1 -1
- package/src/runtime/routes/diagnostics-routes.ts +6 -4
- package/src/runtime/routes/events-routes.ts +16 -0
- package/src/runtime/routes/guardian-approval-interception.ts +33 -3
- package/src/runtime/routes/guardian-approval-prompt.ts +13 -3
- package/src/runtime/routes/home-feed-routes.ts +120 -2
- package/src/runtime/routes/inbound-message-handler.ts +987 -2
- package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +113 -2
- package/src/runtime/routes/inbound-stages/background-dispatch.ts +61 -3
- package/src/runtime/routes/inbound-stages/edit-intercept.ts +129 -6
- package/src/runtime/routes/integrations/slack/channel.ts +25 -3
- package/src/runtime/routes/llm-context-normalization.ts +23 -1
- package/src/runtime/routes/memory-item-routes.test.ts +1 -0
- package/src/runtime/routes/migration-routes.ts +720 -127
- package/src/runtime/routes/playground/__tests__/force-compact.test.ts +284 -0
- package/src/runtime/routes/playground/__tests__/guard.test.ts +80 -0
- package/src/runtime/routes/playground/__tests__/inject-failures.test.ts +294 -0
- package/src/runtime/routes/playground/__tests__/reset-circuit.test.ts +271 -0
- package/src/runtime/routes/playground/__tests__/seed-conversation.test.ts +202 -0
- package/src/runtime/routes/playground/__tests__/seeded-conversations.test.ts +309 -0
- package/src/runtime/routes/playground/__tests__/state.test.ts +224 -0
- package/src/runtime/routes/playground/conversation-not-found.ts +29 -0
- package/src/runtime/routes/playground/deps.ts +56 -0
- package/src/runtime/routes/playground/force-compact.ts +73 -0
- package/src/runtime/routes/playground/guard.ts +37 -0
- package/src/runtime/routes/playground/index.ts +28 -0
- package/src/runtime/routes/playground/inject-failures.ts +159 -0
- package/src/runtime/routes/playground/reset-circuit.ts +115 -0
- package/src/runtime/routes/playground/seed-conversation.ts +139 -0
- package/src/runtime/routes/playground/seeded-conversations.ts +78 -0
- package/src/runtime/routes/playground/state.ts +78 -0
- package/src/runtime/routes/schedule-routes.ts +89 -8
- package/src/runtime/routes/settings-routes.ts +4 -2
- package/src/runtime/routes/trust-rules-routes.ts +30 -14
- package/src/runtime/routes/work-items-routes.test.ts +1 -1
- package/src/runtime/routes/work-items-routes.ts +3 -2
- package/src/runtime/services/__tests__/analyze-conversation.test.ts +25 -43
- package/src/runtime/services/analyze-conversation.ts +12 -16
- package/src/runtime/skill-route-registry.ts +97 -15
- package/src/schedule/run-script.ts +68 -0
- package/src/schedule/schedule-store.ts +7 -1
- package/src/schedule/scheduler.ts +56 -8
- package/src/security/__tests__/provider-key-env-fallback.test.ts +119 -0
- package/src/security/__tests__/untrusted-content.test.ts +109 -0
- package/src/security/oauth2.ts +98 -35
- package/src/security/secure-keys.ts +7 -8
- package/src/security/token-manager.ts +27 -13
- package/src/security/untrusted-content.ts +102 -0
- package/src/skills/catalog-cache.ts +35 -9
- package/src/skills/catalog-install.ts +31 -3
- package/src/skills/skill-cache-store.ts +97 -0
- package/src/stt/__tests__/daemon-batch-transcriber.test.ts +76 -0
- package/src/stt/daemon-batch-transcriber.ts +33 -0
- package/src/stt/stt-stream-session.ts +8 -1
- package/src/stt/types.ts +5 -1
- package/src/subagent/manager.ts +41 -13
- package/src/tasks/ephemeral-permissions.ts +9 -4
- package/src/telemetry/usage-telemetry-reporter.ts +27 -5
- package/src/tools/browser/__tests__/browser-status.test.ts +234 -2
- package/src/tools/browser/browser-execution.ts +150 -54
- package/src/tools/browser/cdp-client/__tests__/extension-cdp-client.test.ts +230 -0
- package/src/tools/browser/cdp-client/__tests__/factory.test.ts +146 -3
- package/src/tools/browser/cdp-client/cdp-inspect/discovery.ts +22 -0
- package/src/tools/browser/cdp-client/extension-cdp-client.ts +54 -3
- package/src/tools/browser/cdp-client/factory.ts +15 -4
- package/src/tools/credentials/tool-policy.ts +39 -5
- package/src/tools/credentials/vault.ts +9 -4
- package/src/tools/executor.ts +129 -73
- package/src/tools/filesystem/write.ts +52 -0
- package/src/tools/host-terminal/host-shell.ts +45 -5
- package/src/tools/memory/register.test.ts +185 -0
- package/src/tools/memory/register.ts +3 -1
- package/src/tools/network/script-proxy/session-manager.ts +37 -1
- package/src/tools/network/web-fetch.ts +20 -10
- package/src/tools/network/web-search.ts +19 -4
- package/src/tools/permission-checker.ts +116 -46
- package/src/tools/policy-context.ts +29 -8
- package/src/tools/registry.ts +195 -6
- package/src/tools/schedule/create.ts +23 -8
- package/src/tools/schedule/update.ts +3 -1
- package/src/tools/secret-detection-handler.ts +0 -51
- package/src/tools/side-effects.ts +0 -11
- package/src/tools/skills/execute.ts +2 -2
- package/src/tools/skills/sandbox-runner.ts +5 -2
- package/src/tools/system/avatar-generator.ts +6 -2
- package/src/tools/terminal/backends/native.ts +51 -2
- package/src/tools/terminal/safe-env.ts +3 -2
- package/src/tools/terminal/shell.ts +1 -0
- package/src/tools/tool-manifest.ts +6 -21
- package/src/tools/types.ts +40 -5
- package/src/tools/verification-control-plane-policy.ts +1 -1
- package/src/tts/__tests__/provider-adapters.test.ts +240 -13
- package/src/tts/provider-catalog.ts +18 -0
- package/src/tts/providers/index.ts +2 -0
- package/src/tts/providers/xai-provider.ts +224 -0
- package/src/tts/types.ts +46 -0
- package/src/types/tar-stream.d.ts +66 -0
- package/src/util/json.ts +17 -0
- package/src/util/platform.ts +9 -4
- package/src/util/pricing.ts +41 -8
- package/src/watcher/engine.ts +1 -1
- package/src/watcher/providers/google-calendar.ts +134 -8
- package/src/watcher/providers/outlook-calendar.ts +42 -2
- package/src/workspace/git-service.ts +23 -4
- package/src/workspace/migrations/006-services-config.ts +2 -4
- package/src/workspace/migrations/022-move-hooks-to-workspace.ts +2 -3
- package/src/workspace/migrations/038-unify-llm-callsite-configs.ts +516 -0
- package/src/workspace/migrations/039-drop-legacy-llm-keys.ts +171 -0
- package/src/workspace/migrations/040-seed-latency-callsite-defaults.ts +154 -0
- package/src/workspace/migrations/041-backfill-google-gmail-settings-scope.ts +56 -0
- package/src/workspace/migrations/042-fix-backfill-google-gmail-settings-scope.ts +70 -0
- package/src/workspace/migrations/043-release-notes-latex-rendering.ts +75 -0
- package/src/workspace/migrations/044-bump-stale-provider-stream-timeout.ts +51 -0
- package/src/workspace/migrations/045-release-notes-meet-avatar.ts +130 -0
- package/src/workspace/migrations/046-seed-conversation-starters-callsite.ts +108 -0
- package/src/workspace/migrations/047-remove-watch-callsites.ts +54 -0
- package/src/workspace/migrations/048-remove-workspace-hooks.ts +81 -0
- package/src/workspace/migrations/049-release-notes-default-sonnet.ts +80 -0
- package/src/workspace/migrations/050-seed-main-agent-opus-callsite.ts +86 -0
- package/src/workspace/migrations/051-seed-conversation-summarization-callsite.ts +128 -0
- package/src/workspace/migrations/AGENTS.md +1 -1
- package/src/workspace/migrations/registry.ts +28 -0
- package/src/workspace/provider-commit-message-generator.ts +19 -38
- package/tsconfig.json +1 -1
- package/hook-templates/debug-prompt-logger/hook.json +0 -7
- package/hook-templates/debug-prompt-logger/run.sh +0 -66
- package/src/__tests__/context-overflow-approval.test.ts +0 -156
- package/src/__tests__/gmail-archive-fallback.test.ts +0 -193
- package/src/__tests__/gmail-archive-gate.test.ts +0 -246
- package/src/__tests__/gmail-preferences.test.ts +0 -117
- package/src/__tests__/hooks-blocking.test.ts +0 -178
- package/src/__tests__/hooks-cli.test.ts +0 -182
- package/src/__tests__/hooks-config.test.ts +0 -108
- package/src/__tests__/hooks-discovery.test.ts +0 -211
- package/src/__tests__/hooks-integration.test.ts +0 -196
- package/src/__tests__/hooks-manager.test.ts +0 -226
- package/src/__tests__/hooks-runner.test.ts +0 -175
- package/src/__tests__/hooks-settings.test.ts +0 -160
- package/src/__tests__/hooks-templates.test.ts +0 -169
- package/src/__tests__/hooks-ts-runner.test.ts +0 -170
- package/src/__tests__/hooks-watch.test.ts +0 -112
- package/src/__tests__/notification-schedule-dedup.test.ts +0 -213
- package/src/__tests__/oauth-scope-policy.test.ts +0 -180
- package/src/__tests__/outlook-attachments.test.ts +0 -301
- package/src/__tests__/outlook-automation-tools.test.ts +0 -425
- package/src/__tests__/outlook-categories.test.ts +0 -212
- package/src/__tests__/outlook-compose-tools.test.ts +0 -325
- package/src/__tests__/outlook-declutter-tools.test.ts +0 -585
- package/src/__tests__/outlook-follow-up.test.ts +0 -196
- package/src/__tests__/outlook-trash.test.ts +0 -77
- package/src/__tests__/outlook-unsubscribe.test.ts +0 -279
- package/src/__tests__/send-notification-tool.test.ts +0 -83
- package/src/__tests__/update-bulletin-format.test.ts +0 -181
- package/src/__tests__/update-bulletin-state.test.ts +0 -135
- package/src/__tests__/update-bulletin.test.ts +0 -478
- package/src/__tests__/update-template-contract.test.ts +0 -29
- package/src/cli/commands/doctor.ts +0 -341
- package/src/cli/commands/shotgun.ts +0 -266
- package/src/config/bundled-skills/browser/SKILL.md +0 -88
- package/src/config/bundled-skills/browser/TOOLS.json +0 -516
- package/src/config/bundled-skills/browser/tools/browser-attach.ts +0 -12
- package/src/config/bundled-skills/browser/tools/browser-click.ts +0 -12
- package/src/config/bundled-skills/browser/tools/browser-close.ts +0 -12
- package/src/config/bundled-skills/browser/tools/browser-detach.ts +0 -12
- package/src/config/bundled-skills/browser/tools/browser-extract.ts +0 -12
- package/src/config/bundled-skills/browser/tools/browser-fill-credential.ts +0 -12
- package/src/config/bundled-skills/browser/tools/browser-hover.ts +0 -12
- package/src/config/bundled-skills/browser/tools/browser-navigate.ts +0 -12
- package/src/config/bundled-skills/browser/tools/browser-press-key.ts +0 -12
- package/src/config/bundled-skills/browser/tools/browser-screenshot.ts +0 -12
- package/src/config/bundled-skills/browser/tools/browser-scroll.ts +0 -12
- package/src/config/bundled-skills/browser/tools/browser-select-option.ts +0 -12
- package/src/config/bundled-skills/browser/tools/browser-snapshot.ts +0 -12
- package/src/config/bundled-skills/browser/tools/browser-status.ts +0 -12
- package/src/config/bundled-skills/browser/tools/browser-type.ts +0 -12
- package/src/config/bundled-skills/browser/tools/browser-wait-for-download.ts +0 -49
- package/src/config/bundled-skills/browser/tools/browser-wait-for.ts +0 -12
- package/src/config/bundled-skills/chatgpt-import/SKILL.md +0 -27
- package/src/config/bundled-skills/chatgpt-import/TOOLS.json +0 -27
- package/src/config/bundled-skills/chatgpt-import/tools/chatgpt-import.ts +0 -378
- package/src/config/bundled-skills/conversations/SKILL.md +0 -20
- package/src/config/bundled-skills/conversations/TOOLS.json +0 -23
- package/src/config/bundled-skills/conversations/tools/rename-conversation.ts +0 -66
- package/src/config/bundled-skills/gmail/SKILL.md +0 -221
- package/src/config/bundled-skills/gmail/TOOLS.json +0 -588
- package/src/config/bundled-skills/gmail/tools/gmail-archive.ts +0 -256
- package/src/config/bundled-skills/gmail/tools/gmail-attachments.ts +0 -112
- package/src/config/bundled-skills/gmail/tools/gmail-draft.ts +0 -44
- package/src/config/bundled-skills/gmail/tools/gmail-filters.ts +0 -81
- package/src/config/bundled-skills/gmail/tools/gmail-follow-up.ts +0 -108
- package/src/config/bundled-skills/gmail/tools/gmail-forward.ts +0 -146
- package/src/config/bundled-skills/gmail/tools/gmail-label.ts +0 -53
- package/src/config/bundled-skills/gmail/tools/gmail-outreach-scan.ts +0 -347
- package/src/config/bundled-skills/gmail/tools/gmail-preferences-tool.ts +0 -59
- package/src/config/bundled-skills/gmail/tools/gmail-preferences.ts +0 -82
- package/src/config/bundled-skills/gmail/tools/gmail-send-draft.ts +0 -26
- package/src/config/bundled-skills/gmail/tools/gmail-sender-digest.ts +0 -347
- package/src/config/bundled-skills/gmail/tools/gmail-trash.ts +0 -29
- package/src/config/bundled-skills/gmail/tools/gmail-unsubscribe.ts +0 -122
- package/src/config/bundled-skills/gmail/tools/gmail-vacation.ts +0 -67
- package/src/config/bundled-skills/gmail/tools/scan-result-store.ts +0 -100
- package/src/config/bundled-skills/gmail/tools/shared.ts +0 -47
- package/src/config/bundled-skills/google-calendar/SKILL.md +0 -51
- package/src/config/bundled-skills/google-calendar/TOOLS.json +0 -226
- package/src/config/bundled-skills/google-calendar/calendar-client.ts +0 -223
- package/src/config/bundled-skills/google-calendar/tools/calendar-check-availability.ts +0 -27
- package/src/config/bundled-skills/google-calendar/tools/calendar-create-event.ts +0 -48
- package/src/config/bundled-skills/google-calendar/tools/calendar-get-event.ts +0 -19
- package/src/config/bundled-skills/google-calendar/tools/calendar-list-events.ts +0 -36
- package/src/config/bundled-skills/google-calendar/tools/calendar-rsvp.ts +0 -58
- package/src/config/bundled-skills/google-calendar/tools/shared.ts +0 -17
- package/src/config/bundled-skills/google-calendar/types.ts +0 -97
- package/src/config/bundled-skills/heartbeat/SKILL.md +0 -43
- package/src/config/bundled-skills/notifications/SKILL.md +0 -40
- package/src/config/bundled-skills/notifications/TOOLS.json +0 -80
- package/src/config/bundled-skills/notifications/tools/send-notification.ts +0 -152
- package/src/config/bundled-skills/notifications/tools/shared.ts +0 -13
- package/src/config/bundled-skills/outlook/SKILL.md +0 -196
- package/src/config/bundled-skills/outlook/TOOLS.json +0 -530
- package/src/config/bundled-skills/outlook/tools/outlook-attachments.ts +0 -85
- package/src/config/bundled-skills/outlook/tools/outlook-categories.ts +0 -77
- package/src/config/bundled-skills/outlook/tools/outlook-draft.ts +0 -84
- package/src/config/bundled-skills/outlook/tools/outlook-follow-up.ts +0 -94
- package/src/config/bundled-skills/outlook/tools/outlook-forward.ts +0 -49
- package/src/config/bundled-skills/outlook/tools/outlook-outreach-scan.ts +0 -237
- package/src/config/bundled-skills/outlook/tools/outlook-rules.ts +0 -161
- package/src/config/bundled-skills/outlook/tools/outlook-send-draft.ts +0 -32
- package/src/config/bundled-skills/outlook/tools/outlook-sender-digest.ts +0 -272
- package/src/config/bundled-skills/outlook/tools/outlook-trash.ts +0 -29
- package/src/config/bundled-skills/outlook/tools/outlook-unsubscribe.ts +0 -129
- package/src/config/bundled-skills/outlook/tools/outlook-vacation.ts +0 -87
- package/src/config/bundled-skills/outlook/tools/shared.ts +0 -20
- package/src/config/bundled-skills/outlook-calendar/SKILL.md +0 -51
- package/src/config/bundled-skills/outlook-calendar/TOOLS.json +0 -221
- package/src/config/bundled-skills/outlook-calendar/calendar-client.ts +0 -252
- package/src/config/bundled-skills/outlook-calendar/tools/outlook-calendar-check-availability.ts +0 -53
- package/src/config/bundled-skills/outlook-calendar/tools/outlook-calendar-create-event.ts +0 -74
- package/src/config/bundled-skills/outlook-calendar/tools/outlook-calendar-get-event.ts +0 -18
- package/src/config/bundled-skills/outlook-calendar/tools/outlook-calendar-list-events.ts +0 -46
- package/src/config/bundled-skills/outlook-calendar/tools/outlook-calendar-rsvp.ts +0 -36
- package/src/config/bundled-skills/outlook-calendar/tools/shared.ts +0 -17
- package/src/config/bundled-skills/outlook-calendar/types.ts +0 -120
- package/src/config/bundled-skills/screen-watch/SKILL.md +0 -27
- package/src/config/bundled-skills/screen-watch/TOOLS.json +0 -35
- package/src/config/bundled-skills/screen-watch/tools/start-screen-watch.ts +0 -12
- package/src/config/bundled-skills/skills-catalog/SKILL.md +0 -84
- package/src/config/bundled-skills/slack/SKILL.md +0 -108
- package/src/config/bundled-skills/tasks/SKILL.md +0 -37
- package/src/config/bundled-skills/tasks/TOOLS.json +0 -353
- package/src/config/bundled-skills/tasks/icon.svg +0 -34
- package/src/config/bundled-skills/tasks/tools/task-delete.ts +0 -12
- package/src/config/bundled-skills/tasks/tools/task-list-add.ts +0 -12
- package/src/config/bundled-skills/tasks/tools/task-list-remove.ts +0 -12
- package/src/config/bundled-skills/tasks/tools/task-list-show.ts +0 -12
- package/src/config/bundled-skills/tasks/tools/task-list-update.ts +0 -12
- package/src/config/bundled-skills/tasks/tools/task-list.ts +0 -12
- package/src/config/bundled-skills/tasks/tools/task-queue-run.ts +0 -12
- package/src/config/bundled-skills/tasks/tools/task-run.ts +0 -12
- package/src/config/bundled-skills/tasks/tools/task-save.ts +0 -12
- package/src/config/bundled-skills/watcher/SKILL.md +0 -31
- package/src/config/bundled-skills/watcher/TOOLS.json +0 -167
- package/src/config/bundled-skills/watcher/tools/watcher-create.ts +0 -12
- package/src/config/bundled-skills/watcher/tools/watcher-delete.ts +0 -12
- package/src/config/bundled-skills/watcher/tools/watcher-digest.ts +0 -12
- package/src/config/bundled-skills/watcher/tools/watcher-list.ts +0 -12
- package/src/config/bundled-skills/watcher/tools/watcher-update.ts +0 -12
- package/src/daemon/context-overflow-approval.ts +0 -52
- package/src/daemon/watch-handler.ts +0 -399
- package/src/hooks/cli.ts +0 -253
- package/src/hooks/config.ts +0 -100
- package/src/hooks/discovery.ts +0 -135
- package/src/hooks/manager.ts +0 -179
- package/src/hooks/runner.ts +0 -117
- package/src/hooks/templates.ts +0 -77
- package/src/hooks/types.ts +0 -75
- package/src/oauth/scope-policy.ts +0 -89
- package/src/prompts/templates/UPDATES.md +0 -50
- package/src/prompts/update-bulletin-format.ts +0 -85
- package/src/prompts/update-bulletin-state.ts +0 -58
- package/src/prompts/update-bulletin-template-path.ts +0 -13
- package/src/prompts/update-bulletin.ts +0 -139
- package/src/runtime/gateway-internal-client.ts +0 -94
- package/src/runtime/routes/watch-routes.ts +0 -156
- package/src/shared/provider-env-vars.ts +0 -19
- package/src/signals/shotgun.ts +0 -203
- package/src/tools/watch/screen-watch.ts +0 -144
- package/src/tools/watch/watch-state.ts +0 -142
- package/src/tools/watcher/create.ts +0 -86
- package/src/tools/watcher/delete.ts +0 -36
- package/src/tools/watcher/digest.ts +0 -54
- package/src/tools/watcher/list.ts +0 -83
- package/src/tools/watcher/update.ts +0 -71
|
@@ -0,0 +1,2522 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streaming `.vbundle` importer.
|
|
3
|
+
*
|
|
4
|
+
* Buffer-based `commitImport` decompresses the whole archive into RAM and
|
|
5
|
+
* re-walks the tar to write each file — fine for small bundles, OOMs on an
|
|
6
|
+
* 8 GB bundle running on a 3 GB pod. This module orchestrates the streaming
|
|
7
|
+
* primitives (`parseVBundleStream`, `readAndValidateManifest`,
|
|
8
|
+
* `createHashVerifier`) to import a bundle with peak memory bounded by
|
|
9
|
+
* "one tar entry size", not bundle size.
|
|
10
|
+
*
|
|
11
|
+
* Atomicity is provided by a temp-dir + double-rename pattern:
|
|
12
|
+
*
|
|
13
|
+
* 1. Entries land in `${workspaceDir}.import-<uuid>/` as they arrive, each
|
|
14
|
+
* byte verified against the manifest's declared sha256/size before it
|
|
15
|
+
* reaches disk.
|
|
16
|
+
* 2. After every declared entry is accounted for, the live DB connection
|
|
17
|
+
* is closed (`resetDb`) and the real workspace is swapped:
|
|
18
|
+
* `rename(workspaceDir, backupDir)`
|
|
19
|
+
* `rename(tempWorkspaceDir, workspaceDir)`
|
|
20
|
+
* — atomic on POSIX. If the second rename fails we restore the backup.
|
|
21
|
+
* 3. Post-commit side effects (credential import into CES, config/trust
|
|
22
|
+
* cache invalidation) run after the swap. Failures here are non-fatal
|
|
23
|
+
* — the workspace is already consistent.
|
|
24
|
+
*
|
|
25
|
+
* On any error before the rename pair, the temp workspace is removed and the
|
|
26
|
+
* real workspace is left untouched.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
30
|
+
import { createWriteStream, existsSync } from "node:fs";
|
|
31
|
+
import {
|
|
32
|
+
copyFile,
|
|
33
|
+
cp,
|
|
34
|
+
lstat,
|
|
35
|
+
mkdir,
|
|
36
|
+
readdir,
|
|
37
|
+
readFile,
|
|
38
|
+
readlink,
|
|
39
|
+
rename,
|
|
40
|
+
rm,
|
|
41
|
+
stat,
|
|
42
|
+
symlink,
|
|
43
|
+
unlink,
|
|
44
|
+
writeFile,
|
|
45
|
+
} from "node:fs/promises";
|
|
46
|
+
import { basename, dirname, join, resolve, sep } from "node:path";
|
|
47
|
+
import { type Readable, Writable } from "node:stream";
|
|
48
|
+
import { pipeline } from "node:stream/promises";
|
|
49
|
+
|
|
50
|
+
import { invalidateConfigCache } from "../../config/loader.js";
|
|
51
|
+
import { sanitizeConfigForTransfer } from "../../config/sanitize-for-transfer.js";
|
|
52
|
+
import { resetDb } from "../../memory/db-connection.js";
|
|
53
|
+
import { clearCache as clearTrustCache } from "../../permissions/trust-store.js";
|
|
54
|
+
import { isGuardianPersonaCustomized } from "../../prompts/persona-resolver.js";
|
|
55
|
+
import { getLogger } from "../../util/logger.js";
|
|
56
|
+
import type { PathResolver } from "./vbundle-import-analyzer.js";
|
|
57
|
+
import {
|
|
58
|
+
CONFIG_ARCHIVE_PATHS,
|
|
59
|
+
type ImportCommitReport,
|
|
60
|
+
type ImportCommitResult,
|
|
61
|
+
type ImportedFileReport,
|
|
62
|
+
type ImportFileAction,
|
|
63
|
+
LEGACY_USER_MD_ARCHIVE_PATH,
|
|
64
|
+
WORKSPACE_PRESERVE_PATHS,
|
|
65
|
+
} from "./vbundle-importer.js";
|
|
66
|
+
import { mergeMetadataPreservingVellum } from "./vbundle-metadata-merge.js";
|
|
67
|
+
import {
|
|
68
|
+
createHashVerifier,
|
|
69
|
+
readAndValidateManifest,
|
|
70
|
+
StreamingValidationError,
|
|
71
|
+
} from "./vbundle-streaming-validator.js";
|
|
72
|
+
import { parseVBundleStream } from "./vbundle-tar-stream.js";
|
|
73
|
+
import type { ManifestType } from "./vbundle-validator.js";
|
|
74
|
+
|
|
75
|
+
const log = getLogger("vbundle-streaming-importer");
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Resource ceilings
|
|
79
|
+
//
|
|
80
|
+
// These cap the streaming importer's exposure to attacker-controlled bundle
|
|
81
|
+
// inputs (e.g. a signed-URL migration from an untrusted source). Both caps
|
|
82
|
+
// are exposed as optional `opts.maxBundleBytes` / `opts.maxBundleEntries`
|
|
83
|
+
// parameters so tests can exercise the abort path with small fixtures —
|
|
84
|
+
// production callers should omit the opts and rely on the defaults.
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Byte ceiling for the cumulative size of all file data streamed from the
|
|
89
|
+
* bundle. 16 GiB gives comfortable headroom over the 8 GB product limit
|
|
90
|
+
* while still bounding worst-case disk use for the temp workspace.
|
|
91
|
+
*/
|
|
92
|
+
const DEFAULT_MAX_BUNDLE_BYTES = 16 * 1024 * 1024 * 1024;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Entry-count ceiling for the bundle. 100k is well above the largest
|
|
96
|
+
* workspace we ship; anything past that is almost certainly an attack or
|
|
97
|
+
* a corrupted archive.
|
|
98
|
+
*/
|
|
99
|
+
const DEFAULT_MAX_BUNDLE_ENTRIES = 100_000;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Prefixes used for scratch dirs the streaming importer creates INSIDE the
|
|
103
|
+
* workspace. Dot-prefixed to stay out of the way of real workspace content.
|
|
104
|
+
* Phase 1 of `swapWorkspaceContents` skips the EXACT scratch basenames for
|
|
105
|
+
* this run (via a `Set<string>` built from the backupDir/tempWorkspaceDir
|
|
106
|
+
* basenames), so a user entry that happens to start with one of these
|
|
107
|
+
* prefixes is still swept into the swap.
|
|
108
|
+
*/
|
|
109
|
+
const IMPORT_TEMP_PREFIX = ".import-";
|
|
110
|
+
const IMPORT_BACKUP_PREFIX = ".pre-import-";
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// Public API
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
export interface StreamProgressEvent {
|
|
117
|
+
/** Archive path of the entry that just finished streaming. */
|
|
118
|
+
archivePath: string;
|
|
119
|
+
/** Total bytes written for that entry (equals manifest-declared size on success). */
|
|
120
|
+
bytesWritten: number;
|
|
121
|
+
/**
|
|
122
|
+
* Zero-based index of the entry in the order it arrived in the tar. The
|
|
123
|
+
* manifest itself is index 0; the first file entry is index 1.
|
|
124
|
+
*/
|
|
125
|
+
entryIndex: number;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface StreamCommitArgs {
|
|
129
|
+
/** Byte source for the `.vbundle`. Typically an HTTP response body. */
|
|
130
|
+
source: Readable;
|
|
131
|
+
/** Maps archive paths to their canonical disk locations. */
|
|
132
|
+
pathResolver: PathResolver;
|
|
133
|
+
/** Absolute path to the real workspace directory. */
|
|
134
|
+
workspaceDir: string;
|
|
135
|
+
/** Optional progress callback invoked after each file entry finishes. */
|
|
136
|
+
onProgress?: (evt: StreamProgressEvent) => void;
|
|
137
|
+
/**
|
|
138
|
+
* Optional callback for importing credentials into CES after the atomic
|
|
139
|
+
* swap succeeds. Failures are treated as non-fatal warnings. When omitted,
|
|
140
|
+
* credentials discovered in the bundle are ignored — the caller
|
|
141
|
+
* (`migration-routes.ts`) is responsible for wiring this.
|
|
142
|
+
*/
|
|
143
|
+
importCredentials?: (
|
|
144
|
+
credentials: Array<{ account: string; value: string }>,
|
|
145
|
+
) => Promise<void>;
|
|
146
|
+
/**
|
|
147
|
+
* Test-only override for the bundle-size ceiling (bytes). Production
|
|
148
|
+
* callers should omit this and rely on the 16 GiB default.
|
|
149
|
+
*/
|
|
150
|
+
maxBundleBytes?: number;
|
|
151
|
+
/**
|
|
152
|
+
* Test-only override for the entry-count ceiling. Production callers
|
|
153
|
+
* should omit this and rely on the 100_000 default.
|
|
154
|
+
*/
|
|
155
|
+
maxBundleEntries?: number;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Stream a `.vbundle` archive from `source` and commit it to disk atomically.
|
|
160
|
+
*
|
|
161
|
+
* Returns an `ImportCommitResult` matching the shape produced by the
|
|
162
|
+
* buffer-based `commitImport`, so callers can treat the two paths
|
|
163
|
+
* interchangeably.
|
|
164
|
+
*/
|
|
165
|
+
export async function streamCommitImport(
|
|
166
|
+
args: StreamCommitArgs,
|
|
167
|
+
): Promise<ImportCommitResult> {
|
|
168
|
+
const {
|
|
169
|
+
source,
|
|
170
|
+
pathResolver,
|
|
171
|
+
workspaceDir,
|
|
172
|
+
onProgress,
|
|
173
|
+
importCredentials,
|
|
174
|
+
maxBundleBytes,
|
|
175
|
+
maxBundleEntries,
|
|
176
|
+
} = args;
|
|
177
|
+
|
|
178
|
+
const bundleByteCap = maxBundleBytes ?? DEFAULT_MAX_BUNDLE_BYTES;
|
|
179
|
+
const bundleEntryCap = maxBundleEntries ?? DEFAULT_MAX_BUNDLE_ENTRIES;
|
|
180
|
+
|
|
181
|
+
const realWorkspaceDir = resolve(workspaceDir);
|
|
182
|
+
|
|
183
|
+
// Replay recovery from any prior interrupted import BEFORE we stage
|
|
184
|
+
// new data. If the previous import died mid-swap, the marker / temp /
|
|
185
|
+
// backup still sit in the workspace and recoverInterruptedImport rolls
|
|
186
|
+
// them back. If that rollback is INCOMPLETE (per-entry restore failed
|
|
187
|
+
// and we had to preserve the marker for retry), we must REFUSE to
|
|
188
|
+
// start a new import — this function is about to rewrite the marker
|
|
189
|
+
// at the same path, and a fresh write would orphan the unresolved
|
|
190
|
+
// backup/temp pointers, making the interrupted state unrecoverable.
|
|
191
|
+
//
|
|
192
|
+
// In that case, return write_failed so the caller retries later; an
|
|
193
|
+
// operator can investigate the leftover `.pre-import-*` / `.import-*`
|
|
194
|
+
// dirs in the workspace.
|
|
195
|
+
let recoveryResult: RecoveryResult;
|
|
196
|
+
try {
|
|
197
|
+
recoveryResult = await recoverInterruptedImport(realWorkspaceDir);
|
|
198
|
+
} catch (err) {
|
|
199
|
+
log.error(
|
|
200
|
+
{ err, realWorkspaceDir },
|
|
201
|
+
"recoverInterruptedImport threw before streaming import",
|
|
202
|
+
);
|
|
203
|
+
return {
|
|
204
|
+
ok: false,
|
|
205
|
+
reason: "write_failed",
|
|
206
|
+
message: `Pre-import recovery failed: ${errMessage(err)}`,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
if (!recoveryResult.ok) {
|
|
210
|
+
log.error(
|
|
211
|
+
{
|
|
212
|
+
realWorkspaceDir,
|
|
213
|
+
failedCount: recoveryResult.failedCount,
|
|
214
|
+
},
|
|
215
|
+
"Previous import rollback is still unresolved; refusing to start a new import",
|
|
216
|
+
);
|
|
217
|
+
return {
|
|
218
|
+
ok: false,
|
|
219
|
+
reason: "write_failed",
|
|
220
|
+
message:
|
|
221
|
+
`Previous import rollback is still unresolved (${recoveryResult.failedCount} entries failed to restore). ` +
|
|
222
|
+
"Leftover backup/temp dirs are preserved in the workspace; manual intervention may be required before the next import.",
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Put scratch dirs (temp staging tree, backup dir) INSIDE the workspace
|
|
227
|
+
// mount so every move during the content-level swap stays on the same
|
|
228
|
+
// filesystem. If they lived as siblings (on the container overlay),
|
|
229
|
+
// every rename in swapWorkspaceContents would cross filesystems and
|
|
230
|
+
// require a full cp+rm of the entire workspace. That defeats the
|
|
231
|
+
// zero-disk fast path and risks ENOSPC on the overlay for large
|
|
232
|
+
// teleports. Dot-prefixed names keep them out of the way of normal
|
|
233
|
+
// content; phase 1 of swapWorkspaceContents filters them out by exact
|
|
234
|
+
// basename so user entries that happen to start with these prefixes
|
|
235
|
+
// are still swept through the swap.
|
|
236
|
+
const tempWorkspaceDir = join(
|
|
237
|
+
realWorkspaceDir,
|
|
238
|
+
`${IMPORT_TEMP_PREFIX}${randomUUID()}`,
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
let manifest: ManifestType | null = null;
|
|
242
|
+
const importedFiles: ImportedFileReport[] = [];
|
|
243
|
+
const warnings: string[] = [];
|
|
244
|
+
const seen = new Set<string>();
|
|
245
|
+
// Credential bodies are small (API keys / tokens) — safe to buffer in
|
|
246
|
+
// memory. They intentionally never touch disk: DefaultPathResolver returns
|
|
247
|
+
// null for `credentials/*`, and CES is the only consumer.
|
|
248
|
+
const bufferedCredentials: Array<{ account: string; value: string }> = [];
|
|
249
|
+
// Track whether the bundle contains at least one `workspace/*` entry that
|
|
250
|
+
// resolves to a real disk path. The atomic swap path (which wipes anything
|
|
251
|
+
// outside WORKSPACE_PRESERVE_PATHS) is only safe to take when this is
|
|
252
|
+
// true — it matches commitImport's `hasWorkspaceEntries` gate. Legacy
|
|
253
|
+
// bundles (e.g. `data/db/*`, `config/*`, `prompts/*`, `skills/*` without a
|
|
254
|
+
// workspace/ prefix) fall through to the in-place write path below.
|
|
255
|
+
let hasWorkspaceNamespacedEntry = false;
|
|
256
|
+
// Accumulates the disk paths of files we staged into the temp workspace
|
|
257
|
+
// from legacy-format archive entries. If the bundle turns out to contain
|
|
258
|
+
// NO workspace/ entries we promote each of these into the live workspace
|
|
259
|
+
// with backup-before-overwrite semantics, matching commitImport's legacy
|
|
260
|
+
// handling. Each tuple carries (tempPath, livePath, archivePath, index).
|
|
261
|
+
const legacyStaged: Array<{
|
|
262
|
+
tempPath: string;
|
|
263
|
+
livePath: string;
|
|
264
|
+
archivePath: string;
|
|
265
|
+
importedFileIndex: number;
|
|
266
|
+
}> = [];
|
|
267
|
+
// Cumulative manifest-declared byte total, accumulated BEFORE each entry
|
|
268
|
+
// is read/written. Checked against `bundleByteCap` pre-write so an
|
|
269
|
+
// oversized entry never lands on disk. We count manifest-declared
|
|
270
|
+
// `expectedEntry.size` (the raw archive bytes) rather than on-disk size
|
|
271
|
+
// so a sanitized config still counts against the cap as originally
|
|
272
|
+
// declared.
|
|
273
|
+
let totalBytesStreamed = 0;
|
|
274
|
+
// Number of file/directory entries processed (not counting the manifest).
|
|
275
|
+
// Compared against `bundleEntryCap`.
|
|
276
|
+
let entryCount = 0;
|
|
277
|
+
|
|
278
|
+
// Create the temp workspace dir up front so any failure between here and
|
|
279
|
+
// the atomic swap can be cleaned up by the catch block below.
|
|
280
|
+
try {
|
|
281
|
+
await mkdir(tempWorkspaceDir, { recursive: true });
|
|
282
|
+
} catch (err) {
|
|
283
|
+
return {
|
|
284
|
+
ok: false,
|
|
285
|
+
reason: "write_failed",
|
|
286
|
+
message: `Failed to create temp workspace dir "${tempWorkspaceDir}": ${errMessage(err)}`,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const cleanupTempDir = async (): Promise<void> => {
|
|
291
|
+
try {
|
|
292
|
+
await rm(tempWorkspaceDir, { recursive: true, force: true });
|
|
293
|
+
} catch (err) {
|
|
294
|
+
log.warn(
|
|
295
|
+
{ err, tempWorkspaceDir },
|
|
296
|
+
"Failed to clean up temp workspace dir after import failure",
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
// Iterate the tar stream. Any error from gzip/tar/source bubbles out of
|
|
302
|
+
// the generator and lands in the catch block below.
|
|
303
|
+
let entryIndex = 0;
|
|
304
|
+
try {
|
|
305
|
+
const entries = parseVBundleStream(source);
|
|
306
|
+
let expected: Map<string, { sha256: string; size: number }> | null = null;
|
|
307
|
+
|
|
308
|
+
for await (const entry of entries) {
|
|
309
|
+
if (entryIndex === 0) {
|
|
310
|
+
// First entry MUST be manifest.json — readAndValidateManifest
|
|
311
|
+
// enforces that and throws StreamingValidationError otherwise.
|
|
312
|
+
const manifestResult = await readAndValidateManifest(entry);
|
|
313
|
+
manifest = manifestResult.manifest;
|
|
314
|
+
expected = manifestResult.expected;
|
|
315
|
+
// Entry-count ceiling check. The manifest declares every file the
|
|
316
|
+
// bundle claims to contain, so one check here bounds the work the
|
|
317
|
+
// importer is willing to do for this bundle.
|
|
318
|
+
if (manifest.files.length > bundleEntryCap) {
|
|
319
|
+
throw new StreamingValidationError(
|
|
320
|
+
"bundle_too_many_entries",
|
|
321
|
+
`bundle contains more than ${bundleEntryCap} entries (declared: ${manifest.files.length})`,
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
entryIndex += 1;
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// After the manifest we must have `expected` populated.
|
|
329
|
+
if (!manifest || !expected) {
|
|
330
|
+
throw new StreamingValidationError(
|
|
331
|
+
"manifest_not_first",
|
|
332
|
+
"Manifest processing did not complete before subsequent entries",
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Entry-count ceiling also applies to tar-level entries that arrive
|
|
337
|
+
// in the stream (pax headers, directories, extras). A bundle whose
|
|
338
|
+
// manifest stayed under the cap but whose tar carries padding-style
|
|
339
|
+
// extras is still bounded.
|
|
340
|
+
entryCount += 1;
|
|
341
|
+
if (entryCount > bundleEntryCap) {
|
|
342
|
+
entry.body.destroy();
|
|
343
|
+
throw new StreamingValidationError(
|
|
344
|
+
"bundle_too_many_entries",
|
|
345
|
+
`bundle contains more than ${bundleEntryCap} entries`,
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const archivePath = entry.header.name;
|
|
350
|
+
|
|
351
|
+
// Non-file entries are either directory markers (empty body) or
|
|
352
|
+
// pax-header / other metadata payloads we don't consume. Apply the
|
|
353
|
+
// bundle byte cap to their tar-header size too — an attacker could
|
|
354
|
+
// otherwise keep `manifest.files` small while stuffing huge pax/other
|
|
355
|
+
// entry bodies, draining the importer for free. Directory bodies are
|
|
356
|
+
// reliably zero-sized; pax headers are measured in bytes, so this
|
|
357
|
+
// check is effectively free in the happy path.
|
|
358
|
+
if (entry.header.type !== "file") {
|
|
359
|
+
const nonFileSize = entry.header.size ?? 0;
|
|
360
|
+
if (totalBytesStreamed + nonFileSize > bundleByteCap) {
|
|
361
|
+
entry.body.destroy();
|
|
362
|
+
throw new StreamingValidationError(
|
|
363
|
+
"bundle_too_large",
|
|
364
|
+
`bundle exceeds ${bundleByteCap}-byte ceiling (non-file entry "${archivePath}" size ${nonFileSize})`,
|
|
365
|
+
archivePath,
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
totalBytesStreamed += nonFileSize;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (entry.header.type === "directory") {
|
|
372
|
+
// Best-effort: create the directory inside the temp workspace if it
|
|
373
|
+
// resolves inside `workspaceDir`. Drain the empty body either way.
|
|
374
|
+
entry.body.resume();
|
|
375
|
+
const dirResolved = resolveInsideTempWorkspace(
|
|
376
|
+
archivePath,
|
|
377
|
+
pathResolver,
|
|
378
|
+
realWorkspaceDir,
|
|
379
|
+
tempWorkspaceDir,
|
|
380
|
+
);
|
|
381
|
+
if (dirResolved) {
|
|
382
|
+
try {
|
|
383
|
+
await mkdir(dirResolved, { recursive: true });
|
|
384
|
+
} catch (err) {
|
|
385
|
+
throw wrapWriteError(
|
|
386
|
+
`Failed to create directory "${dirResolved}"`,
|
|
387
|
+
err,
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
entryIndex += 1;
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (entry.header.type !== "file") {
|
|
396
|
+
// pax-header / other — drain and skip. Non-file payloads are
|
|
397
|
+
// metadata for the tar extractor itself, not user data.
|
|
398
|
+
entry.body.resume();
|
|
399
|
+
entryIndex += 1;
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const expectedEntry = expected.get(archivePath);
|
|
404
|
+
if (!expectedEntry) {
|
|
405
|
+
// Bundle contains a file the manifest didn't declare. Destroy the
|
|
406
|
+
// body so the extractor aborts promptly.
|
|
407
|
+
entry.body.destroy();
|
|
408
|
+
throw new StreamingValidationError(
|
|
409
|
+
"manifest_mismatch",
|
|
410
|
+
`Archive entry "${archivePath}" is not declared in the manifest`,
|
|
411
|
+
archivePath,
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Reject tar entries whose declared size disagrees with the manifest.
|
|
416
|
+
// The bundle-size ceiling below trusts `expectedEntry.size`; if a
|
|
417
|
+
// crafted bundle declared a tiny size in `manifest.json` but carried a
|
|
418
|
+
// huge body in the tar header, the cap would pass and the oversized
|
|
419
|
+
// payload would still stream to disk. `createHashVerifier` already
|
|
420
|
+
// fails on size mismatch at stream end, but by then the bytes have
|
|
421
|
+
// already been written. Fail fast here so no oversized payload lands
|
|
422
|
+
// on disk.
|
|
423
|
+
if (entry.header.size !== expectedEntry.size) {
|
|
424
|
+
entry.body.destroy();
|
|
425
|
+
throw new StreamingValidationError(
|
|
426
|
+
"entry_size",
|
|
427
|
+
`Archive entry "${archivePath}" has tar-header size ${entry.header.size} but manifest declares ${expectedEntry.size}`,
|
|
428
|
+
archivePath,
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Enforce the bundle-size ceiling BEFORE writing/consuming the entry.
|
|
433
|
+
// Checking post-write would still let a single oversized file land on
|
|
434
|
+
// disk before we reject, defeating the cap as a resource guard. We
|
|
435
|
+
// check both the manifest-declared size (what we just verified the
|
|
436
|
+
// tar agrees with) AND the tar-header size directly, using whichever
|
|
437
|
+
// is larger, so a future header/manifest desync can't slip through.
|
|
438
|
+
const declaredSize = Math.max(entry.header.size, expectedEntry.size);
|
|
439
|
+
if (totalBytesStreamed + declaredSize > bundleByteCap) {
|
|
440
|
+
entry.body.destroy();
|
|
441
|
+
throw new StreamingValidationError(
|
|
442
|
+
"bundle_too_large",
|
|
443
|
+
`bundle exceeds ${bundleByteCap}-byte ceiling`,
|
|
444
|
+
archivePath,
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
totalBytesStreamed += declaredSize;
|
|
448
|
+
|
|
449
|
+
if (archivePath.startsWith("credentials/")) {
|
|
450
|
+
// Credentials are hash-verified against the manifest but collected
|
|
451
|
+
// in memory rather than written to disk. DefaultPathResolver
|
|
452
|
+
// deliberately returns null for these paths.
|
|
453
|
+
const buffered = await collectHashVerified(entry.body, {
|
|
454
|
+
sha256: expectedEntry.sha256,
|
|
455
|
+
size: expectedEntry.size,
|
|
456
|
+
archivePath,
|
|
457
|
+
});
|
|
458
|
+
const account = archivePath.slice("credentials/".length);
|
|
459
|
+
if (account) {
|
|
460
|
+
bufferedCredentials.push({
|
|
461
|
+
account,
|
|
462
|
+
value: new TextDecoder().decode(buffered),
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
seen.add(archivePath);
|
|
466
|
+
onProgress?.({
|
|
467
|
+
archivePath,
|
|
468
|
+
bytesWritten: expectedEntry.size,
|
|
469
|
+
entryIndex,
|
|
470
|
+
});
|
|
471
|
+
entryIndex += 1;
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const diskPath = pathResolver.resolve(archivePath);
|
|
476
|
+
if (!diskPath) {
|
|
477
|
+
// Unknown destination. Consume bytes through the verifier anyway so
|
|
478
|
+
// we still catch manifest/content mismatches, but don't write.
|
|
479
|
+
// Tracking this in the report matches the buffer-based importer's
|
|
480
|
+
// "skipped" semantics.
|
|
481
|
+
await drainThroughVerifier(entry.body, {
|
|
482
|
+
sha256: expectedEntry.sha256,
|
|
483
|
+
size: expectedEntry.size,
|
|
484
|
+
archivePath,
|
|
485
|
+
});
|
|
486
|
+
importedFiles.push({
|
|
487
|
+
path: archivePath,
|
|
488
|
+
disk_path: "",
|
|
489
|
+
action: "skipped",
|
|
490
|
+
size: expectedEntry.size,
|
|
491
|
+
sha256: expectedEntry.sha256,
|
|
492
|
+
backup_path: null,
|
|
493
|
+
});
|
|
494
|
+
warnings.push(
|
|
495
|
+
`Skipped "${archivePath}": no known disk target for this archive path`,
|
|
496
|
+
);
|
|
497
|
+
seen.add(archivePath);
|
|
498
|
+
onProgress?.({
|
|
499
|
+
archivePath,
|
|
500
|
+
bytesWritten: expectedEntry.size,
|
|
501
|
+
entryIndex,
|
|
502
|
+
});
|
|
503
|
+
entryIndex += 1;
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Legacy guardian persona (prompts/USER.md) is translated to the
|
|
508
|
+
// current guardian's users/<slug>.md by DefaultPathResolver. If
|
|
509
|
+
// that target already holds user-authored content, skip rather
|
|
510
|
+
// than clobber — the user has curated their persona since the
|
|
511
|
+
// bundle was exported. We check against the LIVE workspace path
|
|
512
|
+
// (diskPath) because the swap hasn't happened yet.
|
|
513
|
+
if (
|
|
514
|
+
archivePath === LEGACY_USER_MD_ARCHIVE_PATH &&
|
|
515
|
+
isGuardianPersonaCustomized(diskPath)
|
|
516
|
+
) {
|
|
517
|
+
log.warn(
|
|
518
|
+
{ archivePath, diskPath },
|
|
519
|
+
"Skipping legacy prompts/USER.md import: guardian persona is already customized",
|
|
520
|
+
);
|
|
521
|
+
await drainThroughVerifier(entry.body, {
|
|
522
|
+
sha256: expectedEntry.sha256,
|
|
523
|
+
size: expectedEntry.size,
|
|
524
|
+
archivePath,
|
|
525
|
+
});
|
|
526
|
+
importedFiles.push({
|
|
527
|
+
path: archivePath,
|
|
528
|
+
disk_path: diskPath,
|
|
529
|
+
action: "skipped",
|
|
530
|
+
size: expectedEntry.size,
|
|
531
|
+
sha256: expectedEntry.sha256,
|
|
532
|
+
backup_path: null,
|
|
533
|
+
});
|
|
534
|
+
warnings.push(
|
|
535
|
+
`Skipped "${archivePath}": guardian persona at "${diskPath}" is already customized`,
|
|
536
|
+
);
|
|
537
|
+
seen.add(archivePath);
|
|
538
|
+
onProgress?.({
|
|
539
|
+
archivePath,
|
|
540
|
+
bytesWritten: expectedEntry.size,
|
|
541
|
+
entryIndex,
|
|
542
|
+
});
|
|
543
|
+
entryIndex += 1;
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// Rebase the resolved path onto the temp workspace.
|
|
548
|
+
const tempDiskPath = rebaseOntoTempWorkspace(
|
|
549
|
+
diskPath,
|
|
550
|
+
realWorkspaceDir,
|
|
551
|
+
tempWorkspaceDir,
|
|
552
|
+
);
|
|
553
|
+
if (!tempDiskPath) {
|
|
554
|
+
// Resolved outside the workspace directory. Not supported for the
|
|
555
|
+
// streaming atomic-swap path — write through the verifier but flag
|
|
556
|
+
// as skipped.
|
|
557
|
+
await drainThroughVerifier(entry.body, {
|
|
558
|
+
sha256: expectedEntry.sha256,
|
|
559
|
+
size: expectedEntry.size,
|
|
560
|
+
archivePath,
|
|
561
|
+
});
|
|
562
|
+
importedFiles.push({
|
|
563
|
+
path: archivePath,
|
|
564
|
+
disk_path: diskPath,
|
|
565
|
+
action: "skipped",
|
|
566
|
+
size: expectedEntry.size,
|
|
567
|
+
sha256: expectedEntry.sha256,
|
|
568
|
+
backup_path: null,
|
|
569
|
+
});
|
|
570
|
+
warnings.push(
|
|
571
|
+
`Skipped "${archivePath}": disk target "${diskPath}" falls outside the workspace directory`,
|
|
572
|
+
);
|
|
573
|
+
seen.add(archivePath);
|
|
574
|
+
onProgress?.({
|
|
575
|
+
archivePath,
|
|
576
|
+
bytesWritten: expectedEntry.size,
|
|
577
|
+
entryIndex,
|
|
578
|
+
});
|
|
579
|
+
entryIndex += 1;
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
try {
|
|
584
|
+
await mkdir(dirname(tempDiskPath), { recursive: true });
|
|
585
|
+
} catch (err) {
|
|
586
|
+
throw wrapWriteError(
|
|
587
|
+
`Failed to create parent directory for "${tempDiskPath}"`,
|
|
588
|
+
err,
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Classify the entry as `workspace/*` (namespaced) vs legacy format.
|
|
593
|
+
// Namespaced entries flip the swap-gate flag; legacy entries are
|
|
594
|
+
// staged for an in-place promote after the stream completes.
|
|
595
|
+
const isWorkspaceNamespaced = archivePath.startsWith("workspace/");
|
|
596
|
+
|
|
597
|
+
// Config files need sanitization before writing to strip
|
|
598
|
+
// environment-specific fields (defense-in-depth; matches commitImport).
|
|
599
|
+
// Configs are small (KB-scale) so buffering them is fine. Hash
|
|
600
|
+
// verification still runs on the RAW bytes — the manifest declares the
|
|
601
|
+
// sha/size of the archive content, not the sanitized output.
|
|
602
|
+
if (CONFIG_ARCHIVE_PATHS.has(archivePath)) {
|
|
603
|
+
const rawBytes = await collectHashVerified(entry.body, {
|
|
604
|
+
sha256: expectedEntry.sha256,
|
|
605
|
+
size: expectedEntry.size,
|
|
606
|
+
archivePath,
|
|
607
|
+
});
|
|
608
|
+
const sanitized = sanitizeConfigForTransfer(
|
|
609
|
+
new TextDecoder().decode(rawBytes),
|
|
610
|
+
);
|
|
611
|
+
const sanitizedBytes = new TextEncoder().encode(sanitized);
|
|
612
|
+
try {
|
|
613
|
+
await writeFile(tempDiskPath, sanitizedBytes, { mode: 0o600 });
|
|
614
|
+
} catch (err) {
|
|
615
|
+
throw wrapWriteError(`Failed to write "${tempDiskPath}"`, err);
|
|
616
|
+
}
|
|
617
|
+
// commitImport reports the sha256 of the bytes actually written to
|
|
618
|
+
// disk (which differs from the manifest-declared sha once
|
|
619
|
+
// sanitization strips fields). Mirror that here so downstream
|
|
620
|
+
// integrity re-checks against the on-disk file succeed.
|
|
621
|
+
const onDiskSha = sha256Hex(sanitizedBytes);
|
|
622
|
+
const importedFileIndex = importedFiles.length;
|
|
623
|
+
importedFiles.push({
|
|
624
|
+
path: archivePath,
|
|
625
|
+
disk_path: diskPath,
|
|
626
|
+
action: "created",
|
|
627
|
+
// Report the sanitized on-disk size, not the archive's raw size —
|
|
628
|
+
// matches what commitImport reports.
|
|
629
|
+
size: sanitizedBytes.length,
|
|
630
|
+
sha256: onDiskSha,
|
|
631
|
+
backup_path: null,
|
|
632
|
+
});
|
|
633
|
+
if (isWorkspaceNamespaced) {
|
|
634
|
+
hasWorkspaceNamespacedEntry = true;
|
|
635
|
+
} else {
|
|
636
|
+
legacyStaged.push({
|
|
637
|
+
tempPath: tempDiskPath,
|
|
638
|
+
livePath: diskPath,
|
|
639
|
+
archivePath,
|
|
640
|
+
importedFileIndex,
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
seen.add(archivePath);
|
|
644
|
+
onProgress?.({
|
|
645
|
+
archivePath,
|
|
646
|
+
bytesWritten: expectedEntry.size,
|
|
647
|
+
entryIndex,
|
|
648
|
+
});
|
|
649
|
+
entryIndex += 1;
|
|
650
|
+
continue;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
const verifier = createHashVerifier({
|
|
654
|
+
sha256: expectedEntry.sha256,
|
|
655
|
+
size: expectedEntry.size,
|
|
656
|
+
archivePath,
|
|
657
|
+
});
|
|
658
|
+
const writeStream = createWriteStream(tempDiskPath, { mode: 0o600 });
|
|
659
|
+
try {
|
|
660
|
+
await pipeline(entry.body, verifier, writeStream);
|
|
661
|
+
} catch (err) {
|
|
662
|
+
// Disambiguate between hash/size validation failures and raw disk
|
|
663
|
+
// write errors so the caller sees the right reason code.
|
|
664
|
+
if (err instanceof StreamingValidationError) {
|
|
665
|
+
throw err;
|
|
666
|
+
}
|
|
667
|
+
throw wrapWriteError(`Failed to write "${tempDiskPath}"`, err);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// Action is "created" for the in-temp-tree record. Whether the real
|
|
671
|
+
// workspace sees this as create vs overwrite is resolved later: the
|
|
672
|
+
// atomic-swap path wipes and replaces wholesale, while the legacy
|
|
673
|
+
// in-place promote checks against the live file and flips the action
|
|
674
|
+
// to "overwritten" with a backup.
|
|
675
|
+
const action: ImportFileAction = "created";
|
|
676
|
+
const importedFileIndex = importedFiles.length;
|
|
677
|
+
importedFiles.push({
|
|
678
|
+
path: archivePath,
|
|
679
|
+
disk_path: diskPath,
|
|
680
|
+
action,
|
|
681
|
+
size: expectedEntry.size,
|
|
682
|
+
sha256: expectedEntry.sha256,
|
|
683
|
+
backup_path: null,
|
|
684
|
+
});
|
|
685
|
+
if (isWorkspaceNamespaced) {
|
|
686
|
+
hasWorkspaceNamespacedEntry = true;
|
|
687
|
+
} else {
|
|
688
|
+
legacyStaged.push({
|
|
689
|
+
tempPath: tempDiskPath,
|
|
690
|
+
livePath: diskPath,
|
|
691
|
+
archivePath,
|
|
692
|
+
importedFileIndex,
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
seen.add(archivePath);
|
|
696
|
+
onProgress?.({
|
|
697
|
+
archivePath,
|
|
698
|
+
bytesWritten: expectedEntry.size,
|
|
699
|
+
entryIndex,
|
|
700
|
+
});
|
|
701
|
+
entryIndex += 1;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// Manifest must have been processed.
|
|
705
|
+
if (!manifest || !expected) {
|
|
706
|
+
throw new StreamingValidationError(
|
|
707
|
+
"manifest_not_first",
|
|
708
|
+
"Archive contained no entries",
|
|
709
|
+
);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// Every declared manifest path must have been seen in the tar stream.
|
|
713
|
+
const missing: string[] = [];
|
|
714
|
+
for (const path of expected.keys()) {
|
|
715
|
+
if (!seen.has(path)) missing.push(path);
|
|
716
|
+
}
|
|
717
|
+
if (missing.length > 0) {
|
|
718
|
+
throw new StreamingValidationError(
|
|
719
|
+
"missing_entry",
|
|
720
|
+
`Bundle is missing ${missing.length} declared entr${
|
|
721
|
+
missing.length === 1 ? "y" : "ies"
|
|
722
|
+
}: ${missing.slice(0, 5).join(", ")}${missing.length > 5 ? ", …" : ""}`,
|
|
723
|
+
missing[0],
|
|
724
|
+
);
|
|
725
|
+
}
|
|
726
|
+
} catch (err) {
|
|
727
|
+
await cleanupTempDir();
|
|
728
|
+
return mapThrownToResult(err);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// -------------------------------------------------------------------------
|
|
732
|
+
// Commit strategy selection
|
|
733
|
+
//
|
|
734
|
+
// commitImport's in-place path only clears the workspace when the bundle
|
|
735
|
+
// carries at least one `workspace/*` entry that resolves to a real disk
|
|
736
|
+
// path — legacy-format bundles (`data/db/*`, `config/*`, `prompts/*`,
|
|
737
|
+
// `skills/*`, `hooks/*` without a workspace/ prefix) write individual
|
|
738
|
+
// files in place without wiping siblings. The streaming importer's
|
|
739
|
+
// atomic-swap path is equivalent to the selective-clear-and-write path;
|
|
740
|
+
// it must therefore only fire when `hasWorkspaceNamespacedEntry` is
|
|
741
|
+
// true. For legacy-only bundles we promote staged temp files into the
|
|
742
|
+
// live workspace one by one with backup-before-overwrite semantics.
|
|
743
|
+
// -------------------------------------------------------------------------
|
|
744
|
+
|
|
745
|
+
// Empty result: no writable entries, no staged legacy files. Skip both
|
|
746
|
+
// commit paths — nothing can alter the live workspace. This matches
|
|
747
|
+
// commitImport's no-op behavior for all-credential or all-skipped
|
|
748
|
+
// bundles.
|
|
749
|
+
if (!hasWorkspaceNamespacedEntry && legacyStaged.length === 0) {
|
|
750
|
+
await cleanupTempDir();
|
|
751
|
+
|
|
752
|
+
// Post-commit side effects still run for things like credential import.
|
|
753
|
+
if (importCredentials && bufferedCredentials.length > 0) {
|
|
754
|
+
try {
|
|
755
|
+
await importCredentials(bufferedCredentials);
|
|
756
|
+
} catch (err) {
|
|
757
|
+
log.warn(
|
|
758
|
+
{ err, count: bufferedCredentials.length },
|
|
759
|
+
"Post-commit credential import failed",
|
|
760
|
+
);
|
|
761
|
+
warnings.push(`Credential import failed: ${errMessage(err)}`);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
const report = buildReport(manifest, importedFiles, warnings);
|
|
766
|
+
return { ok: true, report };
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// Legacy-only bundle: we have files staged under the temp workspace but
|
|
770
|
+
// no `workspace/*` entries telling us the caller wants to replace the
|
|
771
|
+
// entire workspace. Promote each staged file into the live workspace in
|
|
772
|
+
// place, matching commitImport's legacy branch (backup-before-overwrite,
|
|
773
|
+
// parent-dir mkdir, no workspace-wide clear). The temp workspace is
|
|
774
|
+
// removed when done — it only served as a landing zone for the verified
|
|
775
|
+
// hash stream.
|
|
776
|
+
if (!hasWorkspaceNamespacedEntry) {
|
|
777
|
+
// Close the live SQLite connection before promoting staged files. A
|
|
778
|
+
// legacy bundle may carry `data/db/assistant.db`, and replacing the file
|
|
779
|
+
// with an open connection leaves the daemon pinned to the old inode —
|
|
780
|
+
// subsequent reads/writes would go against stale pre-import data until
|
|
781
|
+
// the process reset the connection. The singleton lazily reopens on next
|
|
782
|
+
// use, so closing here is safe even if no DB entry is in the bundle.
|
|
783
|
+
try {
|
|
784
|
+
resetDb();
|
|
785
|
+
} catch (err) {
|
|
786
|
+
log.warn(
|
|
787
|
+
{ err },
|
|
788
|
+
"resetDb threw before legacy-format import promotion; continuing",
|
|
789
|
+
);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
try {
|
|
793
|
+
await promoteLegacyStagedFiles(legacyStaged, importedFiles);
|
|
794
|
+
} catch (err) {
|
|
795
|
+
// Legacy promotion mutates live files one at a time, so a mid-loop
|
|
796
|
+
// failure leaves an observable partial import: every entry in
|
|
797
|
+
// `importedFiles` whose `action` has flipped from "created" (the
|
|
798
|
+
// temp-staged state) to "overwritten" or that now carries a
|
|
799
|
+
// `backup_path` has landed on live disk. Report that back so callers
|
|
800
|
+
// can tell what changed, matching commitImport's partial_report
|
|
801
|
+
// contract for its in-place path.
|
|
802
|
+
const partialReport = buildReport(manifest, importedFiles, warnings);
|
|
803
|
+
await cleanupTempDir();
|
|
804
|
+
return {
|
|
805
|
+
ok: false,
|
|
806
|
+
reason: "write_failed",
|
|
807
|
+
message: `Failed to promote legacy-format import into workspace: ${errMessage(err)}`,
|
|
808
|
+
partial_report: partialReport,
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
await cleanupTempDir();
|
|
813
|
+
|
|
814
|
+
// Post-commit side effects. Config/trust caches can still be stale
|
|
815
|
+
// from a legacy config/settings.json write, and credentials still
|
|
816
|
+
// need to flow through CES.
|
|
817
|
+
if (importCredentials && bufferedCredentials.length > 0) {
|
|
818
|
+
try {
|
|
819
|
+
await importCredentials(bufferedCredentials);
|
|
820
|
+
} catch (err) {
|
|
821
|
+
log.warn(
|
|
822
|
+
{ err, count: bufferedCredentials.length },
|
|
823
|
+
"Post-commit credential import failed",
|
|
824
|
+
);
|
|
825
|
+
warnings.push(`Credential import failed: ${errMessage(err)}`);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
try {
|
|
830
|
+
invalidateConfigCache();
|
|
831
|
+
} catch (err) {
|
|
832
|
+
log.warn({ err }, "invalidateConfigCache threw after legacy import");
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
try {
|
|
836
|
+
clearTrustCache();
|
|
837
|
+
} catch (err) {
|
|
838
|
+
log.warn({ err }, "clearTrustCache threw after legacy import");
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
const report = buildReport(manifest, importedFiles, warnings);
|
|
842
|
+
return { ok: true, report };
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// Atomic swap path for workspace/*-carrying bundles.
|
|
846
|
+
|
|
847
|
+
// Close the live SQLite connection so the DB file inside the real
|
|
848
|
+
// workspace can be replaced. The singleton lazily reopens on next use.
|
|
849
|
+
try {
|
|
850
|
+
resetDb();
|
|
851
|
+
} catch (err) {
|
|
852
|
+
// resetDb close failure is extremely unlikely but not worth aborting
|
|
853
|
+
// over — log and continue.
|
|
854
|
+
log.warn({ err }, "resetDb threw before swap; continuing");
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// Preserve the target's `vellum:*` credential metadata entries across
|
|
858
|
+
// the swap. Django's post-hatch provisioning on the platform writes
|
|
859
|
+
// `vellum:platform_base_url` / `assistant_api_key` / `platform_assistant_id`
|
|
860
|
+
// / `webhook_secret` via POST /v1/secrets, which upserts into the live
|
|
861
|
+
// workspace's `data/credentials/metadata.json`. Without this merge the
|
|
862
|
+
// swap would replace that file with the source's copy (which has no
|
|
863
|
+
// vellum entries on local sources), and the gateway's
|
|
864
|
+
// `readServiceCredentials` would stop finding the platform API key.
|
|
865
|
+
//
|
|
866
|
+
// Executes in the temp workspace only — no effect on the live workspace
|
|
867
|
+
// — so a failure here leaves pre-swap state untouched. Any filesystem
|
|
868
|
+
// error is logged and degraded to a warning rather than aborting the
|
|
869
|
+
// import (credential loss is recoverable via reprovision; an aborted
|
|
870
|
+
// swap is a larger regression).
|
|
871
|
+
const liveMetadataPath = join(
|
|
872
|
+
realWorkspaceDir,
|
|
873
|
+
"data",
|
|
874
|
+
"credentials",
|
|
875
|
+
"metadata.json",
|
|
876
|
+
);
|
|
877
|
+
const tempMetadataPath = join(
|
|
878
|
+
tempWorkspaceDir,
|
|
879
|
+
"data",
|
|
880
|
+
"credentials",
|
|
881
|
+
"metadata.json",
|
|
882
|
+
);
|
|
883
|
+
try {
|
|
884
|
+
await mergeCredentialMetadataIntoTemp(
|
|
885
|
+
liveMetadataPath,
|
|
886
|
+
tempMetadataPath,
|
|
887
|
+
warnings,
|
|
888
|
+
);
|
|
889
|
+
} catch (err) {
|
|
890
|
+
log.warn(
|
|
891
|
+
{ err, liveMetadataPath, tempMetadataPath },
|
|
892
|
+
"Credential metadata merge failed before swap",
|
|
893
|
+
);
|
|
894
|
+
warnings.push(
|
|
895
|
+
`Credential metadata merge failed: ${errMessage(err)}; vellum:* entries may not survive the import`,
|
|
896
|
+
);
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
// Carry-over: for every path in WORKSPACE_PRESERVE_PATHS, if the bundle
|
|
900
|
+
// did NOT populate it inside the temp workspace but the LIVE workspace
|
|
901
|
+
// has it, move the live copy into the temp workspace at the same
|
|
902
|
+
// relative location. Without this step the atomic swap erases live
|
|
903
|
+
// user data (SQLite DB, Qdrant store, embedding-models cache,
|
|
904
|
+
// deprecated/ quarantine) whenever the bundle omits those paths —
|
|
905
|
+
// e.g. partial bundles carrying only prompts/config.
|
|
906
|
+
//
|
|
907
|
+
// Carry-over uses `rename` (not `cp`) to stay zero-disk on the happy
|
|
908
|
+
// path, which is critical on instances with multi-GB Qdrant stores or
|
|
909
|
+
// SQLite DBs and limited free space.
|
|
910
|
+
//
|
|
911
|
+
// Crash-safety is achieved in two phases:
|
|
912
|
+
// 1. `planCarryOverPreservedPaths` walks the live + temp trees WITHOUT
|
|
913
|
+
// mutating anything and produces the full intended `carried` list.
|
|
914
|
+
// 2. `writeImportMarker` persists that plan to disk BEFORE any rename
|
|
915
|
+
// runs. If the process dies during the subsequent
|
|
916
|
+
// `executeCarryOverPlan`, the marker already holds every
|
|
917
|
+
// (liveChild, tempChild) pair the next `recoverInterruptedImport`
|
|
918
|
+
// needs to replay. The marker is deleted only after the atomic
|
|
919
|
+
// swap pair succeeds (or in-process failure paths explicitly
|
|
920
|
+
// restore state).
|
|
921
|
+
let carried: CarriedPath[];
|
|
922
|
+
try {
|
|
923
|
+
carried = await planCarryOverPreservedPaths(
|
|
924
|
+
realWorkspaceDir,
|
|
925
|
+
tempWorkspaceDir,
|
|
926
|
+
);
|
|
927
|
+
} catch (err) {
|
|
928
|
+
await cleanupTempDir();
|
|
929
|
+
return {
|
|
930
|
+
ok: false,
|
|
931
|
+
reason: "write_failed",
|
|
932
|
+
message: `Failed to plan preserved-path carry-over: ${errMessage(err)}`,
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// Ensure the workspace dir exists so writeImportMarker (which writes
|
|
937
|
+
// at `<realWorkspaceDir>/.import-marker.json`) can land the file on
|
|
938
|
+
// first-ever imports where the workspace has never been created.
|
|
939
|
+
// mkdir is idempotent via { recursive: true }.
|
|
940
|
+
await mkdir(realWorkspaceDir, { recursive: true });
|
|
941
|
+
|
|
942
|
+
const markerPath = importMarkerPathFor(realWorkspaceDir);
|
|
943
|
+
try {
|
|
944
|
+
await writeImportMarker(markerPath, {
|
|
945
|
+
tempWorkspaceDir,
|
|
946
|
+
carried: carried.map((c) => ({
|
|
947
|
+
liveChild: c.liveChild,
|
|
948
|
+
tempChild: c.tempChild,
|
|
949
|
+
})),
|
|
950
|
+
});
|
|
951
|
+
} catch (err) {
|
|
952
|
+
// Persisting the recovery plan is a prerequisite for crash-safe
|
|
953
|
+
// carry-over. If we can't write the marker, refuse to mutate the live
|
|
954
|
+
// workspace — a mid-carryover crash would otherwise be unrecoverable.
|
|
955
|
+
await cleanupTempDir();
|
|
956
|
+
return {
|
|
957
|
+
ok: false,
|
|
958
|
+
reason: "write_failed",
|
|
959
|
+
message: `Failed to persist import recovery marker: ${errMessage(err)}`,
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
try {
|
|
964
|
+
await executeCarryOverPlan(carried);
|
|
965
|
+
} catch (err) {
|
|
966
|
+
// A rename in the plan failed. Restore the already-moved entries so
|
|
967
|
+
// the live workspace is whole again, then delete the marker and temp
|
|
968
|
+
// dir. `restoreCarriedPaths` is a no-op on entries that were never
|
|
969
|
+
// moved (tempChild missing), so passing the full plan is safe.
|
|
970
|
+
await restoreCarriedPaths(carried);
|
|
971
|
+
await safelyDeleteMarker(markerPath);
|
|
972
|
+
await cleanupTempDir();
|
|
973
|
+
return {
|
|
974
|
+
ok: false,
|
|
975
|
+
reason: "write_failed",
|
|
976
|
+
message: `Failed to carry over preserved workspace paths: ${errMessage(err)}`,
|
|
977
|
+
};
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
// Workspace swap: content-level, not directory-level.
|
|
981
|
+
//
|
|
982
|
+
// We do NOT `rename(realWorkspaceDir, backupDir)` because in the
|
|
983
|
+
// production platform deployment `realWorkspaceDir` is a mounted volume
|
|
984
|
+
// (and the daemon's cwd / open subsystems pin it), so the kernel returns
|
|
985
|
+
// EBUSY on the parent-directory rename. Instead we swap the DIRECTORY'S
|
|
986
|
+
// CONTENTS: move every top-level entry from `realWorkspaceDir` into a
|
|
987
|
+
// peer `${realWorkspaceDir}.pre-import-<ts>/` backup dir, then move every
|
|
988
|
+
// top-level entry from the temp tree into the (now empty)
|
|
989
|
+
// `realWorkspaceDir`. `realWorkspaceDir` itself is never renamed, so
|
|
990
|
+
// mount-point / cwd pinning doesn't matter.
|
|
991
|
+
//
|
|
992
|
+
// Update the marker to record the backup dir BEFORE any move runs, so
|
|
993
|
+
// `recoverInterruptedImport` on a future boot can restore from backup
|
|
994
|
+
// even if the process is killed mid-swap.
|
|
995
|
+
// backupDir also lives INSIDE the workspace mount — same rationale as
|
|
996
|
+
// tempWorkspaceDir (keep all moves on the same filesystem, dot-prefix
|
|
997
|
+
// so workspace walkers skip it). Suffix with a UUID (not just a
|
|
998
|
+
// timestamp) so a malicious bundle can't guess the name and ship a
|
|
999
|
+
// top-level entry that collides with our active backup dir during
|
|
1000
|
+
// phase 2 — phase 2 also rejects any such collision defensively.
|
|
1001
|
+
const backupDir = join(
|
|
1002
|
+
realWorkspaceDir,
|
|
1003
|
+
`${IMPORT_BACKUP_PREFIX}${Date.now()}-${randomUUID()}`,
|
|
1004
|
+
);
|
|
1005
|
+
try {
|
|
1006
|
+
await writeImportMarker(markerPath, {
|
|
1007
|
+
tempWorkspaceDir,
|
|
1008
|
+
carried: carried.map((c) => ({
|
|
1009
|
+
liveChild: c.liveChild,
|
|
1010
|
+
tempChild: c.tempChild,
|
|
1011
|
+
})),
|
|
1012
|
+
backupDir,
|
|
1013
|
+
});
|
|
1014
|
+
} catch (err) {
|
|
1015
|
+
await restoreCarriedPaths(carried);
|
|
1016
|
+
await cleanupTempDir();
|
|
1017
|
+
await safelyDeleteMarker(markerPath);
|
|
1018
|
+
return {
|
|
1019
|
+
ok: false,
|
|
1020
|
+
reason: "write_failed",
|
|
1021
|
+
message: `Failed to persist pre-swap recovery marker: ${errMessage(err)}`,
|
|
1022
|
+
};
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
try {
|
|
1026
|
+
await swapWorkspaceContents(realWorkspaceDir, tempWorkspaceDir, backupDir);
|
|
1027
|
+
|
|
1028
|
+
// Swap succeeded. Record that fact in the marker BEFORE deleting it —
|
|
1029
|
+
// otherwise a crash between `swapWorkspaceContents` returning and
|
|
1030
|
+
// `safelyDeleteMarker` completing would leave a marker with
|
|
1031
|
+
// `backupDir` populated, and `recoverInterruptedImport` on the next
|
|
1032
|
+
// boot would silently roll back the successful import by restoring
|
|
1033
|
+
// from backup. With `swapCompleted: true` the recovery path knows to
|
|
1034
|
+
// skip the backup restore and just clean up residual artifacts.
|
|
1035
|
+
try {
|
|
1036
|
+
await writeImportMarker(markerPath, {
|
|
1037
|
+
tempWorkspaceDir,
|
|
1038
|
+
carried: carried.map((c) => ({
|
|
1039
|
+
liveChild: c.liveChild,
|
|
1040
|
+
tempChild: c.tempChild,
|
|
1041
|
+
})),
|
|
1042
|
+
backupDir,
|
|
1043
|
+
swapCompleted: true,
|
|
1044
|
+
});
|
|
1045
|
+
} catch (err) {
|
|
1046
|
+
// Very unlikely (we wrote it a moment ago) and not worth failing
|
|
1047
|
+
// the whole import. A crash here would roll back via recovery, but
|
|
1048
|
+
// the import itself is already applied.
|
|
1049
|
+
log.warn(
|
|
1050
|
+
{ err, markerPath },
|
|
1051
|
+
"Failed to mark import recovery marker as swapCompleted; crash window remains until safelyDeleteMarker",
|
|
1052
|
+
);
|
|
1053
|
+
}
|
|
1054
|
+
await safelyDeleteMarker(markerPath);
|
|
1055
|
+
} catch (err) {
|
|
1056
|
+
// Content-level swap either rolled back its own renames (best effort)
|
|
1057
|
+
// or left the workspace in an ambiguous state. Do a final restore pass
|
|
1058
|
+
// from backupDir into realWorkspaceDir so any entries that didn't
|
|
1059
|
+
// make it back end up whole again — the backup restore runs FIRST so
|
|
1060
|
+
// it doesn't later clobber preserved paths that restoreCarriedPaths
|
|
1061
|
+
// just put back. Pass the carried plan so restoreFromBackupDir can
|
|
1062
|
+
// avoid clobbering descendants (e.g. `data/db` already restored
|
|
1063
|
+
// under `data/`) when it replaces a top-level backup entry.
|
|
1064
|
+
const restoreResult = await restoreFromBackupDir(
|
|
1065
|
+
backupDir,
|
|
1066
|
+
realWorkspaceDir,
|
|
1067
|
+
carried,
|
|
1068
|
+
);
|
|
1069
|
+
await restoreCarriedPaths(carried);
|
|
1070
|
+
if (restoreResult.ok) {
|
|
1071
|
+
await cleanupTempDir();
|
|
1072
|
+
await rm(backupDir, { recursive: true, force: true }).catch(() => {
|
|
1073
|
+
/* best effort */
|
|
1074
|
+
});
|
|
1075
|
+
await safelyDeleteMarker(markerPath);
|
|
1076
|
+
} else {
|
|
1077
|
+
// Partial restore — preserve the backup dir, the temp tree, and the
|
|
1078
|
+
// marker so an operator (or the next boot-time
|
|
1079
|
+
// recoverInterruptedImport) can retry. The marker's `carried` plan
|
|
1080
|
+
// references tempChild paths; deleting the temp tree here would
|
|
1081
|
+
// break that plan. A backup dir with unresolved content is the last
|
|
1082
|
+
// recoverable copy of the pre-import state.
|
|
1083
|
+
log.error(
|
|
1084
|
+
{
|
|
1085
|
+
backupDir,
|
|
1086
|
+
tempWorkspaceDir,
|
|
1087
|
+
markerPath,
|
|
1088
|
+
failedCount: restoreResult.failedCount,
|
|
1089
|
+
},
|
|
1090
|
+
"Pre-import backup restore incomplete; leaving backup dir, temp tree, and marker on disk for manual/boot-time recovery",
|
|
1091
|
+
);
|
|
1092
|
+
}
|
|
1093
|
+
return {
|
|
1094
|
+
ok: false,
|
|
1095
|
+
reason: "write_failed",
|
|
1096
|
+
message: `Failed to swap workspace contents: ${errMessage(err)}`,
|
|
1097
|
+
};
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
// -------------------------------------------------------------------------
|
|
1101
|
+
// Post-commit side effects (non-fatal)
|
|
1102
|
+
//
|
|
1103
|
+
// Past this point the real workspace is already replaced — failures here
|
|
1104
|
+
// do not justify reverting the whole import. Log loudly, surface warnings
|
|
1105
|
+
// in the report, return success.
|
|
1106
|
+
// -------------------------------------------------------------------------
|
|
1107
|
+
|
|
1108
|
+
if (importCredentials && bufferedCredentials.length > 0) {
|
|
1109
|
+
try {
|
|
1110
|
+
await importCredentials(bufferedCredentials);
|
|
1111
|
+
} catch (err) {
|
|
1112
|
+
log.warn(
|
|
1113
|
+
{ err, count: bufferedCredentials.length },
|
|
1114
|
+
"Post-commit credential import failed",
|
|
1115
|
+
);
|
|
1116
|
+
warnings.push(`Credential import failed: ${errMessage(err)}`);
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
try {
|
|
1121
|
+
invalidateConfigCache();
|
|
1122
|
+
} catch (err) {
|
|
1123
|
+
log.warn({ err }, "invalidateConfigCache threw after import");
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
try {
|
|
1127
|
+
clearTrustCache();
|
|
1128
|
+
} catch (err) {
|
|
1129
|
+
log.warn({ err }, "clearTrustCache threw after import");
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
// Attempt to remove the backup dir (best-effort). Leaving it around is not
|
|
1133
|
+
// a correctness issue, only a disk-space one, so we swallow errors. The
|
|
1134
|
+
// backup dir now always exists once swap succeeds — we created it during
|
|
1135
|
+
// swapWorkspaceContents to hold the pre-import live entries.
|
|
1136
|
+
rm(backupDir, { recursive: true, force: true }).catch((err) => {
|
|
1137
|
+
log.warn({ err, backupDir }, "Failed to remove pre-import backup dir");
|
|
1138
|
+
});
|
|
1139
|
+
|
|
1140
|
+
const report = buildReport(manifest, importedFiles, warnings);
|
|
1141
|
+
return { ok: true, report };
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
// ---------------------------------------------------------------------------
|
|
1145
|
+
// Helpers
|
|
1146
|
+
// ---------------------------------------------------------------------------
|
|
1147
|
+
|
|
1148
|
+
function sha256Hex(data: Uint8Array): string {
|
|
1149
|
+
return createHash("sha256").update(data).digest("hex");
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
function generateBackupPath(diskPath: string): string {
|
|
1153
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
1154
|
+
return `${diskPath}.backup-${timestamp}`;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
/**
|
|
1158
|
+
* Promote verified-into-temp files for a legacy-format bundle into the
|
|
1159
|
+
* live workspace in place. Mirrors commitImport's legacy write path:
|
|
1160
|
+
*
|
|
1161
|
+
* - If the live path already exists, copy it to a timestamped
|
|
1162
|
+
* `${livePath}.backup-<ts>` sibling first.
|
|
1163
|
+
* - Ensure the parent directory exists.
|
|
1164
|
+
* - `fs.rename` the temp file over the live path for per-file atomicity.
|
|
1165
|
+
* If that fails with EXDEV (cross-filesystem), fall back to `copyFile`
|
|
1166
|
+
* then `rm` of the temp source.
|
|
1167
|
+
* - Update the corresponding `ImportedFileReport` with the overwrite
|
|
1168
|
+
* action and backup path so the report matches commitImport's output.
|
|
1169
|
+
*/
|
|
1170
|
+
async function promoteLegacyStagedFiles(
|
|
1171
|
+
staged: Array<{
|
|
1172
|
+
tempPath: string;
|
|
1173
|
+
livePath: string;
|
|
1174
|
+
archivePath: string;
|
|
1175
|
+
importedFileIndex: number;
|
|
1176
|
+
}>,
|
|
1177
|
+
importedFiles: ImportedFileReport[],
|
|
1178
|
+
): Promise<void> {
|
|
1179
|
+
for (const entry of staged) {
|
|
1180
|
+
// Backup before overwrite, matching commitImport.
|
|
1181
|
+
let backupPath: string | null = null;
|
|
1182
|
+
if (existsSync(entry.livePath)) {
|
|
1183
|
+
backupPath = generateBackupPath(entry.livePath);
|
|
1184
|
+
await copyFile(entry.livePath, backupPath);
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
await mkdir(dirname(entry.livePath), { recursive: true });
|
|
1188
|
+
|
|
1189
|
+
// If we're replacing a SQLite main database file, remove any sibling
|
|
1190
|
+
// `.db-wal`/`.db-shm`/`.db-journal` from live first. Those
|
|
1191
|
+
// auxiliary files are only valid with the exact `.db` that wrote
|
|
1192
|
+
// them — leaving them alongside the replacement DB causes SQLite to
|
|
1193
|
+
// replay incompatible WAL frames on the first open and report
|
|
1194
|
+
// "database disk image is malformed".
|
|
1195
|
+
if (entry.livePath.endsWith(".db")) {
|
|
1196
|
+
for (const suffix of [".db-wal", ".db-shm", ".db-journal"]) {
|
|
1197
|
+
const auxPath = `${entry.livePath.slice(0, -".db".length)}${suffix}`;
|
|
1198
|
+
await rm(auxPath, { force: true }).catch(() => {
|
|
1199
|
+
/* best effort */
|
|
1200
|
+
});
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
try {
|
|
1205
|
+
await rename(entry.tempPath, entry.livePath);
|
|
1206
|
+
} catch (err) {
|
|
1207
|
+
if (isEXDEV(err)) {
|
|
1208
|
+
await copyFile(entry.tempPath, entry.livePath);
|
|
1209
|
+
await rm(entry.tempPath, { force: true });
|
|
1210
|
+
} else {
|
|
1211
|
+
throw err;
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
const report = importedFiles[entry.importedFileIndex];
|
|
1216
|
+
if (report) {
|
|
1217
|
+
if (backupPath) {
|
|
1218
|
+
report.action = "overwritten";
|
|
1219
|
+
report.backup_path = backupPath;
|
|
1220
|
+
} else {
|
|
1221
|
+
report.action = "created";
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
/**
|
|
1228
|
+
* Rewrite the temp workspace's `data/credentials/metadata.json` so the
|
|
1229
|
+
* target's live `vellum:*` entries survive the swap. Exits silently if
|
|
1230
|
+
* there is nothing to merge.
|
|
1231
|
+
*
|
|
1232
|
+
* Four cases:
|
|
1233
|
+
* - No live metadata, no temp metadata → no-op.
|
|
1234
|
+
* - Live metadata present, temp metadata missing → if the live metadata
|
|
1235
|
+
* contains vellum entries, synthesize a minimal v5 metadata file in
|
|
1236
|
+
* the temp tree containing only those preserved entries. If it has
|
|
1237
|
+
* none, no-op (no entries to preserve).
|
|
1238
|
+
* - Live metadata missing, temp metadata present → no-op (nothing to
|
|
1239
|
+
* preserve; the bundle's copy lands as-is).
|
|
1240
|
+
* - Both present → run the merge helper and rewrite the temp copy.
|
|
1241
|
+
*
|
|
1242
|
+
* Invoked under a try/catch by the caller; thrown errors surface as
|
|
1243
|
+
* warnings but don't abort the import.
|
|
1244
|
+
*/
|
|
1245
|
+
async function mergeCredentialMetadataIntoTemp(
|
|
1246
|
+
liveMetadataPath: string,
|
|
1247
|
+
tempMetadataPath: string,
|
|
1248
|
+
warnings: string[],
|
|
1249
|
+
): Promise<void> {
|
|
1250
|
+
let liveJson: string | null = null;
|
|
1251
|
+
try {
|
|
1252
|
+
liveJson = await readFile(liveMetadataPath, "utf-8");
|
|
1253
|
+
} catch (err) {
|
|
1254
|
+
if (!isENOENT(err)) throw err;
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
let tempJson: string | null = null;
|
|
1258
|
+
try {
|
|
1259
|
+
tempJson = await readFile(tempMetadataPath, "utf-8");
|
|
1260
|
+
} catch (err) {
|
|
1261
|
+
if (!isENOENT(err)) throw err;
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
if (liveJson == null && tempJson == null) return;
|
|
1265
|
+
|
|
1266
|
+
if (tempJson != null) {
|
|
1267
|
+
const merged = mergeMetadataPreservingVellum(tempJson, liveJson);
|
|
1268
|
+
if (merged !== tempJson) {
|
|
1269
|
+
await writeFile(tempMetadataPath, merged, { mode: 0o600 });
|
|
1270
|
+
}
|
|
1271
|
+
return;
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
// Live-only path: synthesize a v5 file with just the preserved vellum
|
|
1275
|
+
// entries so the gateway can still locate them after the swap.
|
|
1276
|
+
const synthesized = mergeMetadataPreservingVellum(
|
|
1277
|
+
JSON.stringify({ version: 5, credentials: [] }),
|
|
1278
|
+
liveJson,
|
|
1279
|
+
);
|
|
1280
|
+
const parsed = JSON.parse(synthesized) as {
|
|
1281
|
+
credentials?: unknown[];
|
|
1282
|
+
};
|
|
1283
|
+
if (!parsed.credentials || parsed.credentials.length === 0) {
|
|
1284
|
+
// Live file exists but had no vellum entries worth preserving.
|
|
1285
|
+
return;
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
try {
|
|
1289
|
+
await mkdir(dirname(tempMetadataPath), { recursive: true });
|
|
1290
|
+
await writeFile(tempMetadataPath, synthesized, { mode: 0o600 });
|
|
1291
|
+
} catch (err) {
|
|
1292
|
+
warnings.push(
|
|
1293
|
+
`Failed to write preserved vellum:* metadata into temp workspace: ${errMessage(err)}`,
|
|
1294
|
+
);
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
function buildReport(
|
|
1299
|
+
manifest: ManifestType,
|
|
1300
|
+
files: ImportedFileReport[],
|
|
1301
|
+
warnings: string[],
|
|
1302
|
+
): ImportCommitReport {
|
|
1303
|
+
return {
|
|
1304
|
+
success: true,
|
|
1305
|
+
summary: {
|
|
1306
|
+
total_files: files.length,
|
|
1307
|
+
files_created: files.filter((f) => f.action === "created").length,
|
|
1308
|
+
files_overwritten: files.filter((f) => f.action === "overwritten").length,
|
|
1309
|
+
files_skipped: files.filter((f) => f.action === "skipped").length,
|
|
1310
|
+
backups_created: files.filter((f) => f.backup_path !== null).length,
|
|
1311
|
+
},
|
|
1312
|
+
files,
|
|
1313
|
+
manifest,
|
|
1314
|
+
warnings,
|
|
1315
|
+
};
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
/**
|
|
1319
|
+
* Copy any WORKSPACE_PRESERVE_PATHS entries from the live workspace into
|
|
1320
|
+
* the temp workspace when the bundle did not already populate them. Runs
|
|
1321
|
+
* immediately before the atomic swap so the swap-in tree has the union
|
|
1322
|
+
* of bundle-provided files and live-preserved files.
|
|
1323
|
+
*
|
|
1324
|
+
* Per-file merge semantics (critical): a bundle that touches a SINGLE file
|
|
1325
|
+
* under a preserved directory (e.g. writes `workspace/data/qdrant/config.json`)
|
|
1326
|
+
* must NOT cause the rest of that directory to be wiped. We therefore walk
|
|
1327
|
+
* each preserved path recursively and carry over any live file or
|
|
1328
|
+
* subdirectory the bundle did not itself write. A whole-directory short-
|
|
1329
|
+
* circuit would mis-handle that case by erasing unrelated qdrant segments,
|
|
1330
|
+
* DB WALs, embedding-model shards, etc.
|
|
1331
|
+
*
|
|
1332
|
+
* For each preserved relative path:
|
|
1333
|
+
* - If the preserved path is a FILE in the live workspace and the temp
|
|
1334
|
+
* tree already has that exact path, the bundle populated it — leave
|
|
1335
|
+
* it alone. Otherwise rename/copy the live file over.
|
|
1336
|
+
* - If the preserved path is a DIRECTORY in the live workspace, walk
|
|
1337
|
+
* it recursively. For each entry:
|
|
1338
|
+
* * If the temp tree has a matching entry at the same relative
|
|
1339
|
+
* path, the bundle wrote it — skip.
|
|
1340
|
+
* * If not, carry the live entry over (rename with EXDEV fallback
|
|
1341
|
+
* to recursive copy).
|
|
1342
|
+
* The walk stops descending on any subtree the bundle has completely
|
|
1343
|
+
* populated, since we only need to fill gaps.
|
|
1344
|
+
*/
|
|
1345
|
+
/**
|
|
1346
|
+
* Pre-compute the full `CarriedPath[]` that `carryOverPreservedPaths` will
|
|
1347
|
+
* move, WITHOUT mutating the live workspace. The result lets us write the
|
|
1348
|
+
* crash-recovery marker before any rename runs, so a crash mid-carry-over
|
|
1349
|
+
* still leaves a complete restoration plan for the next
|
|
1350
|
+
* `recoverInterruptedImport` call.
|
|
1351
|
+
*
|
|
1352
|
+
* The walk mirrors `carryOverPreservedPaths` exactly — if the two were to
|
|
1353
|
+
* disagree, recovery would be incomplete. Directory subtrees that the
|
|
1354
|
+
* bundle didn't populate are recorded as a single top-level move (matches
|
|
1355
|
+
* the one-shot rename the executor does); per-file merges happen otherwise.
|
|
1356
|
+
*/
|
|
1357
|
+
async function planCarryOverPreservedPaths(
|
|
1358
|
+
realWorkspaceDir: string,
|
|
1359
|
+
tempWorkspaceDir: string,
|
|
1360
|
+
): Promise<CarriedPath[]> {
|
|
1361
|
+
const plan: CarriedPath[] = [];
|
|
1362
|
+
for (const rel of WORKSPACE_PRESERVE_PATHS) {
|
|
1363
|
+
const livePath = join(realWorkspaceDir, rel);
|
|
1364
|
+
const tempPath = join(tempWorkspaceDir, rel);
|
|
1365
|
+
|
|
1366
|
+
let liveStat;
|
|
1367
|
+
try {
|
|
1368
|
+
liveStat = await stat(livePath);
|
|
1369
|
+
} catch (err) {
|
|
1370
|
+
if (isENOENT(err)) continue;
|
|
1371
|
+
throw err;
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
if (!liveStat.isDirectory()) {
|
|
1375
|
+
if (existsSync(tempPath)) continue;
|
|
1376
|
+
plan.push({ liveChild: livePath, tempChild: tempPath });
|
|
1377
|
+
continue;
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
await planMergeLiveIntoTempDir(livePath, tempPath, plan);
|
|
1381
|
+
}
|
|
1382
|
+
return plan;
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
/**
|
|
1386
|
+
* Same walk as `mergeLiveIntoTempDir` but only records the would-be moves
|
|
1387
|
+
* in `plan`. Intentionally side-effect-free apart from appending to the
|
|
1388
|
+
* plan array.
|
|
1389
|
+
*/
|
|
1390
|
+
async function planMergeLiveIntoTempDir(
|
|
1391
|
+
liveDir: string,
|
|
1392
|
+
tempDir: string,
|
|
1393
|
+
plan: CarriedPath[],
|
|
1394
|
+
): Promise<void> {
|
|
1395
|
+
let entries;
|
|
1396
|
+
try {
|
|
1397
|
+
entries = await readdir(liveDir, { withFileTypes: true });
|
|
1398
|
+
} catch (err) {
|
|
1399
|
+
if (isENOENT(err)) return;
|
|
1400
|
+
throw err;
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
for (const entry of entries) {
|
|
1404
|
+
const liveChild = join(liveDir, entry.name);
|
|
1405
|
+
const tempChild = join(tempDir, entry.name);
|
|
1406
|
+
const existsInTemp = existsSync(tempChild);
|
|
1407
|
+
|
|
1408
|
+
if (entry.isDirectory()) {
|
|
1409
|
+
if (!existsInTemp) {
|
|
1410
|
+
plan.push({ liveChild, tempChild });
|
|
1411
|
+
continue;
|
|
1412
|
+
}
|
|
1413
|
+
await planMergeLiveIntoTempDir(liveChild, tempChild, plan);
|
|
1414
|
+
continue;
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
if (existsInTemp) continue;
|
|
1418
|
+
|
|
1419
|
+
// SQLite auxiliary files (WAL / SHM / journal) are only valid as a
|
|
1420
|
+
// pair with the exact `.db` they were written by. If the bundle
|
|
1421
|
+
// replaced the sibling `.db` in this dir, carrying the live `.db-wal`
|
|
1422
|
+
// forward pairs stale WAL frames with a different DB and SQLite
|
|
1423
|
+
// reports "database disk image is malformed" on first open. Drop
|
|
1424
|
+
// them — SQLite recreates a fresh WAL lazily on next connection,
|
|
1425
|
+
// and the export already checkpointed the source WAL into the main
|
|
1426
|
+
// DB before the bundle was built.
|
|
1427
|
+
//
|
|
1428
|
+
// When the bundle does NOT carry a replacement DB (bundle is
|
|
1429
|
+
// config-only etc.), the live `.db` is preserved and the live WAL
|
|
1430
|
+
// stays paired with it.
|
|
1431
|
+
if (
|
|
1432
|
+
isSqliteAuxiliaryFile(entry.name) &&
|
|
1433
|
+
hasSiblingDbInTemp(tempDir, entry.name)
|
|
1434
|
+
) {
|
|
1435
|
+
continue;
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
plan.push({ liveChild, tempChild });
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
/**
|
|
1443
|
+
* SQLite writes `<name>.db-wal`, `<name>.db-shm`, `<name>.db-journal`
|
|
1444
|
+
* alongside its main `<name>.db` file. These are only consistent with
|
|
1445
|
+
* the exact `.db` they were created for.
|
|
1446
|
+
*/
|
|
1447
|
+
function isSqliteAuxiliaryFile(name: string): boolean {
|
|
1448
|
+
return (
|
|
1449
|
+
name.endsWith(".db-wal") ||
|
|
1450
|
+
name.endsWith(".db-shm") ||
|
|
1451
|
+
name.endsWith(".db-journal")
|
|
1452
|
+
);
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
/**
|
|
1456
|
+
* Does the temp dir contain the main `.db` file that owns this auxiliary
|
|
1457
|
+
* file? Given e.g. `assistant.db-wal`, checks for `tempDir/assistant.db`.
|
|
1458
|
+
*/
|
|
1459
|
+
function hasSiblingDbInTemp(tempDir: string, auxName: string): boolean {
|
|
1460
|
+
const dbName = auxName
|
|
1461
|
+
.replace(/\.db-wal$/, ".db")
|
|
1462
|
+
.replace(/\.db-shm$/, ".db")
|
|
1463
|
+
.replace(/\.db-journal$/, ".db");
|
|
1464
|
+
return existsSync(join(tempDir, dbName));
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
/**
|
|
1468
|
+
* Execute a carry-over plan produced by `planCarryOverPreservedPaths`.
|
|
1469
|
+
* Each entry is moved with `carryOverEntry`; directories that are plan
|
|
1470
|
+
* roots have their parent created so `rename` can land them.
|
|
1471
|
+
*
|
|
1472
|
+
* Per-entry failures abort the loop and throw — the caller is expected to
|
|
1473
|
+
* run `restoreCarriedPaths` on the already-moved entries (a subset of the
|
|
1474
|
+
* plan) on its in-process failure path.
|
|
1475
|
+
*/
|
|
1476
|
+
async function executeCarryOverPlan(plan: CarriedPath[]): Promise<void> {
|
|
1477
|
+
for (const { liveChild, tempChild } of plan) {
|
|
1478
|
+
await mkdir(dirname(tempChild), { recursive: true });
|
|
1479
|
+
await carryOverEntry(liveChild, tempChild);
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
/**
|
|
1484
|
+
* Move a single live workspace entry (file or directory) into the temp
|
|
1485
|
+
* workspace. Uses `rename` for the fast path (same-filesystem, zero copy)
|
|
1486
|
+
* so we don't duplicate potentially multi-GB preserved trees like
|
|
1487
|
+
* `data/qdrant` or `data/db`. Falls back to `cp` + `rm` on EXDEV (different
|
|
1488
|
+
* filesystems) — rare in practice since live and temp share a parent dir.
|
|
1489
|
+
*
|
|
1490
|
+
* Live data is moved, not copied, so the atomic swap must restore it on
|
|
1491
|
+
* failure. `streamCommitImport` tracks every carry-over via `CarriedPath`
|
|
1492
|
+
* and calls `restoreCarriedPaths` on any swap-pair error so the live
|
|
1493
|
+
* workspace ends up whole even if the import aborts.
|
|
1494
|
+
*/
|
|
1495
|
+
async function carryOverEntry(
|
|
1496
|
+
liveChild: string,
|
|
1497
|
+
tempChild: string,
|
|
1498
|
+
): Promise<void> {
|
|
1499
|
+
try {
|
|
1500
|
+
await rename(liveChild, tempChild);
|
|
1501
|
+
} catch (err) {
|
|
1502
|
+
if (isEXDEV(err)) {
|
|
1503
|
+
await copyTreeSkippingTransient(liveChild, tempChild);
|
|
1504
|
+
await rm(liveChild, { recursive: true, force: true });
|
|
1505
|
+
} else {
|
|
1506
|
+
throw err;
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
/**
|
|
1512
|
+
* Every preserved entry that was moved out of the live workspace during
|
|
1513
|
+
* carry-over. Used to undo the move if the atomic swap fails, so we never
|
|
1514
|
+
* leave the daemon with SQLite/Qdrant/embedding-model data stranded in a
|
|
1515
|
+
* temp tree that's about to be deleted.
|
|
1516
|
+
*/
|
|
1517
|
+
interface CarriedPath {
|
|
1518
|
+
/** Original location inside the live workspace (real path before swap). */
|
|
1519
|
+
liveChild: string;
|
|
1520
|
+
/** Landing location inside the temp workspace. */
|
|
1521
|
+
tempChild: string;
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
/**
|
|
1525
|
+
* Undo a set of carry-over moves by renaming each carried path back to its
|
|
1526
|
+
* original live location. Best-effort: logs and continues on per-entry
|
|
1527
|
+
* failures rather than throwing, since the caller is already handling a
|
|
1528
|
+
* swap-pair failure and needs to restore as much state as possible.
|
|
1529
|
+
*/
|
|
1530
|
+
async function restoreCarriedPaths(
|
|
1531
|
+
carried: readonly CarriedPath[],
|
|
1532
|
+
): Promise<void> {
|
|
1533
|
+
for (const { liveChild, tempChild } of carried) {
|
|
1534
|
+
try {
|
|
1535
|
+
await mkdir(dirname(liveChild), { recursive: true });
|
|
1536
|
+
await rename(tempChild, liveChild);
|
|
1537
|
+
} catch (err) {
|
|
1538
|
+
if (isEXDEV(err)) {
|
|
1539
|
+
try {
|
|
1540
|
+
await copyTreeSkippingTransient(tempChild, liveChild);
|
|
1541
|
+
await rm(tempChild, { recursive: true, force: true });
|
|
1542
|
+
continue;
|
|
1543
|
+
} catch (cpErr) {
|
|
1544
|
+
log.error(
|
|
1545
|
+
{ err: cpErr, liveChild, tempChild },
|
|
1546
|
+
"Failed to restore carried preserved path via cp fallback; manual recovery may be required",
|
|
1547
|
+
);
|
|
1548
|
+
continue;
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
if (isENOENT(err)) {
|
|
1552
|
+
// The entry may have already moved (rename-pair partially succeeded)
|
|
1553
|
+
// or never existed. Nothing to restore.
|
|
1554
|
+
continue;
|
|
1555
|
+
}
|
|
1556
|
+
log.error(
|
|
1557
|
+
{ err, liveChild, tempChild },
|
|
1558
|
+
"Failed to restore carried preserved path; manual recovery may be required",
|
|
1559
|
+
);
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
/**
|
|
1565
|
+
* Swap the CONTENTS of the workspace without ever renaming `realWorkspaceDir`
|
|
1566
|
+
* itself. The production platform pod has `realWorkspaceDir` as a mounted
|
|
1567
|
+
* volume (and the daemon's subsystems pin file handles inside it), so the
|
|
1568
|
+
* kernel returns `EBUSY` if we `rename()` the directory. Moving individual
|
|
1569
|
+
* top-level entries sidesteps that: a mount point's children usually aren't
|
|
1570
|
+
* themselves mount points, and individual-file EBUSY is much rarer than
|
|
1571
|
+
* directory-rename EBUSY.
|
|
1572
|
+
*
|
|
1573
|
+
* Semantics:
|
|
1574
|
+
*
|
|
1575
|
+
* 1. Create `backupDir` (peer of `realWorkspaceDir`, different parent entry).
|
|
1576
|
+
* 2. For each top-level entry currently in `realWorkspaceDir`, `rename()`
|
|
1577
|
+
* it into `backupDir`.
|
|
1578
|
+
* 3. For each top-level entry in `tempWorkspaceDir`, `rename()` it into
|
|
1579
|
+
* `realWorkspaceDir`.
|
|
1580
|
+
* 4. Remove the (now empty) temp dir.
|
|
1581
|
+
*
|
|
1582
|
+
* On a per-entry rename failure during phase 2, move what was already moved
|
|
1583
|
+
* back into `realWorkspaceDir` and throw. On a failure during phase 3, move
|
|
1584
|
+
* the already-moved temp entries back to `tempWorkspaceDir`, then move
|
|
1585
|
+
* backup entries back into `realWorkspaceDir`, and throw.
|
|
1586
|
+
*
|
|
1587
|
+
* This function is NOT atomic — a reader that opens `realWorkspaceDir`
|
|
1588
|
+
* mid-swap will see a half-emptied state. The daemon's SQLite connection is
|
|
1589
|
+
* already closed (`resetDb()` ran before this), and the async import is
|
|
1590
|
+
* running in a background job from the external caller's perspective, so
|
|
1591
|
+
* transient readers aren't expected. `recoverInterruptedImport` uses the
|
|
1592
|
+
* `backupDir` recorded in the marker to finish the rollback if a crash hits
|
|
1593
|
+
* mid-swap.
|
|
1594
|
+
*/
|
|
1595
|
+
async function swapWorkspaceContents(
|
|
1596
|
+
realWorkspaceDir: string,
|
|
1597
|
+
tempWorkspaceDir: string,
|
|
1598
|
+
backupDir: string,
|
|
1599
|
+
): Promise<void> {
|
|
1600
|
+
await mkdir(backupDir, { recursive: true });
|
|
1601
|
+
|
|
1602
|
+
// Phase 1: move every top-level entry out of real into backup. Skip
|
|
1603
|
+
// ONLY the exact scratch dirs this import owns (backupDir itself, and
|
|
1604
|
+
// the tempWorkspaceDir passed in) — NOT everything that happens to
|
|
1605
|
+
// start with the `.import-`/`.pre-import-` prefix. A user workspace
|
|
1606
|
+
// that legitimately contains an entry with one of those prefixes
|
|
1607
|
+
// would otherwise leak state across imports, and a bundle carrying
|
|
1608
|
+
// the same name would collide on phase-2 rename-in.
|
|
1609
|
+
//
|
|
1610
|
+
// The recovery marker (`.import-marker.json`) is also reserved — it
|
|
1611
|
+
// lives inside the workspace, must stay put across the swap so
|
|
1612
|
+
// recovery can read it if the process dies mid-swap, and must not be
|
|
1613
|
+
// overwritten by a bundle entry of the same name.
|
|
1614
|
+
const scratchBasenames = new Set<string>([
|
|
1615
|
+
basename(backupDir),
|
|
1616
|
+
basename(tempWorkspaceDir),
|
|
1617
|
+
IMPORT_MARKER_BASENAME,
|
|
1618
|
+
]);
|
|
1619
|
+
let liveEntries: string[];
|
|
1620
|
+
try {
|
|
1621
|
+
liveEntries = (await readdir(realWorkspaceDir)).filter(
|
|
1622
|
+
(name) => !scratchBasenames.has(name),
|
|
1623
|
+
);
|
|
1624
|
+
} catch (err) {
|
|
1625
|
+
if (isENOENT(err)) {
|
|
1626
|
+
liveEntries = [];
|
|
1627
|
+
} else {
|
|
1628
|
+
throw err;
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
const movedToBackup: string[] = [];
|
|
1633
|
+
try {
|
|
1634
|
+
for (const name of liveEntries) {
|
|
1635
|
+
await moveEntryWithExdevFallback(
|
|
1636
|
+
join(realWorkspaceDir, name),
|
|
1637
|
+
join(backupDir, name),
|
|
1638
|
+
);
|
|
1639
|
+
movedToBackup.push(name);
|
|
1640
|
+
}
|
|
1641
|
+
} catch (err) {
|
|
1642
|
+
// Partial move-out. Reverse what we moved so realWorkspaceDir ends up
|
|
1643
|
+
// back to its original content before we throw.
|
|
1644
|
+
for (const name of movedToBackup.reverse()) {
|
|
1645
|
+
try {
|
|
1646
|
+
await moveEntryWithExdevFallback(
|
|
1647
|
+
join(backupDir, name),
|
|
1648
|
+
join(realWorkspaceDir, name),
|
|
1649
|
+
);
|
|
1650
|
+
} catch (restoreErr) {
|
|
1651
|
+
log.error(
|
|
1652
|
+
{ err: restoreErr, name, realWorkspaceDir, backupDir },
|
|
1653
|
+
"Failed to restore entry from backup during swap-out rollback",
|
|
1654
|
+
);
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
throw err;
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
// Phase 2: move every top-level entry from temp into real. `rename`
|
|
1661
|
+
// requires the destination's parent to exist, so ensure realWorkspaceDir
|
|
1662
|
+
// exists even if phase 1 found no entries (first-ever import into a
|
|
1663
|
+
// fresh workspace dir that hasn't been created yet).
|
|
1664
|
+
await mkdir(realWorkspaceDir, { recursive: true });
|
|
1665
|
+
|
|
1666
|
+
let tempEntries: string[];
|
|
1667
|
+
try {
|
|
1668
|
+
tempEntries = await readdir(tempWorkspaceDir);
|
|
1669
|
+
} catch (err) {
|
|
1670
|
+
// A missing temp tree here is a hard failure — phase 1 has already
|
|
1671
|
+
// emptied realWorkspaceDir into backup, so treating temp as an empty
|
|
1672
|
+
// import would commit an empty workspace and the backup would be
|
|
1673
|
+
// deleted in the success path. That's silent data loss. Throw so the
|
|
1674
|
+
// caller's rollback restores backup → real.
|
|
1675
|
+
if (isENOENT(err)) {
|
|
1676
|
+
throw new Error(
|
|
1677
|
+
`Temp workspace dir disappeared before swap-in (${tempWorkspaceDir})`,
|
|
1678
|
+
);
|
|
1679
|
+
}
|
|
1680
|
+
throw err;
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
// Defend against a bundle whose top-level entries collide with this
|
|
1684
|
+
// swap's scratch basenames. The UUID suffix on `backupDir` makes an
|
|
1685
|
+
// accidental collision astronomically unlikely, but a malicious or
|
|
1686
|
+
// corrupted bundle carrying e.g. `.pre-import-<exact-match>` could
|
|
1687
|
+
// otherwise replace the (empty) active backup dir via rename on an
|
|
1688
|
+
// empty live workspace, and the success-path `rm(backupDir)` would
|
|
1689
|
+
// then silently delete the imported content. Fail fast before any
|
|
1690
|
+
// rename so real ends up rolled back to pre-import state.
|
|
1691
|
+
const collidingName = tempEntries.find((name) => scratchBasenames.has(name));
|
|
1692
|
+
if (collidingName !== undefined) {
|
|
1693
|
+
throw new Error(
|
|
1694
|
+
`Bundle top-level entry "${collidingName}" collides with an import scratch dir basename — refusing to swap to avoid accidental deletion of imported content`,
|
|
1695
|
+
);
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
const movedToReal: string[] = [];
|
|
1699
|
+
try {
|
|
1700
|
+
for (const name of tempEntries) {
|
|
1701
|
+
await moveEntryWithExdevFallback(
|
|
1702
|
+
join(tempWorkspaceDir, name),
|
|
1703
|
+
join(realWorkspaceDir, name),
|
|
1704
|
+
);
|
|
1705
|
+
movedToReal.push(name);
|
|
1706
|
+
}
|
|
1707
|
+
} catch (err) {
|
|
1708
|
+
// Partial move-in. Reverse the partial fill-in first (real → temp),
|
|
1709
|
+
// then restore from backup (backup → real), so real ends up back at
|
|
1710
|
+
// its pre-swap state.
|
|
1711
|
+
for (const name of movedToReal.reverse()) {
|
|
1712
|
+
try {
|
|
1713
|
+
await moveEntryWithExdevFallback(
|
|
1714
|
+
join(realWorkspaceDir, name),
|
|
1715
|
+
join(tempWorkspaceDir, name),
|
|
1716
|
+
);
|
|
1717
|
+
} catch (restoreErr) {
|
|
1718
|
+
log.error(
|
|
1719
|
+
{ err: restoreErr, name, realWorkspaceDir, tempWorkspaceDir },
|
|
1720
|
+
"Failed to undo partial swap-in during rollback",
|
|
1721
|
+
);
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
for (const name of movedToBackup.reverse()) {
|
|
1725
|
+
try {
|
|
1726
|
+
await moveEntryWithExdevFallback(
|
|
1727
|
+
join(backupDir, name),
|
|
1728
|
+
join(realWorkspaceDir, name),
|
|
1729
|
+
);
|
|
1730
|
+
} catch (restoreErr) {
|
|
1731
|
+
log.error(
|
|
1732
|
+
{ err: restoreErr, name, realWorkspaceDir, backupDir },
|
|
1733
|
+
"Failed to restore entry from backup during swap-in rollback",
|
|
1734
|
+
);
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
throw err;
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
// Phase 3: remove the now-empty temp dir. If it still has stragglers
|
|
1741
|
+
// (pax headers, etc. we didn't move) take them down too.
|
|
1742
|
+
await rm(tempWorkspaceDir, { recursive: true, force: true }).catch(() => {
|
|
1743
|
+
/* best effort — caller will log if it matters */
|
|
1744
|
+
});
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
/**
|
|
1748
|
+
* Move a single filesystem entry from `src` to `dst`, falling back to
|
|
1749
|
+
* `cp` + `rm` when `rename` returns EXDEV (cross-filesystem move).
|
|
1750
|
+
*
|
|
1751
|
+
* In the production container, `realWorkspaceDir` is typically a mounted
|
|
1752
|
+
* volume on a separate filesystem from the backup / temp dirs that live on
|
|
1753
|
+
* the overlay root — so every move in `swapWorkspaceContents` crosses a
|
|
1754
|
+
* filesystem boundary and would fail with EXDEV without this fallback.
|
|
1755
|
+
* Every other move helper in this file (`carryOverEntry`,
|
|
1756
|
+
* `restoreCarriedPaths`, `restoreFromBackupDir`, `mergeBackupIntoLive`)
|
|
1757
|
+
* already handles EXDEV the same way; this helper centralises that
|
|
1758
|
+
* behaviour for the swap path.
|
|
1759
|
+
*/
|
|
1760
|
+
async function moveEntryWithExdevFallback(
|
|
1761
|
+
src: string,
|
|
1762
|
+
dst: string,
|
|
1763
|
+
): Promise<void> {
|
|
1764
|
+
try {
|
|
1765
|
+
await rename(src, dst);
|
|
1766
|
+
} catch (err) {
|
|
1767
|
+
if (isEXDEV(err)) {
|
|
1768
|
+
try {
|
|
1769
|
+
await copyTreeSkippingTransient(src, dst);
|
|
1770
|
+
} catch (cpErr) {
|
|
1771
|
+
// Partial cp could leave incomplete content at `dst`. Remove it so
|
|
1772
|
+
// `restoreFromBackupDir` (running on a later error path) doesn't
|
|
1773
|
+
// mistake half-a-tree for a valid backup entry and clobber the
|
|
1774
|
+
// still-intact source with it. Leave `src` alone — we never got
|
|
1775
|
+
// to the rm step, so it's whole.
|
|
1776
|
+
await rm(dst, { recursive: true, force: true }).catch((rmErr) => {
|
|
1777
|
+
log.warn(
|
|
1778
|
+
{ err: rmErr, dst },
|
|
1779
|
+
"Failed to clean up partial cp destination after EXDEV fallback failure",
|
|
1780
|
+
);
|
|
1781
|
+
});
|
|
1782
|
+
throw cpErr;
|
|
1783
|
+
}
|
|
1784
|
+
await rm(src, { recursive: true, force: true });
|
|
1785
|
+
return;
|
|
1786
|
+
}
|
|
1787
|
+
throw err;
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
/**
|
|
1792
|
+
* `fs.cp(..., { recursive: true })` throws `ERR_FS_CP_SOCKET` (and
|
|
1793
|
+
* similar for FIFOs / other special files) in newer Node versions, which
|
|
1794
|
+
* breaks imports in real deployments — most concretely, the meet-join
|
|
1795
|
+
* skill creates unix sockets under `meets/<id>/sockets/` that end up
|
|
1796
|
+
* inside the workspace. Special files are session-scoped, always safe to
|
|
1797
|
+
* drop across an import. This wrapper asks `fs.cp` to skip anything that
|
|
1798
|
+
* isn't a regular file / directory / symlink, and falls back to a manual
|
|
1799
|
+
* walk if `fs.cp` still trips over something we couldn't filter ahead of
|
|
1800
|
+
* time.
|
|
1801
|
+
*/
|
|
1802
|
+
async function copyTreeSkippingTransient(
|
|
1803
|
+
src: string,
|
|
1804
|
+
dst: string,
|
|
1805
|
+
): Promise<void> {
|
|
1806
|
+
try {
|
|
1807
|
+
await cp(src, dst, {
|
|
1808
|
+
recursive: true,
|
|
1809
|
+
preserveTimestamps: true,
|
|
1810
|
+
filter: async (source) => {
|
|
1811
|
+
try {
|
|
1812
|
+
const info = await lstat(source);
|
|
1813
|
+
// Keep regular files, directories, and symlinks. Skip sockets,
|
|
1814
|
+
// FIFOs, block/char devices — transient / non-portable content
|
|
1815
|
+
// that `fs.cp` refuses to replicate anyway.
|
|
1816
|
+
return info.isFile() || info.isDirectory() || info.isSymbolicLink();
|
|
1817
|
+
} catch {
|
|
1818
|
+
// If we can't stat, let `fs.cp` try and surface the real error.
|
|
1819
|
+
return true;
|
|
1820
|
+
}
|
|
1821
|
+
},
|
|
1822
|
+
});
|
|
1823
|
+
} catch (err) {
|
|
1824
|
+
if (!isCpUnsupportedFileType(err)) throw err;
|
|
1825
|
+
// Fall back to a manual walk that skips anything that isn't a file,
|
|
1826
|
+
// dir, or symlink. `fs.cp` on Node can still occasionally surface
|
|
1827
|
+
// ERR_FS_CP_SOCKET despite the filter (races where the socket
|
|
1828
|
+
// appears between filter call and read), so the manual walk is the
|
|
1829
|
+
// last-resort path.
|
|
1830
|
+
log.warn(
|
|
1831
|
+
{ err, src, dst },
|
|
1832
|
+
"cp filter still surfaced unsupported file type; falling back to manual walk",
|
|
1833
|
+
);
|
|
1834
|
+
await manualCopyTreeSkippingTransient(src, dst);
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
function isCpUnsupportedFileType(err: unknown): boolean {
|
|
1839
|
+
if (!err || typeof err !== "object") return false;
|
|
1840
|
+
const code = (err as { code?: string }).code;
|
|
1841
|
+
return (
|
|
1842
|
+
code === "ERR_FS_CP_SOCKET" ||
|
|
1843
|
+
code === "ERR_FS_CP_FIFO_PIPE" ||
|
|
1844
|
+
code === "ERR_FS_CP_UNKNOWN"
|
|
1845
|
+
);
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
async function manualCopyTreeSkippingTransient(
|
|
1849
|
+
src: string,
|
|
1850
|
+
dst: string,
|
|
1851
|
+
): Promise<void> {
|
|
1852
|
+
const info = await lstat(src);
|
|
1853
|
+
if (info.isSymbolicLink()) {
|
|
1854
|
+
const target = await readlink(src);
|
|
1855
|
+
await mkdir(dirname(dst), { recursive: true });
|
|
1856
|
+
// `symlink` throws EEXIST if `dst` already exists. We may be running
|
|
1857
|
+
// as a fallback after `fs.cp` partially populated `dst` (including
|
|
1858
|
+
// creating this symlink itself), so clear it first — unlike
|
|
1859
|
+
// `copyFile` / recursive `mkdir`, `symlink` has no replace-mode.
|
|
1860
|
+
await rm(dst, { force: true }).catch(() => {
|
|
1861
|
+
/* best effort — a subsequent symlink error will surface any real issue */
|
|
1862
|
+
});
|
|
1863
|
+
await symlink(target, dst);
|
|
1864
|
+
return;
|
|
1865
|
+
}
|
|
1866
|
+
if (info.isFile()) {
|
|
1867
|
+
await mkdir(dirname(dst), { recursive: true });
|
|
1868
|
+
await copyFile(src, dst);
|
|
1869
|
+
return;
|
|
1870
|
+
}
|
|
1871
|
+
if (info.isDirectory()) {
|
|
1872
|
+
await mkdir(dst, { recursive: true });
|
|
1873
|
+
for (const name of await readdir(src)) {
|
|
1874
|
+
await manualCopyTreeSkippingTransient(join(src, name), join(dst, name));
|
|
1875
|
+
}
|
|
1876
|
+
return;
|
|
1877
|
+
}
|
|
1878
|
+
// Anything else (socket, FIFO, device) — intentionally skip.
|
|
1879
|
+
log.debug(
|
|
1880
|
+
{ src },
|
|
1881
|
+
"Skipping transient/special filesystem entry during cross-fs copy",
|
|
1882
|
+
);
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
interface RestoreFromBackupResult {
|
|
1886
|
+
ok: boolean;
|
|
1887
|
+
/** Entries that could not be restored; backup must be preserved if non-zero. */
|
|
1888
|
+
failedCount: number;
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
/**
|
|
1892
|
+
* Move every top-level entry from `backupDir` back into `realWorkspaceDir`,
|
|
1893
|
+
* overwriting partial swap-in leftovers from a crashed import.
|
|
1894
|
+
*
|
|
1895
|
+
* `carried` is the carry-over plan. Any entry in `carried` whose
|
|
1896
|
+
* `liveChild` is a descendant of a backup entry protects that subtree from
|
|
1897
|
+
* being rm'd — if the backup captured only part of a directory (because
|
|
1898
|
+
* carry-over already moved `data/db` out before the swap started), we must
|
|
1899
|
+
* not clobber a `data/db` that recovery already restored into
|
|
1900
|
+
* `realWorkspaceDir/data/`. In that case we merge the backup's `data/`
|
|
1901
|
+
* into `realWorkspaceDir/data/` per-entry instead of replacing it.
|
|
1902
|
+
*
|
|
1903
|
+
* Used by the in-process rollback path (failed `swapWorkspaceContents`)
|
|
1904
|
+
* and by `recoverInterruptedImport` at boot.
|
|
1905
|
+
*
|
|
1906
|
+
* Best-effort per-entry: logs failures and continues rather than
|
|
1907
|
+
* throwing, and returns a status with `failedCount` so callers can decide
|
|
1908
|
+
* whether to preserve the backup dir for manual recovery. A missing
|
|
1909
|
+
* backup dir is a clean no-op (`{ ok: true, failedCount: 0 }`).
|
|
1910
|
+
*/
|
|
1911
|
+
async function restoreFromBackupDir(
|
|
1912
|
+
backupDir: string,
|
|
1913
|
+
realWorkspaceDir: string,
|
|
1914
|
+
carried: readonly CarriedPath[],
|
|
1915
|
+
): Promise<RestoreFromBackupResult> {
|
|
1916
|
+
let backupEntries: string[];
|
|
1917
|
+
try {
|
|
1918
|
+
backupEntries = await readdir(backupDir);
|
|
1919
|
+
} catch (err) {
|
|
1920
|
+
if (isENOENT(err)) return { ok: true, failedCount: 0 };
|
|
1921
|
+
log.error(
|
|
1922
|
+
{ err, backupDir },
|
|
1923
|
+
"Failed to read backup dir during restore; skipping backup restoration",
|
|
1924
|
+
);
|
|
1925
|
+
return { ok: false, failedCount: 1 };
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
const carriedLivePaths = carried.map((c) => resolve(c.liveChild));
|
|
1929
|
+
|
|
1930
|
+
let failedCount = 0;
|
|
1931
|
+
|
|
1932
|
+
for (const name of backupEntries) {
|
|
1933
|
+
const src = join(backupDir, name);
|
|
1934
|
+
const dst = join(realWorkspaceDir, name);
|
|
1935
|
+
const dstAbs = resolve(dst);
|
|
1936
|
+
|
|
1937
|
+
// If any carried path lives strictly inside `dst` (e.g., dst is
|
|
1938
|
+
// `real/data/` and a carried path is `real/data/db`), we can't
|
|
1939
|
+
// wholesale `rm(dst) + rename(src)` — that would destroy the carried
|
|
1940
|
+
// content that recovery has already put back. Merge instead.
|
|
1941
|
+
const hasProtectedDescendant = carriedLivePaths.some((carriedAbs) => {
|
|
1942
|
+
if (carriedAbs === dstAbs) return false;
|
|
1943
|
+
return carriedAbs.startsWith(dstAbs + sep);
|
|
1944
|
+
});
|
|
1945
|
+
|
|
1946
|
+
if (hasProtectedDescendant) {
|
|
1947
|
+
try {
|
|
1948
|
+
await mergeBackupIntoLive(src, dst, carriedLivePaths);
|
|
1949
|
+
} catch (err) {
|
|
1950
|
+
failedCount += 1;
|
|
1951
|
+
log.error(
|
|
1952
|
+
{ err, src, dst },
|
|
1953
|
+
"Failed to merge backup subtree into live workspace during restore",
|
|
1954
|
+
);
|
|
1955
|
+
}
|
|
1956
|
+
continue;
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
// No carried descendants — safe to replace wholesale. If real already
|
|
1960
|
+
// has this entry (partial swap-in), remove it first.
|
|
1961
|
+
try {
|
|
1962
|
+
await rm(dst, { recursive: true, force: true });
|
|
1963
|
+
} catch (err) {
|
|
1964
|
+
log.warn(
|
|
1965
|
+
{ err, dst },
|
|
1966
|
+
"Failed to clear partial-swap entry before restoring from backup",
|
|
1967
|
+
);
|
|
1968
|
+
}
|
|
1969
|
+
try {
|
|
1970
|
+
await rename(src, dst);
|
|
1971
|
+
} catch (err) {
|
|
1972
|
+
if (isEXDEV(err)) {
|
|
1973
|
+
try {
|
|
1974
|
+
await copyTreeSkippingTransient(src, dst);
|
|
1975
|
+
await rm(src, { recursive: true, force: true });
|
|
1976
|
+
continue;
|
|
1977
|
+
} catch (cpErr) {
|
|
1978
|
+
failedCount += 1;
|
|
1979
|
+
log.error(
|
|
1980
|
+
{ err: cpErr, src, dst },
|
|
1981
|
+
"Failed to restore backup entry via cp fallback; manual recovery may be required",
|
|
1982
|
+
);
|
|
1983
|
+
continue;
|
|
1984
|
+
}
|
|
1985
|
+
}
|
|
1986
|
+
failedCount += 1;
|
|
1987
|
+
log.error(
|
|
1988
|
+
{ err, src, dst },
|
|
1989
|
+
"Failed to restore backup entry; manual recovery may be required",
|
|
1990
|
+
);
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
return { ok: failedCount === 0, failedCount };
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
/**
|
|
1998
|
+
* Copy `src` (a backup subtree) into `dst` (the live-workspace subtree,
|
|
1999
|
+
* which already exists and may already contain carried descendants we
|
|
2000
|
+
* must not clobber). Each child in `src` that doesn't collide with an
|
|
2001
|
+
* existing entry in `dst` is moved in; children that DO collide recurse
|
|
2002
|
+
* so carried files deeper in the tree survive.
|
|
2003
|
+
*/
|
|
2004
|
+
async function mergeBackupIntoLive(
|
|
2005
|
+
src: string,
|
|
2006
|
+
dst: string,
|
|
2007
|
+
carriedLivePaths: readonly string[],
|
|
2008
|
+
): Promise<void> {
|
|
2009
|
+
await mkdir(dst, { recursive: true });
|
|
2010
|
+
|
|
2011
|
+
let children: string[];
|
|
2012
|
+
try {
|
|
2013
|
+
children = await readdir(src);
|
|
2014
|
+
} catch (err) {
|
|
2015
|
+
if (isENOENT(err)) return;
|
|
2016
|
+
throw err;
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
for (const childName of children) {
|
|
2020
|
+
const childSrc = join(src, childName);
|
|
2021
|
+
const childDst = join(dst, childName);
|
|
2022
|
+
const childDstAbs = resolve(childDst);
|
|
2023
|
+
|
|
2024
|
+
let dstExists = false;
|
|
2025
|
+
try {
|
|
2026
|
+
await stat(childDst);
|
|
2027
|
+
dstExists = true;
|
|
2028
|
+
} catch (err) {
|
|
2029
|
+
if (!isENOENT(err)) throw err;
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
if (!dstExists) {
|
|
2033
|
+
try {
|
|
2034
|
+
await rename(childSrc, childDst);
|
|
2035
|
+
} catch (err) {
|
|
2036
|
+
if (isEXDEV(err)) {
|
|
2037
|
+
await copyTreeSkippingTransient(childSrc, childDst);
|
|
2038
|
+
await rm(childSrc, { recursive: true, force: true });
|
|
2039
|
+
} else {
|
|
2040
|
+
throw err;
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
2043
|
+
continue;
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
// dst child exists — check whether it IS a carried entry or CONTAINS
|
|
2047
|
+
// one. If it IS carried, backup's version is stale (carried is
|
|
2048
|
+
// canonical). If it CONTAINS carried, recurse.
|
|
2049
|
+
const isCarriedLeaf = carriedLivePaths.includes(childDstAbs);
|
|
2050
|
+
if (isCarriedLeaf) {
|
|
2051
|
+
// Skip — keep carried version that was already restored.
|
|
2052
|
+
continue;
|
|
2053
|
+
}
|
|
2054
|
+
const containsCarried = carriedLivePaths.some(
|
|
2055
|
+
(c) => c !== childDstAbs && c.startsWith(childDstAbs + sep),
|
|
2056
|
+
);
|
|
2057
|
+
if (containsCarried) {
|
|
2058
|
+
await mergeBackupIntoLive(childSrc, childDst, carriedLivePaths);
|
|
2059
|
+
continue;
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
// No carried conflict — backup's version should win over whatever
|
|
2063
|
+
// the partial-swap-in put here.
|
|
2064
|
+
await rm(childDst, { recursive: true, force: true });
|
|
2065
|
+
try {
|
|
2066
|
+
await rename(childSrc, childDst);
|
|
2067
|
+
} catch (err) {
|
|
2068
|
+
if (isEXDEV(err)) {
|
|
2069
|
+
await copyTreeSkippingTransient(childSrc, childDst);
|
|
2070
|
+
await rm(childSrc, { recursive: true, force: true });
|
|
2071
|
+
} else {
|
|
2072
|
+
throw err;
|
|
2073
|
+
}
|
|
2074
|
+
}
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
/**
|
|
2079
|
+
* Resolve an archive path through the caller's resolver, then rebase the
|
|
2080
|
+
* returned disk path onto the temp workspace. Returns `null` when the path
|
|
2081
|
+
* cannot be resolved or lands outside `realWorkspaceDir`.
|
|
2082
|
+
*/
|
|
2083
|
+
function resolveInsideTempWorkspace(
|
|
2084
|
+
archivePath: string,
|
|
2085
|
+
pathResolver: PathResolver,
|
|
2086
|
+
realWorkspaceDir: string,
|
|
2087
|
+
tempWorkspaceDir: string,
|
|
2088
|
+
): string | null {
|
|
2089
|
+
const resolved = pathResolver.resolve(archivePath);
|
|
2090
|
+
if (!resolved) return null;
|
|
2091
|
+
return rebaseOntoTempWorkspace(resolved, realWorkspaceDir, tempWorkspaceDir);
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
/**
|
|
2095
|
+
* Replace the `realWorkspaceDir` prefix of `diskPath` with `tempWorkspaceDir`.
|
|
2096
|
+
* Returns null if `diskPath` is not inside `realWorkspaceDir`.
|
|
2097
|
+
*/
|
|
2098
|
+
function rebaseOntoTempWorkspace(
|
|
2099
|
+
diskPath: string,
|
|
2100
|
+
realWorkspaceDir: string,
|
|
2101
|
+
tempWorkspaceDir: string,
|
|
2102
|
+
): string | null {
|
|
2103
|
+
const resolved = resolve(diskPath);
|
|
2104
|
+
const root = resolve(realWorkspaceDir);
|
|
2105
|
+
if (resolved === root) return resolve(tempWorkspaceDir);
|
|
2106
|
+
const prefix = root + sep;
|
|
2107
|
+
if (!resolved.startsWith(prefix)) return null;
|
|
2108
|
+
return resolve(tempWorkspaceDir, resolved.slice(prefix.length));
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
/**
|
|
2112
|
+
* Drain an entry body through the hash verifier, discarding the output.
|
|
2113
|
+
*
|
|
2114
|
+
* Uses `pipeline` (not `.pipe()`) so that if `body` is destroyed mid-stream
|
|
2115
|
+
* — e.g. the upstream fetch body is torn down during a URL import — the
|
|
2116
|
+
* verifier is destroyed too, and this call rejects promptly instead of
|
|
2117
|
+
* hanging on a `for await` that never terminates.
|
|
2118
|
+
*
|
|
2119
|
+
* A `/dev/null` Writable sink terminates the chain so the verifier's
|
|
2120
|
+
* readable side is continuously drained. Without this sink, a Transform as
|
|
2121
|
+
* the last pipeline stage would stall once its internal buffer reached
|
|
2122
|
+
* `highWaterMark` (16 KB default), since nothing would pull its output,
|
|
2123
|
+
* and `pipeline` would hang indefinitely on any skipped entry >~16 KB.
|
|
2124
|
+
*/
|
|
2125
|
+
async function drainThroughVerifier(
|
|
2126
|
+
body: Readable,
|
|
2127
|
+
expected: { sha256: string; size: number; archivePath: string },
|
|
2128
|
+
): Promise<void> {
|
|
2129
|
+
const verifier = createHashVerifier(expected);
|
|
2130
|
+
const devNull = new Writable({
|
|
2131
|
+
write(_chunk, _enc, cb) {
|
|
2132
|
+
cb();
|
|
2133
|
+
},
|
|
2134
|
+
});
|
|
2135
|
+
await pipeline(body, verifier, devNull);
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
/**
|
|
2139
|
+
* Hard cap on the per-entry size that `collectHashVerified` is willing to
|
|
2140
|
+
* buffer in memory. Applied to credential bodies and config files — both
|
|
2141
|
+
* are expected to be KB-scale in practice. Exceeding this cap signals a
|
|
2142
|
+
* crafted or corrupted bundle and is rejected before any bytes are read,
|
|
2143
|
+
* so the streaming importer's memory guarantees still hold on a 3 GB pod
|
|
2144
|
+
* even when the URL import is attacker-controlled.
|
|
2145
|
+
*/
|
|
2146
|
+
const MAX_BUFFERED_ENTRY_BYTES = 16 * 1024 * 1024;
|
|
2147
|
+
|
|
2148
|
+
/**
|
|
2149
|
+
* Collect an entry body into a Buffer, verifying hash+size along the way.
|
|
2150
|
+
*
|
|
2151
|
+
* Uses `pipeline` + a sink writable that accumulates chunks, so destroy
|
|
2152
|
+
* signals propagate the same way as `drainThroughVerifier` and the hash
|
|
2153
|
+
* verifier's `_flush` (which asserts size+sha256) always runs.
|
|
2154
|
+
*
|
|
2155
|
+
* Rejects entries whose manifest-declared size exceeds
|
|
2156
|
+
* `MAX_BUFFERED_ENTRY_BYTES` BEFORE reading any bytes, so an oversized
|
|
2157
|
+
* credential or config file cannot drive RSS up by `expected.size` on a
|
|
2158
|
+
* memory-limited pod.
|
|
2159
|
+
*/
|
|
2160
|
+
async function collectHashVerified(
|
|
2161
|
+
body: Readable,
|
|
2162
|
+
expected: { sha256: string; size: number; archivePath: string },
|
|
2163
|
+
): Promise<Buffer> {
|
|
2164
|
+
if (expected.size > MAX_BUFFERED_ENTRY_BYTES) {
|
|
2165
|
+
body.destroy();
|
|
2166
|
+
throw new StreamingValidationError(
|
|
2167
|
+
"entry_too_large_to_buffer",
|
|
2168
|
+
`Archive entry "${expected.archivePath}" declares ${expected.size} bytes, exceeding the ${MAX_BUFFERED_ENTRY_BYTES}-byte in-memory buffer cap for credentials/configs`,
|
|
2169
|
+
expected.archivePath,
|
|
2170
|
+
);
|
|
2171
|
+
}
|
|
2172
|
+
const verifier = createHashVerifier(expected);
|
|
2173
|
+
const chunks: Buffer[] = [];
|
|
2174
|
+
const sink = new Writable({
|
|
2175
|
+
write(chunk, _enc, cb) {
|
|
2176
|
+
chunks.push(chunk instanceof Buffer ? chunk : Buffer.from(chunk));
|
|
2177
|
+
cb();
|
|
2178
|
+
},
|
|
2179
|
+
});
|
|
2180
|
+
await pipeline(body, verifier, sink);
|
|
2181
|
+
return Buffer.concat(chunks);
|
|
2182
|
+
}
|
|
2183
|
+
|
|
2184
|
+
/** Map a thrown error from streaming orchestration into an ImportCommitResult. */
|
|
2185
|
+
function mapThrownToResult(err: unknown): ImportCommitResult {
|
|
2186
|
+
if (err instanceof StreamingValidationError) {
|
|
2187
|
+
return {
|
|
2188
|
+
ok: false,
|
|
2189
|
+
reason: "validation_failed",
|
|
2190
|
+
errors: [
|
|
2191
|
+
{
|
|
2192
|
+
code: err.code,
|
|
2193
|
+
message: err.message,
|
|
2194
|
+
...(err.archivePath !== undefined ? { path: err.archivePath } : {}),
|
|
2195
|
+
},
|
|
2196
|
+
],
|
|
2197
|
+
};
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
// Errors we raised ourselves for disk-side failures.
|
|
2201
|
+
if (err instanceof WriteFailedError) {
|
|
2202
|
+
return {
|
|
2203
|
+
ok: false,
|
|
2204
|
+
reason: "write_failed",
|
|
2205
|
+
message: err.message,
|
|
2206
|
+
};
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
// Anything else bubbling out of the tar / gunzip / HTTP stream pipeline:
|
|
2210
|
+
// treat as extraction_failed. This matches the buffer-based validator's
|
|
2211
|
+
// gzip/tar parse errors.
|
|
2212
|
+
return {
|
|
2213
|
+
ok: false,
|
|
2214
|
+
reason: "extraction_failed",
|
|
2215
|
+
message: errMessage(err),
|
|
2216
|
+
};
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
/** Sentinel error for disk I/O failures during streaming. */
|
|
2220
|
+
class WriteFailedError extends Error {
|
|
2221
|
+
constructor(message: string) {
|
|
2222
|
+
super(message);
|
|
2223
|
+
this.name = "WriteFailedError";
|
|
2224
|
+
}
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2227
|
+
function wrapWriteError(prefix: string, cause: unknown): WriteFailedError {
|
|
2228
|
+
return new WriteFailedError(`${prefix}: ${errMessage(cause)}`);
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
function errMessage(err: unknown): string {
|
|
2232
|
+
return err instanceof Error ? err.message : String(err);
|
|
2233
|
+
}
|
|
2234
|
+
|
|
2235
|
+
function isENOENT(err: unknown): boolean {
|
|
2236
|
+
return (
|
|
2237
|
+
typeof err === "object" &&
|
|
2238
|
+
err !== null &&
|
|
2239
|
+
(err as { code?: string }).code === "ENOENT"
|
|
2240
|
+
);
|
|
2241
|
+
}
|
|
2242
|
+
|
|
2243
|
+
function isEXDEV(err: unknown): boolean {
|
|
2244
|
+
return (
|
|
2245
|
+
typeof err === "object" &&
|
|
2246
|
+
err !== null &&
|
|
2247
|
+
(err as { code?: string }).code === "EXDEV"
|
|
2248
|
+
);
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2251
|
+
// ---------------------------------------------------------------------------
|
|
2252
|
+
// Crash-recovery marker
|
|
2253
|
+
//
|
|
2254
|
+
// `streamCommitImport` moves preserved paths (SQLite DB, Qdrant, etc.) from
|
|
2255
|
+
// the live workspace into a temp tree before the atomic rename pair. If the
|
|
2256
|
+
// process is killed between those two phases the live workspace comes up
|
|
2257
|
+
// missing the preserved paths. The marker written here persists the state
|
|
2258
|
+
// needed to replay the recovery on the next start-up.
|
|
2259
|
+
//
|
|
2260
|
+
// Schema stays deliberately small so a partially-written marker is easy to
|
|
2261
|
+
// detect (JSON parse failure → skip recovery rather than act on garbage).
|
|
2262
|
+
// ---------------------------------------------------------------------------
|
|
2263
|
+
|
|
2264
|
+
interface ImportMarker {
|
|
2265
|
+
/** Absolute path of the `.import-<uuid>` temp tree. */
|
|
2266
|
+
tempWorkspaceDir: string;
|
|
2267
|
+
/** Preserved paths moved out of the live workspace pre-swap. */
|
|
2268
|
+
carried: Array<{ liveChild: string; tempChild: string }>;
|
|
2269
|
+
/**
|
|
2270
|
+
* Absolute path of the `${realWorkspaceDir}.pre-import-<ts>` backup dir
|
|
2271
|
+
* (optional — only present once the content-level swap phase has started).
|
|
2272
|
+
* `recoverInterruptedImport` moves entries from here back into
|
|
2273
|
+
* `realWorkspaceDir` if it's populated, reversing any partial swap.
|
|
2274
|
+
*/
|
|
2275
|
+
backupDir?: string;
|
|
2276
|
+
/**
|
|
2277
|
+
* `true` once `swapWorkspaceContents` has returned successfully.
|
|
2278
|
+
* `recoverInterruptedImport` checks this before restoring from
|
|
2279
|
+
* `backupDir`: if the swap already completed, the backup is the OLD
|
|
2280
|
+
* pre-import state and restoring it would silently undo the successful
|
|
2281
|
+
* import. Instead, recovery just cleans up residual backup / temp
|
|
2282
|
+
* artifacts.
|
|
2283
|
+
*/
|
|
2284
|
+
swapCompleted?: boolean;
|
|
2285
|
+
}
|
|
2286
|
+
|
|
2287
|
+
/** Basename of the recovery marker inside `realWorkspaceDir`. */
|
|
2288
|
+
const IMPORT_MARKER_BASENAME = ".import-marker.json";
|
|
2289
|
+
|
|
2290
|
+
/**
|
|
2291
|
+
* Deterministic marker location INSIDE `realWorkspaceDir`.
|
|
2292
|
+
*
|
|
2293
|
+
* The marker must live on the same persistent volume as the scratch
|
|
2294
|
+
* dirs (`.pre-import-<ts-uuid>`, `.import-<uuid>`). In Docker/Kubernetes
|
|
2295
|
+
* the workspace is typically a mounted persistent volume while the
|
|
2296
|
+
* container rootfs is ephemeral — a pod restart can drop files on
|
|
2297
|
+
* rootfs while preserving the workspace, so a marker stored at
|
|
2298
|
+
* `dirname(realWorkspaceDir)` could vanish across restart while the
|
|
2299
|
+
* scratch dirs survive, leaving `recoverInterruptedImport` with
|
|
2300
|
+
* nothing to act on and orphaning the interrupted state.
|
|
2301
|
+
*
|
|
2302
|
+
* The dot-prefix keeps it out of the way of normal content; phase 1 of
|
|
2303
|
+
* `swapWorkspaceContents` filters it out via `scratchBasenames`, and
|
|
2304
|
+
* the swap's content move also skips it so the marker stays in place
|
|
2305
|
+
* across the workspace swap itself.
|
|
2306
|
+
*/
|
|
2307
|
+
function importMarkerPathFor(realWorkspaceDir: string): string {
|
|
2308
|
+
return join(realWorkspaceDir, IMPORT_MARKER_BASENAME);
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
async function writeImportMarker(
|
|
2312
|
+
markerPath: string,
|
|
2313
|
+
marker: ImportMarker,
|
|
2314
|
+
): Promise<void> {
|
|
2315
|
+
const serialized = JSON.stringify(marker);
|
|
2316
|
+
const tmp = `${markerPath}.tmp-${randomUUID()}`;
|
|
2317
|
+
// Write+rename so a crash mid-write leaves either the old marker (or
|
|
2318
|
+
// nothing) rather than a truncated JSON blob.
|
|
2319
|
+
await writeFile(tmp, serialized, { mode: 0o600 });
|
|
2320
|
+
await rename(tmp, markerPath);
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
async function safelyDeleteMarker(markerPath: string): Promise<void> {
|
|
2324
|
+
try {
|
|
2325
|
+
await unlink(markerPath);
|
|
2326
|
+
} catch (err) {
|
|
2327
|
+
if (isENOENT(err)) return;
|
|
2328
|
+
log.warn({ err, markerPath }, "Failed to delete import-recovery marker");
|
|
2329
|
+
}
|
|
2330
|
+
}
|
|
2331
|
+
|
|
2332
|
+
export interface RecoveryResult {
|
|
2333
|
+
/**
|
|
2334
|
+
* `true` when there's no leftover rollback state blocking a new
|
|
2335
|
+
* import: no marker, successful restore, or a recorded
|
|
2336
|
+
* `swapCompleted` fast-path cleanup. Callers (`streamCommitImport`,
|
|
2337
|
+
* daemon start-up) can proceed safely.
|
|
2338
|
+
*
|
|
2339
|
+
* `false` when the rollback is incomplete — the marker / backup /
|
|
2340
|
+
* temp tree are intentionally preserved on disk for a future retry,
|
|
2341
|
+
* so any caller about to rewrite the marker must refuse to proceed
|
|
2342
|
+
* to avoid orphaning the unresolved state.
|
|
2343
|
+
*/
|
|
2344
|
+
ok: boolean;
|
|
2345
|
+
/** Number of entries that couldn't be restored in the partial case. */
|
|
2346
|
+
failedCount: number;
|
|
2347
|
+
}
|
|
2348
|
+
|
|
2349
|
+
/**
|
|
2350
|
+
* Replay any crash-interrupted import against `realWorkspaceDir`.
|
|
2351
|
+
*
|
|
2352
|
+
* Call at daemon start-up (and implicitly at the start of every
|
|
2353
|
+
* `streamCommitImport` as a self-healing belt) so a prior killed import
|
|
2354
|
+
* doesn't leave the live workspace missing `data/db` / `data/qdrant` /
|
|
2355
|
+
* `embedding-models` / `deprecated`.
|
|
2356
|
+
*
|
|
2357
|
+
* Best-effort: logs per-entry failures and keeps going rather than
|
|
2358
|
+
* throwing. If no marker exists this is a cheap no-op. Returns a
|
|
2359
|
+
* `RecoveryResult` so callers can distinguish "nothing to recover /
|
|
2360
|
+
* recovered cleanly" from "rollback still pending — don't start
|
|
2361
|
+
* anything new."
|
|
2362
|
+
*/
|
|
2363
|
+
export async function recoverInterruptedImport(
|
|
2364
|
+
realWorkspaceDir: string,
|
|
2365
|
+
): Promise<RecoveryResult> {
|
|
2366
|
+
const markerPath = importMarkerPathFor(resolve(realWorkspaceDir));
|
|
2367
|
+
let raw: string;
|
|
2368
|
+
try {
|
|
2369
|
+
raw = await readFile(markerPath, "utf8");
|
|
2370
|
+
} catch (err) {
|
|
2371
|
+
if (isENOENT(err)) return { ok: true, failedCount: 0 };
|
|
2372
|
+
log.warn({ err, markerPath }, "Unable to read import-recovery marker");
|
|
2373
|
+
return { ok: true, failedCount: 0 };
|
|
2374
|
+
}
|
|
2375
|
+
|
|
2376
|
+
let marker: ImportMarker;
|
|
2377
|
+
try {
|
|
2378
|
+
marker = JSON.parse(raw) as ImportMarker;
|
|
2379
|
+
} catch (err) {
|
|
2380
|
+
log.warn(
|
|
2381
|
+
{ err, markerPath },
|
|
2382
|
+
"Import-recovery marker is malformed; deleting without acting on it",
|
|
2383
|
+
);
|
|
2384
|
+
await safelyDeleteMarker(markerPath);
|
|
2385
|
+
return { ok: true, failedCount: 0 };
|
|
2386
|
+
}
|
|
2387
|
+
|
|
2388
|
+
if (
|
|
2389
|
+
!Array.isArray(marker.carried) ||
|
|
2390
|
+
typeof marker.tempWorkspaceDir !== "string"
|
|
2391
|
+
) {
|
|
2392
|
+
log.warn(
|
|
2393
|
+
{ markerPath, marker },
|
|
2394
|
+
"Import-recovery marker has unexpected shape; deleting",
|
|
2395
|
+
);
|
|
2396
|
+
await safelyDeleteMarker(markerPath);
|
|
2397
|
+
return { ok: true, failedCount: 0 };
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2400
|
+
log.info(
|
|
2401
|
+
{
|
|
2402
|
+
markerPath,
|
|
2403
|
+
tempWorkspaceDir: marker.tempWorkspaceDir,
|
|
2404
|
+
carriedCount: marker.carried.length,
|
|
2405
|
+
swapCompleted: marker.swapCompleted === true,
|
|
2406
|
+
},
|
|
2407
|
+
"Recovering from interrupted import",
|
|
2408
|
+
);
|
|
2409
|
+
|
|
2410
|
+
const carriedEntries = marker.carried.map((c) => ({
|
|
2411
|
+
liveChild: c.liveChild,
|
|
2412
|
+
tempChild: c.tempChild,
|
|
2413
|
+
}));
|
|
2414
|
+
|
|
2415
|
+
// FAST PATH: the previous process completed the swap but crashed before
|
|
2416
|
+
// deleting the marker. Backup is the OLD pre-import state — restoring it
|
|
2417
|
+
// would silently undo the successful import. Skip backup restore, skip
|
|
2418
|
+
// carried restore (everything is already in live), just clean up
|
|
2419
|
+
// artifacts.
|
|
2420
|
+
if (marker.swapCompleted === true) {
|
|
2421
|
+
if (typeof marker.backupDir === "string" && marker.backupDir.length > 0) {
|
|
2422
|
+
await rm(marker.backupDir, { recursive: true, force: true }).catch(
|
|
2423
|
+
(err) => {
|
|
2424
|
+
log.warn(
|
|
2425
|
+
{ err, backupDir: marker.backupDir },
|
|
2426
|
+
"Failed to clean up backup dir after completed import",
|
|
2427
|
+
);
|
|
2428
|
+
},
|
|
2429
|
+
);
|
|
2430
|
+
}
|
|
2431
|
+
await rm(marker.tempWorkspaceDir, { recursive: true, force: true }).catch(
|
|
2432
|
+
(err) => {
|
|
2433
|
+
log.warn(
|
|
2434
|
+
{ err, tempWorkspaceDir: marker.tempWorkspaceDir },
|
|
2435
|
+
"Failed to clean up temp workspace after completed import",
|
|
2436
|
+
);
|
|
2437
|
+
},
|
|
2438
|
+
);
|
|
2439
|
+
await safelyDeleteMarker(markerPath);
|
|
2440
|
+
return { ok: true, failedCount: 0 };
|
|
2441
|
+
}
|
|
2442
|
+
|
|
2443
|
+
// SLOW PATH: swap did not complete. Roll back to pre-import state.
|
|
2444
|
+
//
|
|
2445
|
+
// Order matters: restore from backup FIRST, then restore carried
|
|
2446
|
+
// entries. If carried ran first, a subsequent `restoreFromBackupDir`
|
|
2447
|
+
// call that owns a parent dir (`data/`) would clobber the just-restored
|
|
2448
|
+
// carried entries (`data/db`, `data/qdrant`). Backup-first + carrier-
|
|
2449
|
+
// aware merge in `restoreFromBackupDir` preserves both.
|
|
2450
|
+
let restoreResult: RestoreFromBackupResult = { ok: true, failedCount: 0 };
|
|
2451
|
+
if (typeof marker.backupDir === "string" && marker.backupDir.length > 0) {
|
|
2452
|
+
try {
|
|
2453
|
+
restoreResult = await restoreFromBackupDir(
|
|
2454
|
+
marker.backupDir,
|
|
2455
|
+
resolve(realWorkspaceDir),
|
|
2456
|
+
carriedEntries,
|
|
2457
|
+
);
|
|
2458
|
+
} catch (err) {
|
|
2459
|
+
log.error(
|
|
2460
|
+
{ err, backupDir: marker.backupDir },
|
|
2461
|
+
"Failed to restore from backup dir during import recovery; manual intervention may be required",
|
|
2462
|
+
);
|
|
2463
|
+
restoreResult = { ok: false, failedCount: 1 };
|
|
2464
|
+
}
|
|
2465
|
+
}
|
|
2466
|
+
|
|
2467
|
+
await restoreCarriedPaths(carriedEntries);
|
|
2468
|
+
|
|
2469
|
+
// Only drop the backup dir if the restore completed cleanly. A partial
|
|
2470
|
+
// restore means there's still content in `backupDir` that no other
|
|
2471
|
+
// state holds — keep it for manual / next-boot recovery.
|
|
2472
|
+
if (
|
|
2473
|
+
restoreResult.ok &&
|
|
2474
|
+
typeof marker.backupDir === "string" &&
|
|
2475
|
+
marker.backupDir.length > 0
|
|
2476
|
+
) {
|
|
2477
|
+
await rm(marker.backupDir, { recursive: true, force: true }).catch(
|
|
2478
|
+
(err) => {
|
|
2479
|
+
log.warn(
|
|
2480
|
+
{ err, backupDir: marker.backupDir },
|
|
2481
|
+
"Failed to clean up backup dir during import recovery",
|
|
2482
|
+
);
|
|
2483
|
+
},
|
|
2484
|
+
);
|
|
2485
|
+
} else if (!restoreResult.ok) {
|
|
2486
|
+
log.error(
|
|
2487
|
+
{
|
|
2488
|
+
backupDir: marker.backupDir,
|
|
2489
|
+
failedCount: restoreResult.failedCount,
|
|
2490
|
+
},
|
|
2491
|
+
"Backup restore had failures; preserving backup dir and marker for next-boot retry",
|
|
2492
|
+
);
|
|
2493
|
+
}
|
|
2494
|
+
|
|
2495
|
+
// Only clean up the temp tree + marker when the restore completed
|
|
2496
|
+
// cleanly. On a partial restore the marker's `carried` plan still
|
|
2497
|
+
// references tempChild paths, so deleting the temp tree would break
|
|
2498
|
+
// the next boot's recovery attempt — leave both in place for retry.
|
|
2499
|
+
if (restoreResult.ok) {
|
|
2500
|
+
try {
|
|
2501
|
+
await rm(marker.tempWorkspaceDir, { recursive: true, force: true });
|
|
2502
|
+
} catch (err) {
|
|
2503
|
+
log.warn(
|
|
2504
|
+
{ err, tempWorkspaceDir: marker.tempWorkspaceDir },
|
|
2505
|
+
"Failed to clean up temp workspace during import recovery",
|
|
2506
|
+
);
|
|
2507
|
+
}
|
|
2508
|
+
await safelyDeleteMarker(markerPath);
|
|
2509
|
+
} else {
|
|
2510
|
+
log.warn(
|
|
2511
|
+
{
|
|
2512
|
+
tempWorkspaceDir: marker.tempWorkspaceDir,
|
|
2513
|
+
markerPath,
|
|
2514
|
+
},
|
|
2515
|
+
"Preserving temp tree + marker for next-boot recovery retry",
|
|
2516
|
+
);
|
|
2517
|
+
}
|
|
2518
|
+
|
|
2519
|
+
return restoreResult.ok
|
|
2520
|
+
? { ok: true, failedCount: 0 }
|
|
2521
|
+
: { ok: false, failedCount: restoreResult.failedCount };
|
|
2522
|
+
}
|