Conversation
…ores (#1100) After migrating from filesystem to S3 datastore, model method execution failed because all extension bundle loaders hardcoded bundle paths to local `.swamp/bundles/` instead of resolving through the DatastorePathResolver. The resolver already correctly handled path routing for data artifacts, workflow runs, and secrets — but bundles were bypassed. Changes: - Add `driver-bundles`, `datastore-bundles`, `report-bundles` to DEFAULT_DATASTORE_SUBDIRS so the resolver routes all bundle types to the datastore tier - Add optional DatastorePathResolver parameter to UserModelLoader, UserVaultLoader, UserDriverLoader, and UserReportLoader constructors with a `resolveBundlePath()` helper that delegates to the resolver when present, falling back to the local path when absent - Replace all hardcoded `join(repoDir, SWAMP_DATA_DIR, SWAMP_SUBDIRS.*)` bundle path constructions with `resolveBundlePath()` calls across all 4 loaders (model, vault, driver, report) - Wire a lazy resolver factory through mod.ts that constructs the resolver on first loader invocation and shares it across all loaders. UserDatastoreLoader is explicitly excluded (bootstrap ordering — it loads datastore extensions that configure the resolver itself) - Add datastore base path tracking to ExtensionCatalogStore so stale catalog entries are auto-invalidated when the datastore changes (e.g. after migration from filesystem to S3) - Update assertSafePath boundary checks to use the resolver-aware bundle base path Co-authored-by: Sean Escriva <[email protected]> Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
There was a problem hiding this comment.
Code Review
Well-structured bug fix that correctly wires DatastorePathResolver into the 4 extension bundle loaders (model, vault, driver, report) while correctly excluding the datastore loader due to bootstrap ordering. The lazy resolver factory with caching in mod.ts is a clean approach.
Blocking Issues
None.
Suggestions
-
Silent error swallowing in
lazyResolver(src/cli/mod.ts): The.catch(() => undefined)onresolveDatastoreConfigsilently falls back to local paths for any error, including legitimate configuration problems (e.g., malformed.swamp.yaml). Consider logging a warning in the catch handler so users can diagnose misconfiguration, rather than silently falling back. -
Catalog invalidation test coverage: The new
getDatastoreBasePath()/setDatastoreBasePath()methods onExtensionCatalogStoreand the invalidation logic inbuildIndex()(lines ~497-510 inuser_model_loader.ts) aren't directly tested. A unit test that populates the catalog, changes the base path, and verifies re-invalidation would guard against regressions in this logic. -
resolveBundlePath()duplication: The four loaders (model, vault, driver, report) have near-identicalresolveBundlePath()implementations. This is fine for now given the fix scope, but if bundle path resolution grows more complex, consider extracting a shared helper. -
DDD: The
type-onlyimport frommodels -> datastore(bumping the ratchet to 12) is the right call — it keeps the coupling lightweight (no runtime dependency) while enabling the resolver injection. The domain interface (DatastorePathResolver) stays in the datastore bounded context where it belongs, and loaders depend on the abstraction rather than the infrastructure implementation. Clean dependency inversion.
There was a problem hiding this comment.
Adversarial Review
Critical / High
None found. The code is well-structured and the guards are correct.
Medium
-
src/cli/mod.ts:644— Silent catch-all swallows real configuration errors. The lazy resolver factory uses.catch(() => undefined), which treats "no datastore configured" identically to "malformed YAML", "permission denied on config file", or "S3 auth failure". If a user has a broken datastore config, they'll silently fall back to local paths with no indication that their S3 datastore is being bypassed. Consider catching only the expected "no config" error class and logging a warning for unexpected failures:.catch((err) => { if (!(err instanceof NoDatastoreConfiguredError)) { // or whatever the expected error type is logger.warn`Failed to create datastore path resolver, falling back to local paths: ${err}`; } return undefined; });
-
src/domain/models/user_model_loader.ts:504-512— First run after upgrade forces catalog invalidation on ALL installations. On the first run after this PR ships,getDatastoreBasePath()returnsundefined(never been set) whilecurrentBasePathis a non-empty string, soundefined !== "/repo/.swamp/bundles"istrue. This means every existing installation will get a one-time forced catalog rescan on upgrade, even for users who have never used S3. This is a one-time cost and the comment + design make it clear this is intentional for correctness. Mentioning it only because it's non-obvious — it may cause a noticeable first-run delay on repos with many extensions.
Low
-
resolveBundlePath()returns""whenrepoDiris null. All four loaders have this path (if (!this.repoDir) return ""). While currently all callers guard withif (this.repoDir)before reachingresolveBundlePath(), passing""toassertSafePathas a boundary would resolve to the CWD and cause a spuriousPathTraversalError. This is safe today due to the guards, but the empty-string return is a foot-gun if future callers forget the guard. A defensive alternative would be tothrowinstead of returning"", since a null repoDir reaching this point would always be a programming error. -
assertSafePathboundary is now narrower. PreviouslybundleBoundary = join(repoDir, SWAMP_DATA_DIR)(e.g.,/repo/.swamp), now it'sresolveBundlePath()(e.g.,/repo/.swamp/bundles). This is strictly tighter — a security improvement, not a concern. Noting it because it's a behavioral change in path validation that could theoretically reject a path that the old code accepted, though no such path should exist in practice.
Verdict
PASS — Clean, well-guarded refactor. The resolver is correctly excluded from the datastore loader bootstrap, all four loaders follow a consistent pattern, the catalog invalidation is sound, and the fallback behavior preserves backward compatibility. The silent error swallowing (Medium #1) is the most actionable item but does not block.
After rebasing onto main (which added DatastorePathResolver in #1103), update the new catalog-related methods (populateCatalogFromRegistry, getVaultBundlePath, getDriverBundlePath, getReportBundlePath) to use this.resolveBundlePath() instead of hardcoded join() paths. This ensures lazy loading works correctly with S3 datastores. The datastore loader intentionally keeps hardcoded local paths due to bootstrap ordering constraints. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Summary
DatastorePathResolverinto 4 extension bundle loaders (model, vault, driver, report) so bundle reads/writes route through the configured datastore tierFixes #1100
Root Cause
All 5 extension bundle loaders (
UserModelLoader,UserVaultLoader,UserDriverLoader,UserDatastoreLoader,UserReportLoader) hardcoded bundle paths to local.swamp/bundles/viajoin(repoDir, SWAMP_DATA_DIR, SWAMP_SUBDIRS.bundles, ...). TheDatastorePathResolver— which already correctly routed data artifacts, workflow runs, secrets, and other datastore-tier paths — was never consulted for bundles.After S3 datastore migration, bundles existed only in the cache path (
~/.swamp/repos/<repo-id>/bundles/) but the loaders still looked in.swamp/bundles/, producingNo such file or directory (os error 2).What Changed
1.
DEFAULT_DATASTORE_SUBDIRS(datastore_config.ts)Added
driver-bundles,datastore-bundles, andreport-bundlesto the default datastore subdirectories list. Without this, the resolver would treat these 3 bundle types as local-only, even when an S3 datastore is configured. (bundlesandvault-bundleswere already listed.)2. Bundle Loaders — 4 files
Added optional
DatastorePathResolverconstructor parameter and a privateresolveBundlePath()helper to each loader. The helper delegates toresolver.resolvePath()when available, falling back to the hardcoded local path when absent (backward compatibility for tests,repo init, no datastore configured).Replaced all hardcoded
join(repoDir, SWAMP_DATA_DIR, SWAMP_SUBDIRS.*, ...)bundle path constructions withresolveBundlePath():getBundlePath(),bundleWithCache(),importBundle(),populateCatalogFromRegistry(),assertSafePathboundary. ExcludedmigrateOldFlatBundles()which must always read from the old local path.bundleWithCache(),importBundle(),assertSafePathboundarybundleWithCache(),importBundle(),assertSafePathboundarybundleWithCache(),importBundle(),assertSafePathboundaryUserDatastoreLoader is explicitly excluded — it loads datastore extensions that configure the resolver (bootstrap ordering). It always uses the local
.swamp/path.3. CLI Wiring (
mod.ts,auto_resolver_adapters.ts)mod.tsthat constructs theDefaultDatastorePathResolveron first loader invocation and caches it for reusesetLoader()closuresauto_resolver_adapters.tsto accept an optional resolver for hot-load scenarios4. Catalog Invalidation (
extension_catalog_store.ts,user_model_loader.ts)The
ExtensionCatalogStore(SQLite at.swamp/_extension_catalog.db) stores absolutebundle_pathvalues. After datastore migration, these paths become stale (pointing to old.swamp/bundles/instead of the new cache path). Added:getDatastoreBasePath()/setDatastoreBasePath()on the catalog storebuildIndex()that compares the stored base path against the current resolver path, auto-invalidating the catalog when they differ5. Architecture boundary ratchet
The new
models -> datastoretype-only import (import type { DatastorePathResolver }) creates a mutual dependency with the existingdatastore -> modelsimport. Updated the ratchet from 11 to 12.Test Plan
Automated Tests (all 4140 pass)
3 new resolver tests (
default_datastore_path_resolver_test.ts):resolvePath()routes bundles to S3 cache path for custom datastores1 new constructor test (
user_model_loader_test.ts):UserModelLoaderaccepts optionalDatastorePathResolver(with and without)All 4140 existing tests pass — the optional resolver with fallback preserves backward compatibility
Manual End-to-End Verification
Reproduced the exact scenario from the issue:
/tmp/swamp-repro-issue-1100/@swamp/aws/ec2extension — bundles written to.swamp/bundles/2e4ea9ae/.swamp/bundles/to/tmp/swamp-s3-cache-1100/bundles//tmp/swamp-s3-cache-1100/in.swamp.yamlNo such file or directory (os error 2): readfile '/private/tmp/swamp-repro-issue-1100/.swamp/bundles/2e4ea9ae/vpc.js'— confirming the bug/tmp/swamp-s3-cache-1100/bundles/2e4ea9ae/vpc.jsRisk Assessment
.swamp/pathundefined, falls back to hardcoded pathbuildIndex()call; auto-invalidation handles stale entries🤖 Generated with Claude Code