Skip to content

feat(extensions): namespace pulled extensions by scoped name (per-extension layout)#1186

Merged
stack72 merged 3 commits intomainfrom
worktree-jiggly-napping-prism
Apr 17, 2026
Merged

feat(extensions): namespace pulled extensions by scoped name (per-extension layout)#1186
stack72 merged 3 commits intomainfrom
worktree-jiggly-napping-prism

Conversation

@stack72
Copy link
Copy Markdown
Contributor

@stack72 stack72 commented Apr 17, 2026

Fixes swamp-club#120.

Summary

Pulled extensions now land in per-extension on-disk subtrees
(.swamp/pulled-extensions/<ext-name>/{models,workflows,…}/) instead
of a shared flat layout. Fixes the silent overwrite of shared
filenames (_lib/aws.ts, README.md, LICENSE.txt) across sibling
extensions from the same collective — a bug observed in the wild with
@swamp/aws/ec2 + @swamp/aws/eks, where prior pulls corrupted each
other's helpers.

Additional changes riding along:

  • Integrity anchor: upstream_extensions.json stores a SHA-256 per
    install and extension install / migration re-pull now verify
    freshly-downloaded archives against it. Explicit extension pull
    stays opt-in to new registry content.
  • Migration: swamp repo upgrade detects legacy layouts (gen-1
    extensions/<type>/, gen-2 flat .swamp/pulled-extensions/<type>/)
    and migrates. Gen-2 uses selective delete + re-install via the
    integrity anchor because rename cannot restore content that was
    silently overwritten pre-fix. Partial migrations are resumable
    (NotFound on retry is tolerated; any other IO error aborts with the
    lockfile intact).
  • Renderer output: repo upgrade surfaces per-extension deletion
    counts and a next-step hint so users understand what happened.
  • Warn-not-block guard: replaces throw-on-legacy with warn so
    already-migrated extensions stay usable during partial state.
  • Manifest colocation: each installed extension now has a
    read-only manifest.yaml at its root. Fixes a latent
    findDependents bug (never worked pre-120 because the manifest was
    never persisted).
  • Auto-resolve fix: repaired a regression in auto_resolver_adapters.ts
    and resolve_datastore.ts hot-load paths that were still walking
    the flat layout.
  • API cleanup: InstallContext / ExtensionPullDeps lose six
    dead path fields; createExtensionPullDeps shrinks from 10 to 4
    params.

Test Plan

  • deno check clean
  • deno lint clean
  • deno fmt clean
  • deno run test — 4392 pass / 0 fail (up from 4379 baseline):
    5 new migration tests, 6 enumerate-helper tests, 4 layout-tests,
    1 checksum-mismatch test, 2 rm regression tests, 5 auto-resolve
    adapter tests
  • Compiled binary end-to-end against triage reproduction snapshot:
    repo upgrade → migration summary → extension install
    (integrity-verified) → extension listextension rm @stack72/ubiquity → siblings @swamp/aws/ec2 + @swamp/aws/eks
    intact
  • Offline scenario: SWAMP_CLUB_URL=http://localhost:8080 during
    extension install → all entries recorded as failed, lockfile
    preserved, online retry recovers cleanly
  • design/extension.md updated with new layout, integrity anchor,
    and migration generations

Follow-up UAT

🤖 Generated with Claude Code

…ension layout)

Fixes swamp-club#120. Pre-change, all pulled extensions extracted into a
shared flat layout at .swamp/pulled-extensions/<type>/. Files with the
same basename across extensions (e.g. README.md, LICENSE.txt, _lib/aws.ts
in sibling AWS extensions) silently overwrote each other and
`extension rm` of one extension could destroy files another still needed.

## On-disk layout

Each installed extension now owns a dedicated subtree:

  .swamp/pulled-extensions/<ext-name>/{models,workflows,vaults,drivers,
                                       datastores,reports,files}/
  .swamp/pulled-extensions/<ext-name>/manifest.yaml  (read-only, new)

Bundle cache isolation falls out for free: bundleNamespace() hashes the
per-extension models dir, which is unique per extension, so each
extension gets its own bundle namespace under .swamp/bundles/<hash>/.

Skills remain at the tool-specific dir (.claude/skills/ etc.) — they're
already per-skill and not collision-prone.

## Integrity anchor

upstream_extensions.json already records a SHA-256 checksum per install
but never used it for verification. installExtension now accepts an
optional expectedChecksum, and extensionInstall (the lockfile-restore
path) passes the stored value. On mismatch, the install fails loudly
with an actionable message offering `swamp extension pull <name>` as
the recovery path. Explicit `swamp extension pull` stays opt-in (no
expectedChecksum) so users can still accept drifted registry content
by intent. Pre-f4dfc083 entries without a checksum skip verification
gracefully.

## Migration

RepoService.migrateExtensionLayout runs during `swamp repo upgrade` and
handles two legacy generations:

- gen-1 (pre-.swamp/): rename extensions/<type>/... files to their
  .swamp/pulled-extensions/<type>/... equivalents. Unchanged behavior.
- gen-2 (flat-under-.swamp/): because sibling extensions may have
  silently overwritten each other's files, rename cannot restore
  authentic content. Phase 2 selectively deletes each gen-2 entry's
  tracked files (NotFound tolerated — expected on retry after an
  interrupted prior pass; all other IO errors abort the migration
  with the lockfile intact). `swamp extension install` then re-pulls
  each affected extension with the integrity anchor enforcing
  byte-identical restores.

Migration output is surfaced through the repo-upgrade renderer with
per-extension deletion counts and a "run `swamp extension install`"
next-step hint so users understand what happened.

## Warn-not-block guard

Replaces requireCurrentExtensionLayout (throw on legacy) with
warnLegacyExtensionLayout (log + continue). The lockfile tolerates
mixed-generation state, and individual extensions can migrate
independently, so blocking all commands until a full migration was
all-or-nothing is unnecessary. Users with partial migration state
keep using their migrated extensions while being gently reminded to
complete via `swamp repo upgrade`.

## Auto-resolve adapter

Fixed a live regression: the auto-resolve hot-load path in
auto_resolver_adapters.ts and the datastore auto-update path in
resolve_datastore.ts were walking the pre-120 flat layout. Both now
route through enumeratePulledExtensionDirs to discover per-extension
dirs from the lockfile. Locked in by a new test file.

## Dead API cleanup

InstallContext and ExtensionPullDeps lose six fields
(modelsDir/workflowsDir/vaultsDir/driversDir/datastoresDir/reportsDir)
that installExtension no longer consults — destinations are derived
from ref.name. createExtensionPullDeps drops from 10 positional params
to 4. Simplifies the libswamp extension surface and prevents silent
no-op bugs where callers think they're configuring a destination path.

## Testing

- pull.ts: per-extension layout smoke-tested against real registry
  (@stack72/ubiquity, @swamp/aws/ec2, @swamp/aws/eks)
- layout.ts: 14 tests covering generation classification and guard
- enumerate_pulled.ts: 6 tests for the loader-additionalDirs helper
- repo_service.ts: 5 migration scenarios (happy path, partial fail,
  NotFound tolerance, fatal IO abort, mixed-state)
- install.ts: checksum mismatch end-to-end with drift message
- rm_test.ts: sibling-prune regression + findDependents via tracked
  manifest (fixes a latent bug — manifest.yaml was never persisted
  for findDependents to resolve)
- auto_resolver_adapters_test.ts: hot-load plumbing regression
- 4392 total tests pass, deno check/lint/fmt clean

End-to-end verification against the triage reproduction snapshot:
repo upgrade → extension install (integrity-verified) → extension
rm leaves siblings intact.

## Follow-up UAT

- swamp-uat#142: auto-resolution end-to-end coverage
- swamp-uat#143: co-install sibling extensions user story

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CLI UX Review

Blocking

None.

Suggestions

  1. JSON field names expose internal phase jargon (repo_service.ts, ExtensionLayoutMigrationSummary): phase1MovedCount and phase2DeletedPerExtension are now stable public JSON API. Users scripting against swamp repo upgrade --json will see these names with no context for what "phase1" or "phase2" mean. Consider renamedFileCount / deletedPerExtension for clearer, more stable naming.

  2. "flat-layout file(s)" is internal jargon (repo_init.ts:132): The log line Removed Y flat-layout file(s) across Z extension(s) uses the term "flat-layout" which has no meaning to users who haven't read the design doc. "Removed Y outdated file(s) across Z extension(s)" communicates the same intent without leaking the internal layout taxonomy.

  3. Next-step hint appends implementation detail (repo_init.ts:139): "The lockfile's stored checksum is verified on each re-pull." is useful for security-conscious users but is noise for the typical migration case. The actionable part ends at Run 'swamp extension install' to restore these extensions into the per-extension layout.

Verdict

PASS — the warn-not-block migration, per-extension output in repo upgrade, and JSON extensionMigration field all land cleanly from a UX perspective. No blocking issues.

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

Blocking Issues

  1. libswamp import boundary violation (src/cli/mod.ts:25): Imports enumeratePulledExtensionDirs from the internal path "../libswamp/extensions/enumerate_pulled.ts" instead of "../libswamp/mod.ts". Per CLAUDE.md: "CLI commands and presentation renderers must import libswamp types and functions from src/libswamp/mod.ts — never from internal module paths." The function is already exported from mod.ts (line 646), so this is a one-line fix.

  2. libswamp import boundary violation (src/cli/repo_context.ts:49): Same issue — imports enumeratePulledExtensionDirs from "../libswamp/extensions/enumerate_pulled.ts" instead of "../libswamp/mod.ts".

Suggestions

  1. DDD alignment looks good: ExtensionLayoutGeneration is a clean union-type value object, LegacyFileEntry/LegacyLayoutSummary/ExtensionLayoutMigrationSummary are properly immutable summary types, migration logic correctly lives in RepoService, and enumeratePulledExtensionDirs is well-placed as a libswamp query helper.

  2. The migration phase separation (phase 1: rename gen-1→gen-2, phase 2: delete gen-2 for re-install) is well-designed. The lockfile-as-coordination-primitive pattern — preserving it on fatal errors, tolerating NotFound on retry — makes partial migrations safely resumable.

  3. Test coverage is solid: 5 migration tests covering happy path, partial retry tolerance, idempotency, fatal IO error abort, and mixed-generation state; 6 enumerate-helper tests; checksum-mismatch test; auto-resolver adapter regression tests. Good edge case coverage.

  4. The integrity anchor design (lockfile-anchored SHA-256 verification on restore, opt-in on explicit pull, graceful skip for pre-checksum entries) is a nice security improvement.

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial Review

Critical / High

No critical or high severity issues found. The code is well-structured for a large migration change. Key safety mechanisms are solid: the archive path traversal checks (.. and / prefix rejection at pull.ts:743-747, symlink escape validation at pull.ts:770-776), the checksum integrity anchor (pull.ts:700-721), the advisory lock around lockfile writes (pull.ts:217-297), and the atomicWriteTextFile for crash-safe lockfile updates all look correct.

Medium

  1. Phase 1 lockfile write bypasses advisory lockrepo_service.ts:1751-1754: migrateExtensionLayoutPhase1 rewrites upstream_extensions.json via atomicWriteTextFile directly, without acquiring the .lock file that updateUpstreamExtensions (pull.ts:260-297) uses for concurrency safety. If a user concurrently runs swamp extension pull during swamp repo upgrade, the two writers can clobber each other. Low probability since repo upgrade is typically run standalone, but the advisory lock exists for exactly this reason.

  2. Stale advisory lock after crashpull.ts:217-240, 291-297: The advisory lock file contains no PID or timestamp. If the process is killed between acquireLock and the finally block's Deno.remove, the .lock file remains permanently. All subsequent operations fail after 10 retries (1 second) with "Another pull may be in progress." The only recovery is manual deletion. Consider writing the PID and checking liveness on conflict, or at minimum adding a stale-lock age check.

  3. Broad catch {} in fileExistspull.ts:341-348: Returns false for permission errors, not just NotFound. If a file exists but is unreadable, detectConflicts skips it and the install silently overwrites it. Same pattern appears in validateSourceCompleteness (pull.ts:603) and collectTsFiles (pull.ts:635).

  4. hasAnyMissingFiles returns false for empty files arrayinstall.ts:137-153: An extension whose lockfile entry has files: [] (e.g., from a crash after lockfile write but before file extraction, or a corrupt lockfile edit) is treated as "up to date" and never re-installed. The checkForMissingPulledExtensions guard in mod.ts:676-681 also skips the extension because it filters to source files and finds none. This is a narrow edge case but could cause confusion.

  5. PULLED_TYPE_DIRS duplicated between layout.ts and repo_service.tslayout.ts:43-52 defines PULLED_TYPE_DIRS, repo_service.ts:1783-1792 independently defines pulledTypeDirs with the same entries. If one is updated without the other, classifyExtensionFile and migrateExtensionLayoutPhase2 would disagree on what constitutes a gen-2 file vs. a current-layout file. Should be a shared constant.

Low

  1. Redundant lockfile reads in dependency resolutionpull.ts:1096: Each dependency iteration re-reads and re-parses the lockfile to check if a dependency is already installed. With MAX_DEPENDENCY_DEPTH = 10 and multiple deps, this is wasteful. The lockfile was just written at line 1075. A cached read would be cleaner.

  2. resolveSwampBinaryPath uses synchronous outputSync()repo_service.ts:1349: Blocks the event loop for which swamp. Minor since this runs once during upgrade, but inconsistent with the project's async conventions.

  3. Lockfile path resolution copy-pasted three timesrepo_context.ts:252-260, 386-395, 493-501: Identical lockfile path construction logic (with double-call to resolveModelsDir) is repeated verbatim. Maintenance hazard but not a correctness issue.

  4. checkForMissingPulledExtensions ignores .js filesmod.ts:678-681: Missing bundle .js files won't trigger the warning. Intentional per the comment, but could mask real breakage if an extension's only artifact is a bundled JS file.

Verdict

PASS — The migration design is sound: phase 1 renames (reversible), phase 2 deletes with lockfile-anchored re-install, and the integrity checksum prevents silent registry drift. The archive extraction has proper path traversal guards. No data loss or security issues in production paths. The medium items are worth addressing in follow-ups but none block this PR.

stack72 and others added 2 commits April 17, 2026 19:51
Claude Code Review blockers:
- src/cli/mod.ts and src/cli/repo_context.ts imported
  `enumeratePulledExtensionDirs` from the internal module path; CLAUDE.md
  requires CLI callers to import libswamp exports from
  `src/libswamp/mod.ts` only. Both now go through the barrel.

CLI UX Review suggestions:
- Renamed `ExtensionLayoutMigrationSummary.phase1MovedCount` →
  `renamedFileCount` and `phase2DeletedPerExtension` →
  `deletedPerExtension`. The old names leaked internal "phase 1 / phase 2"
  jargon into the public JSON surface of `swamp repo upgrade --json`.
- Replaced the `"flat-layout file(s)"` log line with `"outdated
  file(s)"` — the "flat-layout" term only makes sense to readers of the
  design doc.
- Dropped the trailing "The lockfile's stored checksum is verified on
  each re-pull" note from the migration hint. The actionable part is
  `swamp extension install`; the integrity anchor is an implementation
  detail that doesn't belong in the normal upgrade output.

Adversarial Review medium — duplicated constant:
- `PULLED_TYPE_DIRS` was defined inline in both
  `libswamp/extensions/layout.ts` (for `classifyExtensionFile`) and
  `domain/repo/repo_service.ts` (for phase-two migration). If the two
  drifted, the detector and the migrator would disagree on what counts
  as a gen-2 file. Exported from `layout.ts`, re-exported through
  `libswamp/mod.ts`, consumed by `repo_service.ts`.

Verified: deno check/lint/fmt clean, 4392 tests pass, compiled binary
end-to-end against the triage reproduction snapshot shows the cleaner
migration output.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
`.claude/scheduled_tasks.lock` was inadvertently staged in the previous
commit — it's a runtime lock managed by the scheduler subsystem and
shouldn't live in version control. Remove from the index and gitignore
both the lock file and the sibling state file so neither leaks into
future commits.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CLI UX Review

Blocking

None.

Suggestions

  1. Warn message omits extension nameswarnLegacyExtensionLayout emits "3 extension(s) pending migration. Run 'swamp repo upgrade' to complete." The count is there but the names aren't. summariseLegacyLayout already returns extensionNames; surfacing them (e.g. @swamp/aws/ec2, @swamp/aws/eks pending migration...) would let users confirm which ones need upgrading without a separate lookup.

  2. Awkward pluralization in repo upgrade output"1 file(s)", "1 extension(s)" is a minor roughness. Simple ternary (file(s)file / files) would polish the log output.

Verdict

PASS — The migration summary added to repo upgrade is clear and includes an explicit CTA (swamp extension install). The checksum-mismatch and migration-failure error messages are actionable and tell users exactly what to run. Flipping warn-instead-of-block is a net UX improvement. JSON mode inherits extensionMigration automatically via JSON.stringify(e.data). No blocking issues.

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

Well-structured PR that solves a real data-integrity problem (silent cross-extension file overwrites) with a clean per-extension layout, integrity anchoring, and a resumable migration path. Comprehensive test coverage with 23+ new tests covering happy paths, edge cases (NotFound tolerance, IO abort), and regressions. Design doc updated, API surface cleaned up.

Blocking Issues

None.

Suggestions

  1. Domain→libswamp internal importsrc/domain/repo/repo_service.ts:27 imports PULLED_TYPE_DIRS directly from ../../libswamp/extensions/layout.ts (internal path) rather than from ../../libswamp/mod.ts. While the CLAUDE.md import boundary rule literally targets CLI commands and presentation renderers, this creates a domain→application-layer dependency that inverts the DDD dependency direction. Consider importing through mod.ts, or better, promoting PULLED_TYPE_DIRS into a shared domain/infrastructure location since it's fundamentally a filesystem-convention constant used by both layers.

  2. Repeated lockfile-path computation in repo_context.ts — the pattern isAbsolute(resolveModelsDir(marker)) ? resolveModelsDir(marker) : resolve(repoPath.value, resolveModelsDir(marker)) appears three times (lines ~252, ~385, ~493), calling resolveModelsDir(marker) twice per occurrence. A small helper (e.g. absoluteModelsDir(marker, repoPath)) would reduce duplication and the redundant calls.

  3. Deprecated requireCurrentExtensionLayout uses dynamic import — the backwards-compat shim at layout.ts:183 lazily imports UserError via await import(...) to avoid circular deps. If the function is truly deprecated and all call sites have migrated to warnLegacyExtensionLayout, consider removing it entirely rather than maintaining it with the dynamic-import workaround. If it must stay for one more release cycle, that's fine, but note the removal in a follow-up issue.

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial Review

Critical / High

No critical or high severity findings.

Medium

  1. src/cli/repo_context.ts:252-259, 383-392, 475-484 — Repeated inline lockfile-path computation is fragile and calls resolveModelsDir(marker) twice per site.

    Three nearly identical 8-line blocks compute the lockfile path inline. Each calls resolveModelsDir(marker) twice (once for the isAbsolute check, once for the resolve). While resolveModelsDir is pure (no side effects) and this is functionally correct, the triplication makes it easy for a future editor to fix one site and miss the other two. A local lockfilePath helper or a shared utility would be more robust.

    Breaking example: If resolveModelsDir were ever made stateful or expensive (unlikely but possible), this would double the cost. More practically, a future change to the path convention only updated in one of three places would produce incorrect workflow enumeration in the other two.

    Suggested fix: Extract a buildLockfilePath(repoDir, marker) function used by all three call sites, or at minimum bind const modelsDir = resolveModelsDir(marker) once before the isAbsolute check.

  2. src/libswamp/extensions/layout.ts:74-86 — classifyExtensionFile assumes extension names always start with @ to distinguish from type dirs, but PULLED_TYPE_DIRS could collide with a hypothetical future non-scoped name.

    Currently safe because validateExtensionName enforces @scope/name format, but classifyExtensionFile has no direct dependency on that invariant. If an extension named e.g. models/evil were ever allowed (it wouldn't pass the current regex, but the layout classifier doesn't know that), it would be misclassified as gen-2.

    Suggested fix: No code change needed today — the scoped-name constraint is enforced. This is a documentation-level concern: a comment noting the invariant dependency would prevent future drift.

  3. src/libswamp/extensions/pull.ts:700-701 — Lockfile-anchored checksum comparison uses raw string equality between two hex checksums. If the lockfile were ever hand-edited or a future serialisation prepended sha256- (as the design doc's example "sha256-…" suggests), the comparison would always fail.

    The design doc at line 32 of the diff ("checksum": "sha256-…") uses a sha256- prefix in its illustrative example, while computeChecksum returns raw hex. If someone reads the design doc and manually constructs or patches a lockfile entry with a sha256- prefix, every restore would trigger a false-positive mismatch and tell the user their registry has drifted.

    Suggested fix: Either normalise the comparison (strip any sha256- prefix before comparing), or update the design doc example to use raw hex to match the actual stored format.

Low

  1. src/libswamp/extensions/enumerate_pulled.ts:54 — readUpstreamExtensions is called on every type enumeration (models, workflows, vaults, drivers, datastores, reports). In src/cli/mod.ts, this means the lockfile is read and parsed 5 times during startup.

    Each of loadUserModels, loadUserVaults, loadUserDrivers, loadUserDatastores, and loadUserReports independently calls enumeratePulledExtensionDirs, which reads and parses the lockfile. The lockfile is small and this is I/O-cached by the OS, so the practical cost is negligible, but a single parse shared across all loaders would be cleaner.

  2. src/domain/repo/repo_service.ts:1749 — Phase 1 migration assigns newPath even when the rename was skipped (NotFound). The lockfile entry is still rewritten to newPath for a file that doesn't exist on disk.

    When Deno.rename throws NotFound (source file missing), the code catches it silently but still pushes newPath into updatedFiles. This means the lockfile path is rewritten to the new location even though the file was never actually moved there. Subsequent tooling will look for the file at the new path and not find it — which is the same outcome as if it were at the old path. Not harmful because extension install handles missing files by re-pulling, but it's semantically sloppy.

  3. src/libswamp/extensions/pull.ts:975 — Deno.mkdir(absoluteFilesDir, { recursive: true }) is called unconditionally before copyDir, even when the archive has no files/ directory. This creates an empty files/ dir in every extension's subtree.

    Harmless but produces filesystem noise. The models, workflows, etc. dirs are only created by copyDir when content exists; files is the exception.

Verdict

PASS — The code is well-structured, thoroughly tested, and handles the hard parts (migration idempotency, selective delete with abort semantics, integrity verification, shared-scope-root preservation) correctly. The gen-1 → gen-2 → current classification logic is sound, the lockfile coordination invariants hold, and the error paths are well-tested. Medium findings are about future robustness, not current correctness.

@stack72 stack72 merged commit c709f0f into main Apr 17, 2026
10 checks passed
@stack72 stack72 deleted the worktree-jiggly-napping-prism branch April 17, 2026 18:58
ianarsenault pushed a commit to ianarsenault/swamp that referenced this pull request Apr 21, 2026
…uto-resolver (systeminit#1187)

Fixes [swamp-club#121](https://swamp.club/lab/issues/121).

## Summary

`workflow validate` and other commands that trigger auto-resolution
could silently overwrite local edits to pulled extensions. The
auto-resolver adapter hardcoded `force: true` when installing a type it
failed to find locally, and `installExtension`'s conflict check was
gated on `!force` — so any user edits in the pulled directory were
`copyDir`'d over with no prompt, no warning, and no diff.

The fix adds an `isInstalled` capability to `ExtensionInstallerPort` and
has the domain service consult it before calling `install`. If the
extension is already on disk (lockfile entry **and** per-extension
directory both present), the resolver surfaces a new
`alreadyInstalledButFailed` event naming the path and the explicit
opt-in command (`swamp extension pull <name> --force`) and returns
failure rather than clobbering. Belt-and-braces: the adapter now passes
`force: false` and catches `ConflictError`, so a race past the per-type
re-entrancy guard (concurrent auto-resolves for sibling types) still
fails safely.

The datastore auto-update path in `resolve_datastore.ts:132` has a
related but distinct `force: true` bug with a different trigger and fix
shape; tracked separately as
[swamp-club#126](https://swamp.club/lab/issues/126).

## What changed

- `src/domain/extensions/extension_auto_resolver.ts` — port gains
`isInstalled` and `installedPath`; service gains
`alreadyInstalledButFailed` output event and the pre-install check in
`installAndLoad`.
- `src/cli/auto_resolver_adapters.ts` — installer adapter implements
`isInstalled` (dual lockfile + filesystem check against the
per-extension layout from systeminit#1186), `installedPath` returns the
per-extension root, `install()` flips to `force: false` and catches
`ConflictError` with a defence-in-depth comment.
- `src/presentation/renderers/extension_auto_resolve.ts` — new
`renderAutoResolveAlreadyInstalled` for both `log` and `json` modes.
- `design/extension.md` — new \"Safety: never overwrite on-disk
extensions\" subsection under Automatic Resolution.

## Test plan

- [x] `deno fmt` clean
- [x] `deno check` clean
- [x] `deno lint` clean
- [x] `deno run test` — 4400 pass / 0 fail (2 new service tests, 5 new
adapter tests, 1 new integration regression test)
- [x] Manual reproduction against `/tmp/swamp-repro-issue-121`:
- **Pre-fix:** md5 of `system_usage.ts` changed from user-edited to
pristine registry version; WIP marker comment lost
- **Post-fix:** md5 identical before/after trigger; marker preserved;
user sees three ERR lines naming the path and the `--force` recovery
command

## Follow-up

- [swamp-club#126](https://swamp.club/lab/issues/126) — datastore
auto-update `force: true` in `resolve_datastore.ts:132` (related
data-loss vector, different fix shape).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant