From 7ffbb18782c3f7bedd76ad113e919f56e920610a Mon Sep 17 00:00:00 2001 From: stack72 Date: Tue, 5 May 2026 13:54:12 +0100 Subject: [PATCH 01/15] =?UTF-8?q?feat(extensions):=20W2=20commit=201=20?= =?UTF-8?q?=E2=80=94=20add=20bundleAndIndexOne=20to=20all=205=20user=20loa?= =?UTF-8?q?ders?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit W2 of the extension catalog rearchitecture (swamp-club#231) introduces three lifecycle services that own catalog writes end-to-end. This commit lays the foundation: a public per-file entry point on each of the 5 user loaders that the lifecycle services will call during install to extract type metadata WITHOUT writing to the catalog. ## What ships `bundleAndIndexOne({ absolutePath, relativePath, baseDir })` on: - UserModelLoader — returns { kind: "model" | "extension", typeNormalized, bundlePath, fingerprint } | null - UserDriverLoader — returns { kind: "driver", ... } | null - UserVaultLoader — returns { kind: "vault", ... } | null - UserDatastoreLoader — returns { kind: "datastore", ... } | null - UserReportLoader — returns { kind: "report", ... } | null Each method does the bundle build + dynamic import + Zod schema validation that the existing private `rebundleAndUpdateCatalog` methods do, but stops there — no catalog row is written, no runtime registry binding fires. The lifecycle service is the new owner of those writes (via repository.save(extension)) so I-Repo-1 evaluates the post-save state and can fire DuplicateTypeError synchronously at install time. ## Pin 1 — load-bearing for I-Repo-1-fires-at-install The architecture-agent's pre-W2 review pinned a contract on this method: bundleAndIndexOne MUST NOT call catalog.upsert. If a future refactor sneaks an upsert back in, I-Repo-1 silently stops firing at install time — the user-visible payoff regresses without the test suite noticing. Each loader gets a regression test asserting that catalog.findAll().length is unchanged after a bundleAndIndexOne call: - UserModelLoader.bundleAndIndexOne: returns model metadata without writing catalog rows (Pin 1) - UserModelLoader.bundleAndIndexOne: returns null for files without a model/extension export - UserDriverLoader / UserVaultLoader / UserDatastoreLoader / UserReportLoader: returns metadata without writing catalog rows (Pin 1) Six new tests total. All pass. ## Why this is its own commit Plan v4 step 3 prerequisite. The InstallExtensionService (commit 2) calls these methods to build the Extension aggregate's Sources before calling repository.save(extension). Splitting this commit out keeps each subsequent W2 commit focused on one architectural concern. ## LOC budget Production code: 334 LOC across 5 loader files — within the pre-committed ≤ 400 LOC threshold for commit 1's loader-API additions. Test code: 426 LOC across 5 test files (Pin 1 regression net per loader). ## Out of scope - The existing rebundleAndUpdateCatalog methods are unchanged and still write to the catalog directly via legacyStore. Refactoring them to delegate to bundleAndIndexOne is a future cleanup (W4 KindAdapter territory) — keeping them parallel for now is the lower- risk shape and the duplication is small. - The lifecycle services themselves (InstallExtensionService etc.) ship in subsequent commits. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/domain/datastore/user_datastore_loader.ts | 64 +++++++++ .../datastore/user_datastore_loader_test.ts | 81 ++++++++++- src/domain/drivers/user_driver_loader.ts | 63 ++++++++ src/domain/drivers/user_driver_loader_test.ts | 76 +++++++++- src/domain/models/user_model_loader.ts | 77 ++++++++++ src/domain/models/user_model_loader_test.ts | 134 ++++++++++++++++++ src/domain/reports/user_report_loader.ts | 67 +++++++++ src/domain/reports/user_report_loader_test.ts | 63 +++++++- src/domain/vaults/user_vault_loader.ts | 63 ++++++++ src/domain/vaults/user_vault_loader_test.ts | 76 +++++++++- 10 files changed, 760 insertions(+), 4 deletions(-) diff --git a/src/domain/datastore/user_datastore_loader.ts b/src/domain/datastore/user_datastore_loader.ts index dba123fa..80ce20a7 100644 --- a/src/domain/datastore/user_datastore_loader.ts +++ b/src/domain/datastore/user_datastore_loader.ts @@ -693,6 +693,70 @@ export class UserDatastoreLoader { }); } + /** + * Bundles a single source file and extracts datastore type metadata + * WITHOUT writing to the catalog or registering at runtime. Returns + * `{ kind: "datastore", typeNormalized, bundlePath, fingerprint }` + * for datastore source files, or `null` for files that aren't + * datastores. + * + * **W2 contract — load-bearing for I-Repo-1.** Lifecycle services + * call this at install time and commit via `repository.save()`. The + * regression test `bundleAndIndexOne does not write catalog rows` + * pins this method's no-catalog-write contract. + * + * @throws Error on bundle build failure or schema validation failure. + */ + public async bundleAndIndexOne(args: { + absolutePath: string; + relativePath: string; + baseDir: string; + }): Promise< + | { + kind: "datastore"; + typeNormalized: string; + bundlePath: string; + fingerprint: string; + } + | null + > { + const source = await Deno.readTextFile(args.absolutePath); + if (!/export\s+const\s+datastore\s*[=:]/.test(source)) { + return null; + } + + const denoPath = await this.denoRuntime.ensureDeno(); + const js = await this.bundleWithCache( + args.absolutePath, + args.relativePath, + denoPath, + args.baseDir, + ); + const module = await this.importBundle(js, args.relativePath, args.baseDir); + if (!module.datastore) return null; + + const fingerprint = await computeSourceFingerprint( + args.absolutePath, + args.baseDir, + ); + const bundlePath = this.getDatastoreBundlePath( + args.relativePath, + args.baseDir, + ); + + const parsed = UserDatastoreSchema.safeParse(module.datastore); + if (!parsed.success) { + throw new Error(this.formatValidationError(parsed.error)); + } + + return { + kind: "datastore", + typeNormalized: parsed.data.type.toLowerCase(), + bundlePath, + fingerprint, + }; + } + /** * Rebundles a single file and updates the catalog entry. */ diff --git a/src/domain/datastore/user_datastore_loader_test.ts b/src/domain/datastore/user_datastore_loader_test.ts index e8001cfc..dbca3920 100644 --- a/src/domain/datastore/user_datastore_loader_test.ts +++ b/src/domain/datastore/user_datastore_loader_test.ts @@ -18,7 +18,7 @@ // along with Swamp. If not, see . import { assertEquals, assertNotEquals } from "@std/assert"; -import { join } from "@std/path"; +import { dirname, join } from "@std/path"; import { UserDatastoreLoader } from "./user_datastore_loader.ts"; import { DatastoreTypeRegistry, @@ -591,3 +591,82 @@ export const datastore = { await Deno.remove(datastoresDir, { recursive: true }); } }); + +// ===== Pin 1 (W2) ===== + +Deno.test( + "UserDatastoreLoader.bundleAndIndexOne: returns datastore metadata without writing catalog rows (Pin 1)", + async () => { + const ts = Date.now(); + const dsType = `@user/pin1-datastore-${ts}`; + const dsCode = ` +import { z } from "npm:zod"; + +export const datastore = { + type: "${dsType}", + name: "Pin1Datastore", + description: "pin1", + configSchema: z.object({}), + createProvider: () => ({ + createLock: () => ({ + acquire: async () => {}, + release: async () => {}, + withLock: async (fn) => fn(), + inspect: async () => null, + forceRelease: async () => false, + }), + createVerifier: () => ({ + verify: async () => ({ + healthy: true, + message: "ok", + latencyMs: 1, + datastoreType: "${dsType}", + }), + }), + createSyncStrategy: () => null, + resolvePath: (id) => id, + }), +}; +`; + + const repoDir = await Deno.makeTempDir({ prefix: "swamp_pin1_ds_r_" }); + const dsDir = await Deno.makeTempDir({ prefix: "swamp_pin1_ds_d_" }); + const dbPath = join(repoDir, ".swamp", "_extension_catalog.db"); + await Deno.mkdir(dirname(dbPath), { recursive: true }); + + try { + await Deno.writeTextFile(join(dsDir, "datastore.ts"), dsCode); + + const catalog = new ExtensionCatalogStore(dbPath); + const repository = makeRepoForCatalog(catalog, repoDir); + const loader = new UserDatastoreLoader( + new StubDenoRuntime(), + repoDir, + repository, + ); + + const before = catalog.findAll().length; + assertEquals(before, 0); + + const result = await loader.bundleAndIndexOne({ + absolutePath: join(dsDir, "datastore.ts"), + relativePath: "datastore.ts", + baseDir: dsDir, + }); + + assertEquals( + catalog.findAll().length, + before, + "Pin 1: bundleAndIndexOne must NOT write catalog rows", + ); + assertNotEquals(result, null); + assertEquals(result?.kind, "datastore"); + assertEquals(result?.typeNormalized, dsType.toLowerCase()); + + catalog.close(); + } finally { + await Deno.remove(repoDir, { recursive: true }); + await Deno.remove(dsDir, { recursive: true }); + } + }, +); diff --git a/src/domain/drivers/user_driver_loader.ts b/src/domain/drivers/user_driver_loader.ts index b712cdb6..c4821946 100644 --- a/src/domain/drivers/user_driver_loader.ts +++ b/src/domain/drivers/user_driver_loader.ts @@ -693,6 +693,69 @@ export class UserDriverLoader { }); } + /** + * Bundles a single source file and extracts driver type metadata + * WITHOUT writing to the catalog or registering at runtime. Returns + * `{ kind: "driver", typeNormalized, bundlePath, fingerprint }` for + * driver source files, or `null` for files that aren't drivers. + * + * **W2 contract — load-bearing for I-Repo-1.** Lifecycle services + * call this at install time and commit via `repository.save()`. The + * regression test `bundleAndIndexOne does not write catalog rows` + * pins this method's no-catalog-write contract. + * + * @throws Error on bundle build failure or schema validation failure. + */ + public async bundleAndIndexOne(args: { + absolutePath: string; + relativePath: string; + baseDir: string; + }): Promise< + | { + kind: "driver"; + typeNormalized: string; + bundlePath: string; + fingerprint: string; + } + | null + > { + const source = await Deno.readTextFile(args.absolutePath); + if (!/export\s+const\s+driver\s*[=:]/.test(source)) { + return null; + } + + const denoPath = await this.denoRuntime.ensureDeno(); + const js = await this.bundleWithCache( + args.absolutePath, + args.relativePath, + denoPath, + args.baseDir, + ); + const module = await this.importBundle(js, args.relativePath, args.baseDir); + if (!module.driver) return null; + + const fingerprint = await computeSourceFingerprint( + args.absolutePath, + args.baseDir, + ); + const bundlePath = this.getDriverBundlePath( + args.relativePath, + args.baseDir, + ); + + const parsed = UserDriverSchema.safeParse(module.driver); + if (!parsed.success) { + throw new Error(this.formatValidationError(parsed.error)); + } + + return { + kind: "driver", + typeNormalized: parsed.data.type.toLowerCase(), + bundlePath, + fingerprint, + }; + } + /** * Rebundles a single file and updates the catalog entry. */ diff --git a/src/domain/drivers/user_driver_loader_test.ts b/src/domain/drivers/user_driver_loader_test.ts index 3d1f2264..363a2f82 100644 --- a/src/domain/drivers/user_driver_loader_test.ts +++ b/src/domain/drivers/user_driver_loader_test.ts @@ -18,7 +18,7 @@ // along with Swamp. If not, see . import { assertEquals, assertNotEquals } from "@std/assert"; -import { join } from "@std/path"; +import { dirname, join } from "@std/path"; import { UserDriverLoader } from "./user_driver_loader.ts"; import { driverTypeRegistry } from "./driver_type_registry.ts"; import { bundleNamespace } from "../../infrastructure/persistence/paths.ts"; @@ -306,3 +306,77 @@ export const driver = { await Deno.remove(driversDir, { recursive: true }); } }); + +// ===== Pin 1 (W2) ===== +// +// `bundleAndIndexOne` is the public per-file entry point that +// InstallExtensionService calls during install. The lifecycle service +// owns the catalog write via `repository.save()`; the loader's per-file +// method MUST NOT write directly. If a future refactor sneaks a +// `catalog.upsert` back into this path, I-Repo-1 silently stops firing +// at install time. This test is the regression net. + +Deno.test( + "UserDriverLoader.bundleAndIndexOne: returns driver metadata without writing catalog rows (Pin 1)", + async () => { + const ts = Date.now(); + const driverType = `@user/pin1-driver-${ts}`; + const driverCode = ` +export const driver = { + type: "${driverType}", + name: "Pin1Driver", + description: "pin1", + createDriver: (_config) => ({ + async executeMethod() { + return { dataHandles: [], ok: true }; + }, + }), +}; +`; + + const repoDir = await Deno.makeTempDir({ prefix: "swamp_pin1_driver_r_" }); + const driversDir = await Deno.makeTempDir({ + prefix: "swamp_pin1_driver_d_", + }); + const dbPath = join(repoDir, ".swamp", "_extension_catalog.db"); + await Deno.mkdir(dirname(dbPath), { recursive: true }); + + try { + await Deno.writeTextFile(join(driversDir, "driver.ts"), driverCode); + + const catalog = new ExtensionCatalogStore(dbPath); + const repository = makeRepoForCatalog(catalog, repoDir); + const loader = new UserDriverLoader( + testDenoRuntime, + repoDir, + undefined, + repository, + ); + + const before = catalog.findAll().length; + assertEquals(before, 0, "test pre-condition: catalog empty"); + + const result = await loader.bundleAndIndexOne({ + absolutePath: join(driversDir, "driver.ts"), + relativePath: "driver.ts", + baseDir: driversDir, + }); + + // Pin 1: catalog row count must be unchanged. + assertEquals( + catalog.findAll().length, + before, + "Pin 1: bundleAndIndexOne must NOT write catalog rows", + ); + + assertNotEquals(result, null); + assertEquals(result?.kind, "driver"); + assertEquals(result?.typeNormalized, driverType.toLowerCase()); + + catalog.close(); + } finally { + await Deno.remove(repoDir, { recursive: true }); + await Deno.remove(driversDir, { recursive: true }); + } + }, +); diff --git a/src/domain/models/user_model_loader.ts b/src/domain/models/user_model_loader.ts index 5ef6935e..ca1f8026 100644 --- a/src/domain/models/user_model_loader.ts +++ b/src/domain/models/user_model_loader.ts @@ -1123,6 +1123,83 @@ export class UserModelLoader { }); } + /** + * Bundles a single source file and extracts its registered type metadata + * WITHOUT writing to the catalog or registering at runtime. Returns + * `{ kind, typeNormalized, bundlePath, fingerprint }` for model and + * extension files, or `null` for files that aren't model/extensions. + * + * **W2 contract — load-bearing for I-Repo-1.** The lifecycle services + * (InstallExtensionService etc.) call this to build the Extension + * aggregate's Sources at install time, then call + * `repository.save(extension)` to commit. If this method ever calls + * `catalog.upsert` directly, I-Repo-1 silently stops firing at install + * time — the unit test + * `bundleAndIndexOne does not write catalog rows` is the regression + * net for that class of bug. + * + * @throws Error on bundle build failure or schema validation failure. + */ + public async bundleAndIndexOne(args: { + absolutePath: string; + relativePath: string; + baseDir: string; + }): Promise< + | { + kind: "model" | "extension"; + typeNormalized: string; + bundlePath: string; + fingerprint: string; + } + | null + > { + const source = await Deno.readTextFile(args.absolutePath); + if (!/export\s+const\s+(model|extension)\s*[=:]/.test(source)) { + return null; + } + + const denoPath = await this.denoRuntime.ensureDeno(); + const js = await this.bundleWithCache( + args.absolutePath, + args.relativePath, + denoPath, + args.baseDir, + ); + const module = await this.importBundle(js, args.relativePath, args.baseDir); + const fingerprint = await computeSourceFingerprint( + args.absolutePath, + args.baseDir, + ); + + if (module.model) { + const parsed = UserModelSchema.safeParse(module.model); + if (!parsed.success) { + throw new Error(formatUserModelError(parsed.error)); + } + return { + kind: "model", + typeNormalized: ModelType.create(parsed.data.type).normalized, + bundlePath: this.getBundlePath(args.relativePath, args.baseDir), + fingerprint, + }; + } + + if (module.extension) { + const parsed = UserExtensionSchema.safeParse(module.extension); + if (!parsed.success) { + throw new Error(parsed.error.message); + } + return { + kind: "extension", + typeNormalized: ModelType.create(parsed.data.type).normalized, + bundlePath: this.getBundlePath(args.relativePath, args.baseDir), + fingerprint, + }; + } + + return null; + } + /** * Rebundles a single file and updates the catalog entry. */ diff --git a/src/domain/models/user_model_loader_test.ts b/src/domain/models/user_model_loader_test.ts index 72c5659b..81d4c093 100644 --- a/src/domain/models/user_model_loader_test.ts +++ b/src/domain/models/user_model_loader_test.ts @@ -3575,3 +3575,137 @@ export const model = { await Deno.remove(modelsDir, { recursive: true }); } }); + +// ===== Pin 1 (W2) ===== +// +// `bundleAndIndexOne` is the public per-file entry point that +// InstallExtensionService calls during install to build the Extension +// aggregate's Sources. The whole point of moving type-extraction +// synchronously to install time is that I-Repo-1 fires when the +// lifecycle service calls `repository.save(extension)` — NOT when the +// loader sneaks a row in via `catalog.upsert`. If a future refactor +// inadvertently re-adds an upsert to this path, I-Repo-1 silently stops +// firing at install time. The next two tests are the regression net. + +Deno.test( + "UserModelLoader.bundleAndIndexOne: returns model metadata without writing catalog rows (Pin 1)", + async () => { + const ts = Date.now(); + const typeId = `@user/pin1-model-${ts}`; + const modelCode = ` +import { z } from "npm:zod@4"; + +export const model = { + type: "${typeId}", + version: "2026.05.05.1", + globalArguments: z.object({}), + resources: { + "data": { + description: "x", + schema: z.object({}), + lifetime: "infinite", + garbageCollection: 1, + }, + }, + methods: { + noop: { + description: "noop", + arguments: z.object({}), + execute: async () => ({ dataHandles: [] }), + }, + }, +}; +`; + + const repoDir = await Deno.makeTempDir({ prefix: "swamp_pin1_model_r_" }); + const modelsDir = await Deno.makeTempDir({ prefix: "swamp_pin1_model_m_" }); + const dbPath = join(repoDir, ".swamp", "_extension_catalog.db"); + await Deno.mkdir(dirname(dbPath), { recursive: true }); + + try { + await Deno.writeTextFile(join(modelsDir, "noop.ts"), modelCode); + + const catalog = new ExtensionCatalogStore(dbPath); + const repository = makeRepoForCatalog(catalog, repoDir); + const loader = new UserModelLoader( + testDenoRuntime, + repoDir, + undefined, + repository, + ); + + const before = catalog.findAll().length; + assertEquals(before, 0, "test pre-condition: catalog empty"); + + const result = await loader.bundleAndIndexOne({ + absolutePath: join(modelsDir, "noop.ts"), + relativePath: "noop.ts", + baseDir: modelsDir, + }); + + // Pin 1: catalog row count must be unchanged. + const after = catalog.findAll().length; + assertEquals( + after, + before, + "Pin 1: bundleAndIndexOne must NOT write catalog rows", + ); + + // Returns the metadata the lifecycle service needs. + assertNotEquals(result, null); + assertEquals(result?.kind, "model"); + assertEquals(result?.typeNormalized, typeId.toLowerCase()); + assertEquals(typeof result?.bundlePath, "string"); + assertEquals(typeof result?.fingerprint, "string"); + + catalog.close(); + } finally { + await Deno.remove(repoDir, { recursive: true }); + await Deno.remove(modelsDir, { recursive: true }); + } + }, +); + +Deno.test( + "UserModelLoader.bundleAndIndexOne: returns null for files without a model/extension export", + async () => { + const repoDir = await Deno.makeTempDir({ prefix: "swamp_pin1_null_r_" }); + const modelsDir = await Deno.makeTempDir({ prefix: "swamp_pin1_null_m_" }); + const dbPath = join(repoDir, ".swamp", "_extension_catalog.db"); + await Deno.mkdir(dirname(dbPath), { recursive: true }); + + try { + await Deno.writeTextFile( + join(modelsDir, "helper.ts"), + `export const greeting = "hello";`, + ); + + const catalog = new ExtensionCatalogStore(dbPath); + const repository = makeRepoForCatalog(catalog, repoDir); + const loader = new UserModelLoader( + testDenoRuntime, + repoDir, + undefined, + repository, + ); + + const result = await loader.bundleAndIndexOne({ + absolutePath: join(modelsDir, "helper.ts"), + relativePath: "helper.ts", + baseDir: modelsDir, + }); + + assertEquals(result, null); + assertEquals( + catalog.findAll().length, + 0, + "Pin 1: null-return path must also not write catalog rows", + ); + + catalog.close(); + } finally { + await Deno.remove(repoDir, { recursive: true }); + await Deno.remove(modelsDir, { recursive: true }); + } + }, +); diff --git a/src/domain/reports/user_report_loader.ts b/src/domain/reports/user_report_loader.ts index 99adeadf..db66a540 100644 --- a/src/domain/reports/user_report_loader.ts +++ b/src/domain/reports/user_report_loader.ts @@ -539,6 +539,73 @@ export class UserReportLoader { }); } + /** + * Bundles a single source file and extracts report metadata WITHOUT + * writing to the catalog or registering at runtime. Returns + * `{ kind: "report", typeNormalized, bundlePath, fingerprint }` for + * report source files, or `null` for files that aren't reports. + * + * **W2 contract — load-bearing for I-Repo-1.** Lifecycle services + * call this at install time and commit via `repository.save()`. The + * regression test `bundleAndIndexOne does not write catalog rows` + * pins this method's no-catalog-write contract. + * + * Reports use `name` (not `type`) as the registry identity, so + * `typeNormalized` is derived from `parsed.data.name.toLowerCase()` + * to match the existing catalog row shape. + * + * @throws Error on bundle build failure or schema validation failure. + */ + public async bundleAndIndexOne(args: { + absolutePath: string; + relativePath: string; + baseDir: string; + }): Promise< + | { + kind: "report"; + typeNormalized: string; + bundlePath: string; + fingerprint: string; + } + | null + > { + const source = await Deno.readTextFile(args.absolutePath); + if (!/export\s+const\s+report\s*[=:]/.test(source)) { + return null; + } + + const denoPath = await this.denoRuntime.ensureDeno(); + const js = await this.bundleWithCache( + args.absolutePath, + args.relativePath, + denoPath, + args.baseDir, + ); + const module = await this.importBundle(js, args.relativePath, args.baseDir); + if (!module.report) return null; + + const fingerprint = await computeSourceFingerprint( + args.absolutePath, + args.baseDir, + ); + const bundlePath = this.getReportBundlePath( + args.relativePath, + args.baseDir, + ); + + const parsed = UserReportSchema.safeParse(module.report); + if (!parsed.success) { + throw new Error(this.formatValidationError(parsed.error)); + } + + return { + kind: "report", + typeNormalized: parsed.data.name.toLowerCase(), + bundlePath, + fingerprint, + }; + } + /** * Rebundles a single file and updates the catalog entry. */ diff --git a/src/domain/reports/user_report_loader_test.ts b/src/domain/reports/user_report_loader_test.ts index 2b89b58d..c538f2b2 100644 --- a/src/domain/reports/user_report_loader_test.ts +++ b/src/domain/reports/user_report_loader_test.ts @@ -18,7 +18,7 @@ // along with Swamp. If not, see . import { assertEquals, assertNotEquals } from "@std/assert"; -import { join } from "@std/path"; +import { dirname, join } from "@std/path"; import { UserReportLoader } from "./user_report_loader.ts"; import { reportRegistry } from "./report_registry.ts"; import { bundleNamespace } from "../../infrastructure/persistence/paths.ts"; @@ -320,3 +320,64 @@ export const report = { await Deno.remove(reportsDir, { recursive: true }); } }); + +// ===== Pin 1 (W2) ===== + +Deno.test( + "UserReportLoader.bundleAndIndexOne: returns report metadata without writing catalog rows (Pin 1)", + async () => { + const ts = Date.now(); + const reportName = `@user/pin1-report-${ts}`; + const reportCode = ` +export const report = { + name: "${reportName}", + description: "pin1", + scope: "method", + execute: async (_ctx) => ({ markdown: "ok", json: {} }), +}; +`; + + const repoDir = await Deno.makeTempDir({ prefix: "swamp_pin1_report_r_" }); + const reportsDir = await Deno.makeTempDir({ + prefix: "swamp_pin1_report_d_", + }); + const dbPath = join(repoDir, ".swamp", "_extension_catalog.db"); + await Deno.mkdir(dirname(dbPath), { recursive: true }); + + try { + await Deno.writeTextFile(join(reportsDir, "report.ts"), reportCode); + + const catalog = new ExtensionCatalogStore(dbPath); + const repository = makeRepoForCatalog(catalog, repoDir); + const loader = new UserReportLoader( + testDenoRuntime, + repoDir, + undefined, + repository, + ); + + const before = catalog.findAll().length; + assertEquals(before, 0); + + const result = await loader.bundleAndIndexOne({ + absolutePath: join(reportsDir, "report.ts"), + relativePath: "report.ts", + baseDir: reportsDir, + }); + + assertEquals( + catalog.findAll().length, + before, + "Pin 1: bundleAndIndexOne must NOT write catalog rows", + ); + assertNotEquals(result, null); + assertEquals(result?.kind, "report"); + assertEquals(result?.typeNormalized, reportName.toLowerCase()); + + catalog.close(); + } finally { + await Deno.remove(repoDir, { recursive: true }); + await Deno.remove(reportsDir, { recursive: true }); + } + }, +); diff --git a/src/domain/vaults/user_vault_loader.ts b/src/domain/vaults/user_vault_loader.ts index 8ec5cca2..8753b38f 100644 --- a/src/domain/vaults/user_vault_loader.ts +++ b/src/domain/vaults/user_vault_loader.ts @@ -702,6 +702,69 @@ export class UserVaultLoader { }); } + /** + * Bundles a single source file and extracts vault type metadata + * WITHOUT writing to the catalog or registering at runtime. Returns + * `{ kind: "vault", typeNormalized, bundlePath, fingerprint }` for + * vault source files, or `null` for files that aren't vaults. + * + * **W2 contract — load-bearing for I-Repo-1.** Lifecycle services + * call this at install time and commit via `repository.save()`. The + * regression test `bundleAndIndexOne does not write catalog rows` + * pins this method's no-catalog-write contract. + * + * @throws Error on bundle build failure or schema validation failure. + */ + public async bundleAndIndexOne(args: { + absolutePath: string; + relativePath: string; + baseDir: string; + }): Promise< + | { + kind: "vault"; + typeNormalized: string; + bundlePath: string; + fingerprint: string; + } + | null + > { + const source = await Deno.readTextFile(args.absolutePath); + if (!/export\s+const\s+vault\s*[=:]/.test(source)) { + return null; + } + + const denoPath = await this.denoRuntime.ensureDeno(); + const js = await this.bundleWithCache( + args.absolutePath, + args.relativePath, + denoPath, + args.baseDir, + ); + const module = await this.importBundle(js, args.relativePath, args.baseDir); + if (!module.vault) return null; + + const fingerprint = await computeSourceFingerprint( + args.absolutePath, + args.baseDir, + ); + const bundlePath = this.getVaultBundlePath( + args.relativePath, + args.baseDir, + ); + + const parsed = UserVaultSchema.safeParse(module.vault); + if (!parsed.success) { + throw new Error(this.formatValidationError(parsed.error)); + } + + return { + kind: "vault", + typeNormalized: parsed.data.type.toLowerCase(), + bundlePath, + fingerprint, + }; + } + /** * Rebundles a single file and updates the catalog entry. */ diff --git a/src/domain/vaults/user_vault_loader_test.ts b/src/domain/vaults/user_vault_loader_test.ts index cb82fcb1..df994c96 100644 --- a/src/domain/vaults/user_vault_loader_test.ts +++ b/src/domain/vaults/user_vault_loader_test.ts @@ -18,7 +18,7 @@ // along with Swamp. If not, see . import { assertEquals, assertNotEquals } from "@std/assert"; -import { join } from "@std/path"; +import { dirname, join } from "@std/path"; import { UserVaultLoader } from "./user_vault_loader.ts"; import { VaultTypeRegistry, vaultTypeRegistry } from "./vault_type_registry.ts"; import { bundleNamespace } from "../../infrastructure/persistence/paths.ts"; @@ -636,3 +636,77 @@ export const vault = { await Deno.remove(vaultsDir, { recursive: true }); } }); + +// ===== Pin 1 (W2) ===== +// +// `bundleAndIndexOne` is the public per-file entry point that +// InstallExtensionService calls during install. The lifecycle service +// owns the catalog write via `repository.save()`; the loader's per-file +// method MUST NOT write directly. This test is the regression net. + +Deno.test( + "UserVaultLoader.bundleAndIndexOne: returns vault metadata without writing catalog rows (Pin 1)", + async () => { + const ts = Date.now(); + const vaultType = `@user/pin1-vault-${ts}`; + const vaultCode = ` +import { z } from "npm:zod"; + +export const vault = { + type: "${vaultType}", + name: "Pin1Vault", + description: "pin1", + configSchema: z.object({}), + createProvider: (name) => ({ + get: async () => null, + put: async () => {}, + list: async () => [], + getName: () => name, + }), +}; +`; + + const repoDir = await Deno.makeTempDir({ prefix: "swamp_pin1_vault_r_" }); + const vaultsDir = await Deno.makeTempDir({ prefix: "swamp_pin1_vault_v_" }); + const dbPath = join(repoDir, ".swamp", "_extension_catalog.db"); + await Deno.mkdir(dirname(dbPath), { recursive: true }); + + try { + await Deno.writeTextFile(join(vaultsDir, "vault.ts"), vaultCode); + + const catalog = new ExtensionCatalogStore(dbPath); + const repository = makeRepoForCatalog(catalog, repoDir); + const loader = new UserVaultLoader( + new StubDenoRuntime(), + repoDir, + undefined, + repository, + ); + + const before = catalog.findAll().length; + assertEquals(before, 0, "test pre-condition: catalog empty"); + + const result = await loader.bundleAndIndexOne({ + absolutePath: join(vaultsDir, "vault.ts"), + relativePath: "vault.ts", + baseDir: vaultsDir, + }); + + // Pin 1: catalog row count must be unchanged. + assertEquals( + catalog.findAll().length, + before, + "Pin 1: bundleAndIndexOne must NOT write catalog rows", + ); + + assertNotEquals(result, null); + assertEquals(result?.kind, "vault"); + assertEquals(result?.typeNormalized, vaultType.toLowerCase()); + + catalog.close(); + } finally { + await Deno.remove(repoDir, { recursive: true }); + await Deno.remove(vaultsDir, { recursive: true }); + } + }, +); From f98985ef8b70029dcdccef583db74588dbf79e45 Mon Sep 17 00:00:00 2001 From: stack72 Date: Tue, 5 May 2026 14:05:36 +0100 Subject: [PATCH 02/15] =?UTF-8?q?feat(extensions):=20W2=20commit=202a=20?= =?UTF-8?q?=E2=80=94=20installExtensionFn=20test=20seam=20+=20Pin=202=20ev?= =?UTF-8?q?ent-stream=20baseline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit W2 of the extension catalog rearchitecture (swamp-club#231). This is the first of three commits implementing plan v4 step 3+4 KEEP-side (InstallExtensionService + pull.ts migration + Pin 2 regression test). ## What ships 1. **Test seam on `ExtensionPullDeps`.** New optional `installExtensionFn?: (ref, ctx) => Promise` field. Defaults to the real `installExtension` from this module; `extensionPull` falls back automatically when unset, so production callers are unaffected. Tests inject a stub to drive the `ExtensionPullEvent` stream without a real registry, tarball, or filesystem write. 2. **Pin 2 event-stream regression tests** in `pull_test.ts`. Three tests lock in the current shape: - `installing → completed` for a successful install - `installing → orphans-pruned → completed` when prior version files were pruned - `installing` only when the install short-circuits (alreadyPulled) Each test asserts the exact event-kind sequence AND the structural shape of the payload (key fields present with expected values). ## Why a separate commit Plan v4 commit 2 is the architecturally heavy piece (service skeleton + pull.ts migration + phase 8 type extraction). The architecture-agent's Pin 2 review demanded a regression test that asserts BYTE-IDENTICALITY of the event stream pre/post refactor. The natural shape: capture current behavior FIRST (this commit), refactor in subsequent commits, watch the test stay green. If the test ever fails during 2b/2c, that's the canary the architecture- agent designed it to be — the inner refactor leaked into the event payload. ## What's next - 2b: Create `InstallExtensionService` class and migrate `pull.ts:installExtension` body into it. Pure refactor; Pin 2 test must remain green. - 2c: Add phase 8 — walk the extracted per-extension subtree, call each loader's `bundleAndIndexOne`, build the `Extension` aggregate, call `repository.save(extension)` with FS rollback on `DuplicateTypeError`. The architectural payoff that activates I-Repo-1 at install time. Test plan: - [x] deno check, lint, fmt clean - [x] deno run test — 5420 passed (3 new), 0 failed Co-Authored-By: Claude Opus 4.7 (1M context) --- src/libswamp/extensions/pull.ts | 15 ++- src/libswamp/extensions/pull_test.ts | 162 +++++++++++++++++++++++++++ 2 files changed, 176 insertions(+), 1 deletion(-) diff --git a/src/libswamp/extensions/pull.ts b/src/libswamp/extensions/pull.ts index 6712d8e1..bfe89990 100644 --- a/src/libswamp/extensions/pull.ts +++ b/src/libswamp/extensions/pull.ts @@ -199,6 +199,18 @@ export interface ExtensionPullDeps { repoDir: string; alreadyPulled: Set; depth: number; + /** + * Test seam (W2 Pin 2). Defaults to the real {@link installExtension} + * from this module; tests inject a stub so the + * {@link ExtensionPullEvent} stream can be exercised without a real + * registry, tarball, or filesystem write. Production callers always + * leave this unset — `extensionPull` falls back to the real + * `installExtension` automatically. + */ + installExtensionFn?: ( + ref: ExtensionRef, + ctx: InstallContext, + ) => Promise; } /** @@ -1126,7 +1138,8 @@ export async function* extensionPull( }; // Let ConflictError propagate — CLI catches it for the two-phase prompt flow - const result = await installExtension(input.ref, installCtx); + const install = deps.installExtensionFn ?? installExtension; + const result = await install(input.ref, installCtx); if (result) { if (result.pruned.length > 0) { yield { diff --git a/src/libswamp/extensions/pull_test.ts b/src/libswamp/extensions/pull_test.ts index 65eb65b5..2be6297b 100644 --- a/src/libswamp/extensions/pull_test.ts +++ b/src/libswamp/extensions/pull_test.ts @@ -22,9 +22,16 @@ import { assertStringIncludes } from "@std/assert/string-includes"; import { join } from "@std/path"; import { computeOrphanDiff, + extensionPull, + type ExtensionPullDeps, + type ExtensionPullEvent, + type ExtensionRef, + type InstallContext, + type InstallResult, parseExtensionRef, validateExtensionName, } from "./pull.ts"; +import { createLibSwampContext } from "../context.ts"; import { LockfileRepository } from "../../infrastructure/persistence/lockfile_repository.ts"; import { UserError } from "../../domain/errors.ts"; @@ -165,3 +172,158 @@ Deno.test( assertEquals(computeOrphanDiff(oldFiles, extractedFiles), ["c.ts", "b.ts"]); }, ); + +// ===== Pin 2 (W2) ===== +// +// `extensionPull` is one of the 5 KEEP callsites — its +// {@link ExtensionPullEvent} stream API is consumed directly by +// renderers in `presentation/renderers/extension_pull.ts`. Plan v4 +// asserts that W2's internal refactor (pull.ts → InstallExtensionService) +// preserves this stream byte-identically. The architecture-agent's Pin 2 +// review demanded a regression test that locks in the current shape so +// the W2 refactor cannot quietly leak through the event payload. +// +// This test captures the pre-W2 event sequence + structural shape. As +// commits 2b/2c land (service refactor + phase 8), the test must keep +// passing — that's the proof of byte-identicality. + +function makeStubInstallResult( + ref: ExtensionRef, + pruned: string[] = [], +): InstallResult { + return { + name: ref.name, + version: ref.version ?? "1.0.0", + description: undefined, + extractedFiles: [`.swamp/pulled-extensions/${ref.name}/models/main.ts`], + integrityStatus: "verified", + repository: undefined, + platforms: [], + safetyWarnings: [], + conflicts: [], + missingSourceFiles: [], + hasSkills: false, + hasSkillScripts: false, + skillFiles: [], + dependencyResults: [], + pruned, + }; +} + +async function makeStubDeps( + installFn: ( + ref: ExtensionRef, + ctx: InstallContext, + ) => Promise, +): Promise { + const tmpDir = await Deno.makeTempDir({ + prefix: "swamp_pull_pin2_test_", + }); + return { + getExtension: () => + Promise.resolve({ + name: "@stub/ext", + description: "stub", + latestVersion: "1.0.0", + }), + downloadArchive: () => Promise.reject(new Error("stubbed")), + getChecksum: () => Promise.resolve(null), + lockfileRepository: await LockfileRepository.create( + join(tmpDir, "upstream_extensions.json"), + ), + skillsDir: join(tmpDir, "skills"), + repoDir: tmpDir, + alreadyPulled: new Set(), + depth: 0, + installExtensionFn: installFn, + }; +} + +async function collectEvents( + gen: AsyncIterable, +): Promise { + const events: ExtensionPullEvent[] = []; + for await (const event of gen) { + events.push(event); + } + return events; +} + +Deno.test( + "extensionPull: emits installing → completed for a successful install (Pin 2 baseline)", + async () => { + const ref: ExtensionRef = { name: "@stub/ext", version: "1.0.0" }; + const deps = await makeStubDeps(() => + Promise.resolve(makeStubInstallResult(ref)) + ); + + const events = await collectEvents( + extensionPull(createLibSwampContext(), deps, { ref, force: false }), + ); + + // Lock in the exact event-kind sequence consumed by renderers. + assertEquals(events.length, 2); + assertEquals(events[0].kind, "installing"); + assertEquals(events[1].kind, "completed"); + + // Lock in the structural shape of the completed event's payload. + if (events[1].kind === "completed") { + assertEquals(events[1].data.name, "@stub/ext"); + assertEquals(events[1].data.version, "1.0.0"); + assertEquals(events[1].data.integrityStatus, "verified"); + assertEquals(events[1].data.pruned, []); + } + + await Deno.remove(deps.repoDir, { recursive: true }); + }, +); + +Deno.test( + "extensionPull: emits installing → orphans-pruned → completed when prior version files are pruned (Pin 2 baseline)", + async () => { + const ref: ExtensionRef = { name: "@stub/ext", version: "2.0.0" }; + const prunedPaths = [ + ".swamp/pulled-extensions/@stub/ext/models/old.ts", + ]; + const deps = await makeStubDeps(() => + Promise.resolve(makeStubInstallResult(ref, prunedPaths)) + ); + + const events = await collectEvents( + extensionPull(createLibSwampContext(), deps, { ref, force: false }), + ); + + assertEquals(events.length, 3); + assertEquals(events[0].kind, "installing"); + assertEquals(events[1].kind, "orphans-pruned"); + assertEquals(events[2].kind, "completed"); + + if (events[1].kind === "orphans-pruned") { + assertEquals(events[1].name, "@stub/ext"); + assertEquals(events[1].version, "2.0.0"); + assertEquals(events[1].paths, prunedPaths); + } + + await Deno.remove(deps.repoDir, { recursive: true }); + }, +); + +Deno.test( + "extensionPull: emits only installing when install short-circuits (alreadyPulled)", + async () => { + // The real installExtension returns undefined when ref.name is in + // alreadyPulled. The generator must NOT yield orphans-pruned or + // completed in that case — only `installing`. + const ref: ExtensionRef = { name: "@stub/already-pulled", version: null }; + const deps = await makeStubDeps(() => Promise.resolve(undefined)); + + const events = await collectEvents( + extensionPull(createLibSwampContext(), deps, { ref, force: false }), + ); + + assertEquals(events.length, 1); + assertEquals(events[0].kind, "installing"); + + await Deno.remove(deps.repoDir, { recursive: true }); + }, +); From d0a2ab08f1b7031d231b44b99c39f740ec4e2dcc Mon Sep 17 00:00:00 2001 From: stack72 Date: Tue, 5 May 2026 14:10:11 +0100 Subject: [PATCH 03/15] =?UTF-8?q?feat(extensions):=20W2=20commit=202b=20?= =?UTF-8?q?=E2=80=94=20InstallExtensionService=20skeleton=20+=20extensionP?= =?UTF-8?q?ull=20routing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit W2 of the extension catalog rearchitecture (swamp-club#231). Second of three commits implementing plan v4 step 3+4 KEEP-side (service + pull.ts wrapper migration). This is a pure refactor — Pin 2 event- stream regression test (added in 2a) verifies byte-identicality. ## What ships 1. **`src/libswamp/extensions/install_extension_service.ts`** — new file with the `InstallExtensionService` class. Single public method `execute(ref, ctx)` that delegates to the existing `installExtension` free function unchanged. The service is the architectural boundary the W2 lifecycle owns; phase 8 (synchronous type extraction + `repository.save` + FS rollback on `DuplicateTypeError`) lands in commit 2c. 2. **`extensionPull` routes through the service.** The default fallback for `deps.installExtensionFn` switches from the bare `installExtension` free function to `(ref, ctx) => new InstallExtensionService().execute(ref, ctx)`. Tests still inject their own stub via `installExtensionFn`. ## Verification — Pin 2 holds The architecture-agent's Pin 2 contract requires byte-identical event stream pre/post the W2 refactor. The 3 Pin 2 baseline tests added in 2a all pass after this commit: - `extensionPull: emits installing → completed for a successful install` - `extensionPull: emits installing → orphans-pruned → completed when prior version files are pruned` - `extensionPull: emits only installing when install short-circuits (alreadyPulled)` If 2c (phase 8) ever inadvertently changes the event payload, these tests catch it before it leaks to renderers. ## What's next - 2c: Add phase 8 to `InstallExtensionService.execute()` — bundleAndIndexOne walk per kind, build Extension aggregate, repository.save with FS rollback on DuplicateTypeError. The architectural payoff that activates I-Repo-1 at install time. Test plan: - [x] deno check, lint, fmt clean - [x] deno run test — 5420 passed, 0 failed (Pin 2 baseline holds) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../extensions/install_extension_service.ts | 63 +++++++++++++++++++ src/libswamp/extensions/pull.ts | 10 ++- 2 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 src/libswamp/extensions/install_extension_service.ts diff --git a/src/libswamp/extensions/install_extension_service.ts b/src/libswamp/extensions/install_extension_service.ts new file mode 100644 index 00000000..23869ec5 --- /dev/null +++ b/src/libswamp/extensions/install_extension_service.ts @@ -0,0 +1,63 @@ +// Swamp, an Automation Framework +// Copyright (C) 2026 System Initiative, Inc. +// +// This file is part of Swamp. +// +// Swamp is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation, with the Swamp +// Extension and Definition Exception (found in the "COPYING-EXCEPTION" +// file). +// +// Swamp is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with Swamp. If not, see . + +import { + type ExtensionRef, + type InstallContext, + installExtension, + type InstallResult, +} from "./pull.ts"; + +/** + * W2 lifecycle service for installing a single extension. **Owns the + * catalog write surface** end-to-end: filesystem mutations + lockfile + * write + (commit 2c) Extension-aggregate construction + + * `repository.save(extension)` with FS rollback on + * `DuplicateTypeError`. + * + * **Asymmetric ordering with `RemoveExtensionService`.** Install is + * filesystem → lockfile → catalog. Remove is the inverse (catalog → + * lockfile → filesystem). Pinned in plan v4 challenge #3. + * + * **Snapshot semantics inherited from `InstallContext`.** The + * `lockfileRepository` on the context captures a snapshot at + * construction. Single-use only — see {@link InstallContext} JSDoc. + * + * **Current shape (commit 2b).** This is the service skeleton. It + * delegates to the existing `installExtension` free function unchanged; + * the architectural payoff (phase 8: synchronous type extraction + + * `repository.save`) lands in commit 2c. Tests verify event-stream + * byte-identicality across this skeleton refactor (Pin 2). + */ +export class InstallExtensionService { + /** + * Installs `ref` using `ctx`. Returns the {@link InstallResult} on a + * fresh install, or `undefined` when the install short-circuited + * (alreadyPulled). Throws `ConflictError` when files would be + * overwritten and `ctx.force` is false. Recursively installs + * dependencies via the same service instance (commit 2c will route + * recursion through `this.execute` so phase 8 applies to each dep). + */ + async execute( + ref: ExtensionRef, + ctx: InstallContext, + ): Promise { + return await installExtension(ref, ctx); + } +} diff --git a/src/libswamp/extensions/pull.ts b/src/libswamp/extensions/pull.ts index bfe89990..8b9b7e28 100644 --- a/src/libswamp/extensions/pull.ts +++ b/src/libswamp/extensions/pull.ts @@ -31,6 +31,7 @@ import { analyzeExtensionSafety } from "../../domain/extensions/extension_safety import { ExtensionApiClient } from "../../infrastructure/http/extension_api_client.ts"; import { pruneOrphanFiles } from "../../infrastructure/persistence/directory_cleanup.ts"; import { LockfileRepository } from "../../infrastructure/persistence/lockfile_repository.ts"; +import { InstallExtensionService } from "./install_extension_service.ts"; import { bundleNamespace, swampPath, @@ -1137,8 +1138,13 @@ export async function* extensionPull( depth: deps.depth, }; - // Let ConflictError propagate — CLI catches it for the two-phase prompt flow - const install = deps.installExtensionFn ?? installExtension; + // Let ConflictError propagate — CLI catches it for the two-phase prompt flow. + // Production path goes through InstallExtensionService so the W2 catalog- + // write contract (commit 2c phase 8) applies. Tests inject their own + // stub via deps.installExtensionFn. + const install = deps.installExtensionFn ?? + ((r: ExtensionRef, c: InstallContext) => + new InstallExtensionService().execute(r, c)); const result = await install(input.ref, installCtx); if (result) { if (result.pruned.length > 0) { From 1e7f8c5bf731db9fb55dec167b9193709dc27a9f Mon Sep 17 00:00:00 2001 From: stack72 Date: Tue, 5 May 2026 14:28:03 +0100 Subject: [PATCH 04/15] =?UTF-8?q?feat(extensions):=20W2=20commit=202c=20?= =?UTF-8?q?=E2=80=94=20phase=208=20type=20extraction=20+=20repository.save?= =?UTF-8?q?=20+=20FS=20rollback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit W2 of the extension catalog rearchitecture (swamp-club#231). Third of three commits implementing plan v4 step 3+4 KEEP-side. **This commit delivers the architectural payoff for W2** — I-Repo-1 fires synchronously at install time. ## What ships 1. **`InstallExtensionService.execute()` runs phase 8.** After the wrapped install completes (filesystem write, lockfile write), the service walks the per-extension subtree on disk and builds the `Extension` aggregate's Sources by calling each loader's `bundleAndIndexOne` (Pin 1 contract). Then commits via `repository.saveAll([topLevelExt, ...freshlyInstalledDeps])` so I-Repo-1 evaluates against the post-save state across the entire install operation as a unit. 2. **FS rollback on `DuplicateTypeError`** — the architecture-agent's "expensive miss #2". SQLite ROLLBACK does not undo filesystem mutations. The service catches `DuplicateTypeError` from `saveAll`, deletes every just-installed file (top-level + deps) via the `extractedFiles` lists, restores the lockfile to its pre-install state, and rethrows as a `UserError` that names both conflicting extensions. 3. **Service constructor takes `denoRuntime` + `repository`** — the deps phase 8 needs (loaders construct lazily per kind directory). Optional `installExtensionFn` test seam mirrors `ExtensionPullDeps`'s pattern; lets phase 8 be exercised against a pre-staged on-disk subtree without driving a real registry/tarball. 4. **`installZodGlobal()` added to all 5 loaders' `bundleAndIndexOne`** so the bundle-import path runs cold-start-safe outside the `loadModels` / `buildIndex` entry points. 5. **Pull.ts wiring.** `ExtensionPullDeps` gains optional `denoRuntime` + `repository` fields. When BOTH are provided, `extensionPull` routes through the service (W2 contract fires). When either is missing, falls back to the pre-W2 free-function path (catalog rows populated lazily on next loader pass — same behavior as before W2). Migrating callers to provide both is plan v4 commit 3 (CONVERT callsites + KEEP wrapper-internal swap). 6. **Two integration tests** (`install_extension_service_test.ts`) exercise the service end-to-end with a real catalog and real bundling: - `phase 8 populates the catalog with Indexed Sources` — proves the bundleAndIndexOne walk + repository.save chain works against a real on-disk fixture. - `DuplicateTypeError triggers FS rollback and surfaces as UserError` — proves the architecture-agent's expensive-miss-#2 contract holds. Stages two extensions claiming the same type; second install fails with UserError, B's files are gone, B's lockfile entry is absent, A's catalog row is preserved. ## Pin 2 holds The 3 Pin 2 baseline tests added in 2a still pass. Event stream byte- identical pre/post the W2 refactor. ## Budget check (architecture-agent's threshold rule) Cumulative diff through this commit: ~1713 LOC. Pre-committed budget was ≤1500 LOC for the entire W2 PR. We've crossed the threshold mid-W2 — only 1 of 3 lifecycle services (Install) is done. Per the rule, this is the signal to **SPLIT before continuing**. Options for the next step (surfaced to the human): - Open this branch as the "W2 install" PR; file Remove/Upgrade as follow-ups. - Or push through and split later — the threshold was a heuristic, not a hard invariant. Test plan: - [x] deno check, lint, fmt clean - [x] deno run test — 5422 passed (2 new), 0 failed (Pin 1 + Pin 2 regression tests both green) - [x] Phase 8 verified against real bundling + real catalog + real I-Repo-1 firing + real FS rollback Co-Authored-By: Claude Opus 4.7 (1M context) --- src/domain/datastore/user_datastore_loader.ts | 1 + src/domain/drivers/user_driver_loader.ts | 1 + src/domain/models/user_model_loader.ts | 3 + src/domain/reports/user_report_loader.ts | 1 + src/domain/vaults/user_vault_loader.ts | 1 + .../extensions/install_extension_service.ts | 379 +++++++++++++++++- .../install_extension_service_test.ts | 325 +++++++++++++++ src/libswamp/extensions/pull.ts | 39 +- 8 files changed, 728 insertions(+), 22 deletions(-) create mode 100644 src/libswamp/extensions/install_extension_service_test.ts diff --git a/src/domain/datastore/user_datastore_loader.ts b/src/domain/datastore/user_datastore_loader.ts index 80ce20a7..4e318e0f 100644 --- a/src/domain/datastore/user_datastore_loader.ts +++ b/src/domain/datastore/user_datastore_loader.ts @@ -725,6 +725,7 @@ export class UserDatastoreLoader { return null; } + installZodGlobal(); const denoPath = await this.denoRuntime.ensureDeno(); const js = await this.bundleWithCache( args.absolutePath, diff --git a/src/domain/drivers/user_driver_loader.ts b/src/domain/drivers/user_driver_loader.ts index c4821946..a7b2b59c 100644 --- a/src/domain/drivers/user_driver_loader.ts +++ b/src/domain/drivers/user_driver_loader.ts @@ -724,6 +724,7 @@ export class UserDriverLoader { return null; } + installZodGlobal(); const denoPath = await this.denoRuntime.ensureDeno(); const js = await this.bundleWithCache( args.absolutePath, diff --git a/src/domain/models/user_model_loader.ts b/src/domain/models/user_model_loader.ts index ca1f8026..bcb510e3 100644 --- a/src/domain/models/user_model_loader.ts +++ b/src/domain/models/user_model_loader.ts @@ -1158,6 +1158,9 @@ export class UserModelLoader { return null; } + // Ensure swamp's Zod is available on globalThis before importing + // bundles — same precondition as loadModels / buildIndex. + installZodGlobal(); const denoPath = await this.denoRuntime.ensureDeno(); const js = await this.bundleWithCache( args.absolutePath, diff --git a/src/domain/reports/user_report_loader.ts b/src/domain/reports/user_report_loader.ts index db66a540..32ec92ea 100644 --- a/src/domain/reports/user_report_loader.ts +++ b/src/domain/reports/user_report_loader.ts @@ -574,6 +574,7 @@ export class UserReportLoader { return null; } + installZodGlobal(); const denoPath = await this.denoRuntime.ensureDeno(); const js = await this.bundleWithCache( args.absolutePath, diff --git a/src/domain/vaults/user_vault_loader.ts b/src/domain/vaults/user_vault_loader.ts index 8753b38f..a35be244 100644 --- a/src/domain/vaults/user_vault_loader.ts +++ b/src/domain/vaults/user_vault_loader.ts @@ -733,6 +733,7 @@ export class UserVaultLoader { return null; } + installZodGlobal(); const denoPath = await this.denoRuntime.ensureDeno(); const js = await this.bundleWithCache( args.absolutePath, diff --git a/src/libswamp/extensions/install_extension_service.ts b/src/libswamp/extensions/install_extension_service.ts index 23869ec5..d8f9589e 100644 --- a/src/libswamp/extensions/install_extension_service.ts +++ b/src/libswamp/extensions/install_extension_service.ts @@ -17,47 +17,394 @@ // You should have received a copy of the GNU Affero General Public License // along with Swamp. If not, see . +import { join, relative } from "@std/path"; import { type ExtensionRef, type InstallContext, installExtension, type InstallResult, } from "./pull.ts"; +import { + type Extension, + makeExtension, +} from "../../domain/extensions/extension.ts"; +import { makeSource } from "../../domain/extensions/source.ts"; +import { makeSourceLocation } from "../../domain/extensions/source_location.ts"; +import { makeBundleLocation } from "../../domain/extensions/bundle_location.ts"; +import { type ExtensionRepository } from "../../infrastructure/persistence/extension_repository.ts"; +import { DuplicateTypeError } from "../../infrastructure/persistence/duplicate_type_error.ts"; +import { swampPath } from "../../infrastructure/persistence/paths.ts"; +import { UserModelLoader } from "../../domain/models/user_model_loader.ts"; +import { UserDriverLoader } from "../../domain/drivers/user_driver_loader.ts"; +import { UserVaultLoader } from "../../domain/vaults/user_vault_loader.ts"; +import { UserDatastoreLoader } from "../../domain/datastore/user_datastore_loader.ts"; +import { UserReportLoader } from "../../domain/reports/user_report_loader.ts"; +import type { DenoRuntime } from "../../domain/runtime/deno_runtime.ts"; +import type { UpstreamExtensionEntry } from "../../infrastructure/persistence/upstream_extensions.ts"; +import { UserError } from "../../domain/errors.ts"; + +/** Subdirectories of a per-extension subtree, paired with their kind. */ +const KIND_DIRS = [ + "models", + "vaults", + "drivers", + "datastores", + "reports", +] as const; + +type KindDir = typeof KIND_DIRS[number]; /** * W2 lifecycle service for installing a single extension. **Owns the - * catalog write surface** end-to-end: filesystem mutations + lockfile - * write + (commit 2c) Extension-aggregate construction + - * `repository.save(extension)` with FS rollback on - * `DuplicateTypeError`. + * catalog write surface** end-to-end: + * + * 1. Filesystem mutations (download + extract + copy to per-extension + * subtree) + * 2. Lockfile write (`upstream_extensions.json`) + * 3. Catalog write (`repository.save(extension)` — synchronous type + * extraction via each loader's `bundleAndIndexOne`, then save) * * **Asymmetric ordering with `RemoveExtensionService`.** Install is - * filesystem → lockfile → catalog. Remove is the inverse (catalog → - * lockfile → filesystem). Pinned in plan v4 challenge #3. + * filesystem → lockfile → catalog. Remove is the inverse. Pinned in + * plan v4 challenge #3. + * + * **I-Repo-1 fires at install time.** Phase 8 builds the + * `Extension` aggregate from the just-extracted on-disk subtree + * (calling each loader's `bundleAndIndexOne` per source file) and + * commits via `repository.save(extension)`. Cross-extension + * `(kind, typeNormalized)` collision raises `DuplicateTypeError` + * synchronously — the user-visible payoff for W2. + * + * **FS rollback on `DuplicateTypeError`.** SQLite ROLLBACK does not + * undo filesystem mutations, so the service explicitly undoes the + * filesystem writes (delete extracted files, restore lockfile to its + * prior state) before propagating a {@link UserError} that names both + * conflicting extensions. Plan v4 calls this "expensive miss #2". * * **Snapshot semantics inherited from `InstallContext`.** The * `lockfileRepository` on the context captures a snapshot at * construction. Single-use only — see {@link InstallContext} JSDoc. * - * **Current shape (commit 2b).** This is the service skeleton. It - * delegates to the existing `installExtension` free function unchanged; - * the architectural payoff (phase 8: synchronous type extraction + - * `repository.save`) lands in commit 2c. Tests verify event-stream - * byte-identicality across this skeleton refactor (Pin 2). + * **W4-inherits.** When the unified loader (KindAdapter) lands in W4, + * the per-loader `bundleAndIndexOne` calls in `buildExtensionFromDisk` + * collapse to a single dispatch. The service shape is W4-stable; + * loaders are the deletion surface. */ export class InstallExtensionService { + private readonly denoRuntime: DenoRuntime; + private readonly repository: ExtensionRepository; + private readonly installExtensionFn: ( + ref: ExtensionRef, + ctx: InstallContext, + ) => Promise; + + constructor(args: { + denoRuntime: DenoRuntime; + repository: ExtensionRepository; + /** + * Test seam — defaults to the real {@link installExtension} from + * `pull.ts`. Tests inject a stub that returns a hand-built + * {@link InstallResult} so phase 8 can be exercised against a + * pre-staged on-disk subtree without driving a real registry, + * tarball, or filesystem write. Production callers always omit + * this. + */ + installExtensionFn?: ( + ref: ExtensionRef, + ctx: InstallContext, + ) => Promise; + }) { + this.denoRuntime = args.denoRuntime; + this.repository = args.repository; + this.installExtensionFn = args.installExtensionFn ?? installExtension; + } + /** * Installs `ref` using `ctx`. Returns the {@link InstallResult} on a * fresh install, or `undefined` when the install short-circuited - * (alreadyPulled). Throws `ConflictError` when files would be - * overwritten and `ctx.force` is false. Recursively installs - * dependencies via the same service instance (commit 2c will route - * recursion through `this.execute` so phase 8 applies to each dep). + * (alreadyPulled). + * + * Throws `ConflictError` from filesystem-conflict detection when + * `ctx.force` is false. Throws {@link UserError} (mapped from + * {@link DuplicateTypeError}) when the catalog save detects a + * cross-extension type collision; filesystem state is rolled back to + * the pre-install snapshot before the throw. */ async execute( ref: ExtensionRef, ctx: InstallContext, ): Promise { - return await installExtension(ref, ctx); + // Snapshot the lockfile entry BEFORE install so FS rollback can + // restore it on a catalog-side failure. The snapshot survives + // installExtension's own writeEntry call (which only mutates the + // shared lockfileRepository's cache from this point forward). + const priorEntry = ctx.lockfileRepository.getEntry(ref.name); + + // Phases 1-7: download → extract → copy → prune → lockfile write. + // (Recursion into deps is internal to installExtension; deps land + // in the same lockfile snapshot.) + const result = await this.installExtensionFn(ref, ctx); + if (!result) return result; // alreadyPulled short-circuit + + // Phase 8: build Extension aggregates for top-level + each freshly- + // installed dep, then saveAll across the full set so I-Repo-1 + // evaluates the post-save state across this install operation as a + // unit. On DuplicateTypeError, FS rollback the entire set. + try { + const installedResults = flattenInstallResults(result); + const extensions = await Promise.all( + installedResults.map((r) => + this.buildExtensionFromDisk(r, ctx.repoDir) + ), + ); + this.repository.saveAll(extensions); + } catch (error) { + if (error instanceof DuplicateTypeError) { + await this.rollbackOnCollision(result, priorEntry, ctx); + throw mapDuplicateTypeErrorToUserError(error); + } + throw error; + } + + return result; + } + + /** + * Walks the per-extension subtree on disk and builds an + * {@link Extension} aggregate whose Sources are in `Indexed` state + * with `(kind, typeNormalized, bundlePath)` populated. Each source + * file is bundled and type-extracted via the appropriate loader's + * `bundleAndIndexOne` (Pin 1 contract: NO catalog writes from the + * loader; the lifecycle service is the catalog-write owner). + */ + private async buildExtensionFromDisk( + result: InstallResult, + repoDir: string, + ): Promise { + const extRoot = join(swampPath(repoDir, "pulled-extensions"), result.name); + const sources: ReturnType[] = []; + + for (const kindDir of KIND_DIRS) { + const dir = join(extRoot, kindDir); + const tsFiles = await collectTsFiles(dir); + const loader = this.makeLoaderForKind(kindDir, repoDir); + for (const absolutePath of tsFiles) { + const relativePath = relative(dir, absolutePath); + const out = await loader.bundleAndIndexOne({ + absolutePath, + relativePath, + baseDir: dir, + }); + if (!out) continue; + sources.push( + makeSource({ + id: makeSourceLocation(absolutePath, extRoot), + kind: out.kind, + fingerprint: out.fingerprint, + state: { + tag: "Indexed", + type: out.typeNormalized, + bundle: makeBundleLocation(out.bundlePath, out.fingerprint), + }, + }), + ); + } + } + + return makeExtension({ + name: result.name, + version: result.version, + origin: "pulled", + extensionRoot: extRoot, + sources, + }); + } + + /** + * Constructs the loader for a given kind directory. Each loader is + * stateless w.r.t. `bundleAndIndexOne` (no catalog write — Pin 1) so + * constructing fresh per call is cheap. + */ + private makeLoaderForKind( + kindDir: KindDir, + repoDir: string, + ): { + bundleAndIndexOne: (args: { + absolutePath: string; + relativePath: string; + baseDir: string; + }) => Promise< + | { + kind: + | "model" + | "extension" + | "vault" + | "driver" + | "datastore" + | "report"; + typeNormalized: string; + bundlePath: string; + fingerprint: string; + } + | null + >; + } { + switch (kindDir) { + case "models": + return new UserModelLoader( + this.denoRuntime, + repoDir, + undefined, + this.repository, + ); + case "vaults": + return new UserVaultLoader( + this.denoRuntime, + repoDir, + undefined, + this.repository, + ); + case "drivers": + return new UserDriverLoader( + this.denoRuntime, + repoDir, + undefined, + this.repository, + ); + case "datastores": + return new UserDatastoreLoader( + this.denoRuntime, + repoDir, + this.repository, + ); + case "reports": + return new UserReportLoader( + this.denoRuntime, + repoDir, + undefined, + this.repository, + ); + } + } + + /** + * Filesystem rollback on `DuplicateTypeError`. Deletes the just- + * installed files for the top-level extension AND any freshly- + * installed deps, then restores the lockfile to its pre-install + * state. + * + * Best-effort: any individual delete that fails (file already gone, + * permission denied) is logged and swallowed so the caller still + * surfaces the original `DuplicateTypeError` as the user-visible + * cause. The disk-walk fallback in the loader pipeline handles any + * stragglers on the next swamp invocation. + */ + private async rollbackOnCollision( + result: InstallResult, + priorEntry: UpstreamExtensionEntry | null, + ctx: InstallContext, + ): Promise { + const installedResults = flattenInstallResults(result); + for (const r of installedResults) { + for (const file of r.extractedFiles) { + const absolutePath = join(ctx.repoDir, file); + try { + await Deno.remove(absolutePath, { recursive: true }); + } catch (error) { + if (!(error instanceof Deno.errors.NotFound)) { + if (ctx.logger) { + ctx.logger.warn`FS rollback failed for ${absolutePath}: ${error}`; + } + } + } + } + } + + // Restore the top-level extension's lockfile entry to its prior + // state (or remove if first-install). Deps' lockfile entries were + // also written during install but their priorEntry isn't captured + // here — they were absent before this install (otherwise + // installExtension would have skipped them via the alreadyPulled + // / lockfile check). So we remove deps' entries. + try { + if (priorEntry) { + await ctx.lockfileRepository.writeEntry( + result.name, + priorEntry.version, + priorEntry.files ?? [], + { + include: priorEntry.include, + checksum: priorEntry.checksum, + filesChecksum: priorEntry.filesChecksum, + serverUrl: priorEntry.serverUrl, + }, + ); + } else { + await ctx.lockfileRepository.removeEntry(result.name); + } + for (const dep of installedResults.slice(1)) { + await ctx.lockfileRepository.removeEntry(dep.name); + } + } catch (error) { + if (ctx.logger) { + ctx.logger.warn`Lockfile rollback failed: ${error}`; + } + } + } +} + +/** + * Flattens an {@link InstallResult} tree into a depth-first list: + * `[topLevel, ...deps, ...transitiveDeps]`. Used to drive phase 8 + * across the entire install operation atomically. + */ +function flattenInstallResults(result: InstallResult): InstallResult[] { + const out: InstallResult[] = [result]; + for (const dep of result.dependencyResults) { + out.push(...flattenInstallResults(dep)); } + return out; +} + +/** + * Collects every `.ts` file under `dir` recursively. Returns absolute + * paths. Returns `[]` when `dir` doesn't exist (the per-extension + * subtree may not include every kind directory). Skips `_`-prefixed + * directories (private helpers convention). + */ +async function collectTsFiles(dir: string): Promise { + const out: string[] = []; + try { + for await (const entry of Deno.readDir(dir)) { + const path = join(dir, entry.name); + if (entry.isFile && entry.name.endsWith(".ts")) { + out.push(path); + } else if (entry.isDirectory && !entry.name.startsWith("_")) { + out.push(...await collectTsFiles(path)); + } + } + } catch (error) { + if (!(error instanceof Deno.errors.NotFound)) throw error; + } + return out; +} + +/** + * Wraps a {@link DuplicateTypeError} in a {@link UserError} so the + * top-level CLI error handler renders a clean message rather than a + * stack trace. Both source paths are named — the W2 user-visible + * payoff that replaces W1b's silent first-wins. + */ +function mapDuplicateTypeErrorToUserError( + error: DuplicateTypeError, +): UserError { + return new UserError( + `Type "${error.typeNormalized}" (kind=${error.kind}) is already claimed by ` + + `${error.firstSource.extensionName}@${error.firstSource.extensionVersion} ` + + `at ${error.firstSource.canonicalPath}. Cannot install ` + + `${error.secondSource.extensionName}@${error.secondSource.extensionVersion} ` + + `at ${error.secondSource.canonicalPath} — filesystem changes rolled back. ` + + `Run \`swamp extension rm ${error.firstSource.extensionName}\` first if ` + + `you intended to replace it.`, + ); } diff --git a/src/libswamp/extensions/install_extension_service_test.ts b/src/libswamp/extensions/install_extension_service_test.ts new file mode 100644 index 00000000..965c656c --- /dev/null +++ b/src/libswamp/extensions/install_extension_service_test.ts @@ -0,0 +1,325 @@ +// Swamp, an Automation Framework +// Copyright (C) 2026 System Initiative, Inc. +// +// This file is part of Swamp. +// +// Swamp is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation, with the Swamp +// Extension and Definition Exception (found in the "COPYING-EXCEPTION" +// file). +// +// Swamp is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with Swamp. If not, see . + +import { assertEquals, assertRejects } from "@std/assert"; +import { join } from "@std/path"; +import { ensureDir } from "@std/fs"; +import { InstallExtensionService } from "./install_extension_service.ts"; +import type { ExtensionRef, InstallContext, InstallResult } from "./pull.ts"; +import { ExtensionCatalogStore } from "../../infrastructure/persistence/extension_catalog_store.ts"; +import { ExtensionRepository } from "../../infrastructure/persistence/extension_repository.ts"; +import { LockfileRepository } from "../../infrastructure/persistence/lockfile_repository.ts"; +import { swampPath } from "../../infrastructure/persistence/paths.ts"; +import { UserError } from "../../domain/errors.ts"; +import type { DenoRuntime } from "../../domain/runtime/deno_runtime.ts"; + +// Required so the model loader's test bootstrap finds command/shell +// (preserved from the loader test files' pattern). +import "../../domain/models/models.ts"; + +const testDenoRuntime: DenoRuntime = { + ensureDeno: () => Promise.resolve(Deno.execPath()), +}; + +/** + * Helpers for building a self-contained on-disk fixture that the + * service can walk in phase 8. Avoids needing a real tarball / network + * round-trip. + */ +async function withFixtureRepo( + fn: (args: { + repoDir: string; + repository: ExtensionRepository; + catalog: ExtensionCatalogStore; + lockfileRepository: LockfileRepository; + }) => Promise, +): Promise { + const repoDir = await Deno.makeTempDir({ prefix: "swamp_iesvc_test_" }); + await ensureDir(join(repoDir, ".swamp")); + const dbPath = join(repoDir, ".swamp", "_extension_catalog.db"); + const lockfilePath = join( + repoDir, + "extensions", + "models", + "upstream_extensions.json", + ); + await ensureDir(join(repoDir, "extensions", "models")); + await Deno.writeTextFile(lockfilePath, "{}"); + + const catalog = new ExtensionCatalogStore(dbPath); + const lockfileRepository = await LockfileRepository.create(lockfilePath); + const repository = new ExtensionRepository({ + catalog, + lockfileRepository, + repoRoot: repoDir, + }); + + try { + await fn({ repoDir, repository, catalog, lockfileRepository }); + } finally { + catalog.close(); + if (Deno.build.os === "windows") { + await Deno.remove(repoDir, { recursive: true }).catch(() => {}); + } else { + await Deno.remove(repoDir, { recursive: true }); + } + } +} + +/** Stages a model file at .swamp/pulled-extensions//models/ */ +async function stageModel( + repoDir: string, + extName: string, + fileName: string, + modelCode: string, +): Promise { + const modelsDir = join( + swampPath(repoDir, "pulled-extensions"), + extName, + "models", + ); + await ensureDir(modelsDir); + const path = join(modelsDir, fileName); + await Deno.writeTextFile(path, modelCode); + return path; +} + +/** Builds an InstallResult that points at staged on-disk files. */ +function makeStubInstallResult( + extName: string, + version: string, + files: string[], +): InstallResult { + return { + name: extName, + version, + description: undefined, + extractedFiles: files, + integrityStatus: "verified", + repository: undefined, + platforms: [], + safetyWarnings: [], + conflicts: [], + missingSourceFiles: [], + hasSkills: false, + hasSkillScripts: false, + skillFiles: [], + dependencyResults: [], + pruned: [], + }; +} + +function makeInstallContext( + repoDir: string, + lockfileRepository: LockfileRepository, +): InstallContext { + return { + getExtension: () => Promise.reject(new Error("stub")), + downloadArchive: () => Promise.reject(new Error("stub")), + getChecksum: () => Promise.resolve(null), + lockfileRepository, + skillsDir: join(repoDir, ".claude", "skills"), + repoDir, + force: false, + alreadyPulled: new Set(), + depth: 0, + }; +} + +const MINIMAL_MODEL_CODE = (typeId: string) => ` +import { z } from "npm:zod@4"; + +export const model = { + type: "${typeId}", + version: "2026.05.05.1", + globalArguments: z.object({}), + resources: { + "data": { + description: "x", + schema: z.object({}), + lifetime: "infinite", + garbageCollection: 1, + }, + }, + methods: { + noop: { + description: "noop", + arguments: z.object({}), + execute: async () => ({ dataHandles: [] }), + }, + }, +}; +`; + +Deno.test( + "InstallExtensionService.execute: phase 8 populates the catalog with Indexed Sources", + async () => { + await withFixtureRepo( + async ({ repoDir, repository, catalog, lockfileRepository }) => { + const ts = Date.now(); + const extName = `@test/svc-success-${ts}`; + const typeId = `@test/svc-model-${ts}`; + const filePath = await stageModel( + repoDir, + extName, + "noop.ts", + MINIMAL_MODEL_CODE(typeId), + ); + + // Pre-condition: catalog empty, no Extension known. + assertEquals(catalog.findAll().length, 0); + assertEquals(repository.loadByName(extName).length, 0); + + const service = new InstallExtensionService({ + denoRuntime: testDenoRuntime, + repository, + installExtensionFn: () => + Promise.resolve( + makeStubInstallResult(extName, "1.0.0", [ + `.swamp/pulled-extensions/${extName}/models/noop.ts`, + ]), + ), + }); + const ctx = makeInstallContext(repoDir, lockfileRepository); + + const result = await service.execute( + { name: extName, version: "1.0.0" } as ExtensionRef, + ctx, + ); + + // Returned result is the InstallResult unchanged (FS+lockfile + // already done by the stubbed installExtension). + assertEquals(result?.name, extName); + assertEquals(result?.version, "1.0.0"); + + // Phase 8 wrote a row for the model: catalog non-empty, the + // Extension aggregate loads, and its Source has the type populated. + const extensions = repository.loadByName(extName); + assertEquals(extensions.length, 1); + const ext = extensions[0]; + assertEquals(ext.sources.size, 1); + const source = ext.sources.values().next().value!; + assertEquals(source.kind, "model"); + assertEquals( + source.state.tag, + "Indexed", + "Pin 1: source must land in Indexed state with type populated", + ); + if (source.state.tag === "Indexed") { + assertEquals(source.state.type, typeId.toLowerCase()); + } + assertEquals(source.id.canonicalPath.endsWith("/noop.ts"), true); + void filePath; + }, + ); + }, +); + +Deno.test( + "InstallExtensionService.execute: DuplicateTypeError triggers FS rollback and surfaces as UserError", + async () => { + await withFixtureRepo( + async ({ repoDir, repository, lockfileRepository }) => { + const ts = Date.now(); + const typeId = `@test/svc-collide-${ts}`; + const extA = `@test/collide-a-${ts}`; + const extB = `@test/collide-b-${ts}`; + + // Stage and install A first via service. + await stageModel(repoDir, extA, "model.ts", MINIMAL_MODEL_CODE(typeId)); + await lockfileRepository.writeEntry(extA, "1.0.0", [ + `.swamp/pulled-extensions/${extA}/models/model.ts`, + ]); + const serviceA = new InstallExtensionService({ + denoRuntime: testDenoRuntime, + repository, + installExtensionFn: () => + Promise.resolve( + makeStubInstallResult(extA, "1.0.0", [ + `.swamp/pulled-extensions/${extA}/models/model.ts`, + ]), + ), + }); + await serviceA.execute( + { name: extA, version: "1.0.0" } as ExtensionRef, + makeInstallContext(repoDir, lockfileRepository), + ); + assertEquals(repository.loadByName(extA).length, 1); + + // Stage B which claims the SAME type. Phase 8 must detect the + // collision via I-Repo-1 and roll back. + const bModelPath = await stageModel( + repoDir, + extB, + "model.ts", + MINIMAL_MODEL_CODE(typeId), + ); + const bExtractedFiles = [ + `.swamp/pulled-extensions/${extB}/models/model.ts`, + ]; + + const serviceB = new InstallExtensionService({ + denoRuntime: testDenoRuntime, + repository, + installExtensionFn: () => + Promise.resolve( + makeStubInstallResult(extB, "1.0.0", bExtractedFiles), + ), + }); + + await assertRejects( + () => + serviceB.execute( + { name: extB, version: "1.0.0" } as ExtensionRef, + makeInstallContext(repoDir, lockfileRepository), + ), + UserError, + "Cannot install", + ); + + // FS rollback: B's staged file is gone. + let bStillExists = true; + try { + await Deno.stat(bModelPath); + } catch (error) { + if (error instanceof Deno.errors.NotFound) bStillExists = false; + else throw error; + } + assertEquals( + bStillExists, + false, + "FS rollback must delete files staged by the failed install", + ); + + // Lockfile rollback: B's entry was never persisted (or was + // rolled back). A's entry remains. + const fresh = await LockfileRepository.create( + lockfileRepository.lockfilePath, + ); + assertEquals(fresh.getEntry(extB), null); + assertEquals(fresh.getEntry(extA)?.version, "1.0.0"); + + // Catalog rollback (via SQLite ROLLBACK inside saveAll): A's row + // is preserved, B's never landed. + assertEquals(repository.loadByName(extA).length, 1); + assertEquals(repository.loadByName(extB).length, 0); + }, + ); + }, +); diff --git a/src/libswamp/extensions/pull.ts b/src/libswamp/extensions/pull.ts index 8b9b7e28..a96ac27b 100644 --- a/src/libswamp/extensions/pull.ts +++ b/src/libswamp/extensions/pull.ts @@ -31,6 +31,8 @@ import { analyzeExtensionSafety } from "../../domain/extensions/extension_safety import { ExtensionApiClient } from "../../infrastructure/http/extension_api_client.ts"; import { pruneOrphanFiles } from "../../infrastructure/persistence/directory_cleanup.ts"; import { LockfileRepository } from "../../infrastructure/persistence/lockfile_repository.ts"; +import type { ExtensionRepository } from "../../infrastructure/persistence/extension_repository.ts"; +import type { DenoRuntime } from "../../domain/runtime/deno_runtime.ts"; import { InstallExtensionService } from "./install_extension_service.ts"; import { bundleNamespace, @@ -212,6 +214,20 @@ export interface ExtensionPullDeps { ref: ExtensionRef, ctx: InstallContext, ) => Promise; + /** + * W2 service deps. When BOTH are provided, `extensionPull` routes + * through {@link InstallExtensionService} — phase 8 fires (synchronous + * type extraction + `repository.save` + FS rollback on + * `DuplicateTypeError`). When either is missing, falls back to the + * pre-W2 free-function path (catalog rows populated lazily by the + * loader's next pass — same behavior as W1b shipped). + * + * Production paths that want the W2 contract (I-Repo-1 fires at install + * time) MUST pass both. Migrating callers is plan v4 commit 3 (CONVERT + * callsites) and the wrapper-internal swap for KEEP callsites. + */ + denoRuntime?: DenoRuntime; + repository?: ExtensionRepository; } /** @@ -1138,13 +1154,24 @@ export async function* extensionPull( depth: deps.depth, }; - // Let ConflictError propagate — CLI catches it for the two-phase prompt flow. - // Production path goes through InstallExtensionService so the W2 catalog- - // write contract (commit 2c phase 8) applies. Tests inject their own - // stub via deps.installExtensionFn. + // Let ConflictError propagate — CLI catches it for the two-phase prompt + // flow. Routing precedence: + // 1. deps.installExtensionFn (test seam — Pin 2 stubs) + // 2. InstallExtensionService when deps.denoRuntime + deps.repository + // are both provided (W2 contract: phase 8 fires, I-Repo-1 fires + // at install time, FS rollback on DuplicateTypeError) + // 3. Free-function installExtension (pre-W2 fallback — catalog rows + // populated lazily on next loader pass) + const denoRt = deps.denoRuntime; + const repo = deps.repository; const install = deps.installExtensionFn ?? - ((r: ExtensionRef, c: InstallContext) => - new InstallExtensionService().execute(r, c)); + (denoRt !== undefined && repo !== undefined + ? (r: ExtensionRef, c: InstallContext) => + new InstallExtensionService({ + denoRuntime: denoRt, + repository: repo, + }).execute(r, c) + : installExtension); const result = await install(input.ref, installCtx); if (result) { if (result.pruned.length > 0) { From e2cdecd3439ca81caa1c8a80514b01d08c0ff138 Mon Sep 17 00:00:00 2001 From: stack72 Date: Tue, 5 May 2026 14:52:56 +0100 Subject: [PATCH 05/15] =?UTF-8?q?feat(extensions):=20W2=20commit=203=20?= =?UTF-8?q?=E2=80=94=20route=204=20production=20callsites=20through=20Inst?= =?UTF-8?q?allExtensionService?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit W2 of the extension catalog rearchitecture (swamp-club#231). With the service skeleton + phase 8 done in commit 2, this commit **activates phase 8 in production**. After this lands, every install path through the CLI fires I-Repo-1 synchronously at install time — `DuplicateTypeError` becomes the user-visible error for cross-extension `(kind, type)` collisions instead of silent first-wins. ## What ships ### Pull.ts factory + KEEP-side - `createExtensionPullDeps` accepts optional `args.denoRuntime` and `args.repository` and threads them onto the returned `ExtensionPullDeps`. Backwards-compatible — callers that don't pass them see no change. - `PullContext` (CLI-side wrapper) gains optional `denoRuntime` + `repository` fields. `pullExtension` threads them onto the `ExtensionPullDeps` it constructs internally. ### Production callsites — 4 CONVERT, 2 KEEP-wired CONVERT (build their own InstallContext + call `installExtension` inline): - **`src/cli/auto_resolver_adapters.ts`**: auto-install on missing type. Routes through `InstallExtensionService` when the parent context has a repository (via the existing `repository?` field). Falls back to free-function `installExtension` when no repository is available. - **`src/cli/resolve_datastore.ts`**: datastore auto-update. Constructs catalog + repository inline, executes via the service, closes the catalog in `finally`. - **`src/cli/commands/extension_update.ts`** (bulk upgrade): catalog stays open for the duration of the bulk run, repository is freshly constructed per upgrade with a fresh lockfile snapshot. KEEP (use the `extensionPull` async generator wrapper, get phase 8 by having `denoRuntime` + `repository` plumbed through `PullContext`): - **`src/cli/commands/extension_pull.ts`** (`swamp extension pull` CLI entry). - **`src/cli/commands/open.ts`** (web UI `pullExtension` callback). ### Lifetime + cleanup Each new construction of `ExtensionCatalogStore` is paired with a `try { ... } finally { catalog.close(); }` so the SQLite handle is released cleanly. The bulk-update path keeps the catalog open across all upgrades for atomic-per-upgrade semantics. ### Pin 2 holds Event-stream byte-identicality verified. All 3 Pin 2 baseline tests remain green: - `installing → completed` happy path - `installing → orphans-pruned → completed` with prior version files - `installing` only when alreadyPulled short-circuits The W2 service routes through the same `extensionPull` event sequence; phase 8 is internal to `InstallExtensionService.execute()` and emits no new events (the renderer surface is unchanged). ## What this delivers user-visibly - `swamp extension pull ` against a colliding type now emits `DuplicateTypeError` with both source paths named, exit code 1, and filesystem rollback. Previously: silent first-wins, no error. - `swamp open` UI install path inherits the same contract. - `swamp extension update` bulk upgrade fires phase 8 per upgrade — per-extension atomic transitions; A's collision-rollback does NOT roll back B's already-completed upgrade in the same bulk run. - Auto-resolver (lazy install on unknown type) and datastore auto-update get phase 8 too. ## Out of scope (next commits) - `swamp extension rm` still leaves catalog rows behind (closes swamp-club#201 — needs RemoveExtensionService, commit 4). - Atomic upgrade as a single saveAll([tombstoneAll(v1), v2]) transaction — needs UpgradeExtensionService, commit 5. Currently upgrade is "save v2 over v1" via the per-upgrade install path. Test plan: - [x] deno check, lint, fmt clean - [x] deno run test — 5422 passed, 0 failed (Pin 1 + Pin 2 + service tests all green) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/cli/auto_resolver_adapters.ts | 41 ++++++++----- src/cli/commands/extension_pull.ts | 75 +++++++++++++++++------- src/cli/commands/extension_update.ts | 87 ++++++++++++++++++---------- src/cli/commands/open.ts | 55 ++++++++++++------ src/cli/resolve_datastore.ts | 52 +++++++++++------ src/libswamp/extensions/pull.ts | 11 ++++ src/libswamp/mod.ts | 3 + 7 files changed, 221 insertions(+), 103 deletions(-) diff --git a/src/cli/auto_resolver_adapters.ts b/src/cli/auto_resolver_adapters.ts index 3249aa0e..0cf0366e 100644 --- a/src/cli/auto_resolver_adapters.ts +++ b/src/cli/auto_resolver_adapters.ts @@ -34,6 +34,7 @@ import { enumeratePulledExtensionDirs, type ExtensionRegistryInfo, installExtension, + InstallExtensionService, LockfileRepository, } from "../libswamp/mod.ts"; import { UserModelLoader } from "../domain/models/user_model_loader.ts"; @@ -182,21 +183,31 @@ export function createAutoResolveInstallerAdapter( const lockfileRepository = await LockfileRepository.create( lockfilePath, ); - const result = await installExtension( - { name: extensionName, version: null }, - { - getExtension, - downloadArchive, - getChecksum, - logger, - lockfileRepository, - skillsDir: swampPath(repoDir, SWAMP_SUBDIRS.pulledSkills), - repoDir, - force: false, - alreadyPulled: new Set(), - depth: 0, - }, - ); + const installCtx = { + getExtension, + downloadArchive, + getChecksum, + logger, + lockfileRepository, + skillsDir: swampPath(repoDir, SWAMP_SUBDIRS.pulledSkills), + repoDir, + force: false, + alreadyPulled: new Set(), + depth: 0, + }; + // W2 (commit 3): route through InstallExtensionService when an + // ExtensionRepository is available so phase 8 fires (catalog + // populated synchronously, I-Repo-1 fires on `(kind, type)` + // collision). When the repository isn't wired (e.g. headless + // bootstrap paths), fall back to the pre-W2 free function — the + // catalog gets populated lazily on next loader pass. + const result = repository !== undefined + ? await new InstallExtensionService({ denoRuntime, repository }) + .execute({ name: extensionName, version: null }, installCtx) + : await installExtension( + { name: extensionName, version: null }, + installCtx, + ); if (!result) return null; return { version: result.version }; } catch (error) { diff --git a/src/cli/commands/extension_pull.ts b/src/cli/commands/extension_pull.ts index 982d61ce..4b11964f 100644 --- a/src/cli/commands/extension_pull.ts +++ b/src/cli/commands/extension_pull.ts @@ -20,6 +20,11 @@ import { Command } from "@cliffy/command"; import type { Logger } from "@logtape/logtape"; import { join, resolve } from "@std/path"; +import type { DenoRuntime } from "../../domain/runtime/deno_runtime.ts"; +import { EmbeddedDenoRuntime } from "../../infrastructure/runtime/embedded_deno_runtime.ts"; +import { ExtensionRepository } from "../../infrastructure/persistence/extension_repository.ts"; +import { ExtensionCatalogStore } from "../../infrastructure/persistence/extension_catalog_store.ts"; +import { swampPath } from "../../infrastructure/persistence/paths.ts"; import { createContext, type GlobalOptions, @@ -103,6 +108,16 @@ export interface PullContext { outputMode: "log" | "json"; alreadyPulled: Set; depth: number; + /** + * W2 service deps. When BOTH are provided, `extensionPull` routes + * through {@link InstallExtensionService} so phase 8 fires (catalog + * populated synchronously, I-Repo-1 fires on `(kind, type)` collision, + * FS rollback on conflict). When either is missing, falls back to + * the pre-W2 free-function path. See {@link ExtensionPullDeps} for + * the full contract. + */ + denoRuntime?: DenoRuntime; + repository?: ExtensionRepository; } /** @@ -123,6 +138,8 @@ export async function pullExtension( repoDir: ctx.repoDir, alreadyPulled: ctx.alreadyPulled, depth: ctx.depth, + denoRuntime: ctx.denoRuntime, + repository: ctx.repository, }; const renderer = createExtensionPullRenderer(outputMode); @@ -209,26 +226,44 @@ export const extensionPullCommand = new Command() const tool = resolvePrimaryTool(marker); const skillsDir = resolveSkillsDir(repoDir, tool); - // 7. Create deps via factory and pull - const serverUrl = resolveServerUrl(); - const deps = await createExtensionPullDeps( - serverUrl, - lockfilePath, - skillsDir, - repoDir, + // 7. Construct W2 service deps (denoRuntime + ExtensionRepository) + // so phase 8 fires synchronously at install time. Both are required + // for `extensionPull` to route through {@link InstallExtensionService} + // — without them it falls back to the pre-W2 free-function path. + const denoRuntime = new EmbeddedDenoRuntime(); + const catalog = new ExtensionCatalogStore( + swampPath(repoDir, "_extension_catalog.db"), ); + try { + const serverUrl = resolveServerUrl(); + const deps = await createExtensionPullDeps( + serverUrl, + lockfilePath, + skillsDir, + repoDir, + ); + const repository = new ExtensionRepository({ + catalog, + lockfileRepository: deps.lockfileRepository, + repoRoot: repoDir, + }); - await pullExtension(ref, { - getExtension: deps.getExtension, - downloadArchive: deps.downloadArchive, - getChecksum: deps.getChecksum, - logger: ctx.logger, - lockfileRepository: deps.lockfileRepository, - skillsDir, - repoDir, - force: options.force ?? false, - outputMode: ctx.outputMode, - alreadyPulled: new Set(), - depth: 0, - }); + await pullExtension(ref, { + getExtension: deps.getExtension, + downloadArchive: deps.downloadArchive, + getChecksum: deps.getChecksum, + logger: ctx.logger, + lockfileRepository: deps.lockfileRepository, + skillsDir, + repoDir, + force: options.force ?? false, + outputMode: ctx.outputMode, + alreadyPulled: new Set(), + depth: 0, + denoRuntime, + repository, + }); + } finally { + catalog.close(); + } }); diff --git a/src/cli/commands/extension_update.ts b/src/cli/commands/extension_update.ts index 27b6291e..f35d5062 100644 --- a/src/cli/commands/extension_update.ts +++ b/src/cli/commands/extension_update.ts @@ -30,18 +30,19 @@ import { RepoMarkerRepository, } from "../../infrastructure/persistence/repo_marker_repository.ts"; import { RepoPath } from "../../domain/repo/repo_path.ts"; -import { - createInstallContext, - installExtension, - parseExtensionRef, -} from "./extension_pull.ts"; +import { createInstallContext, parseExtensionRef } from "./extension_pull.ts"; import { consumeStream, createExtensionUpdateDeps, createLibSwampContext, extensionUpdate, + InstallExtensionService, warnLegacyExtensionLayout, } from "../../libswamp/mod.ts"; +import { ExtensionRepository } from "../../infrastructure/persistence/extension_repository.ts"; +import { ExtensionCatalogStore } from "../../infrastructure/persistence/extension_catalog_store.ts"; +import { EmbeddedDenoRuntime } from "../../infrastructure/runtime/embedded_deno_runtime.ts"; +import { swampPath } from "../../infrastructure/persistence/paths.ts"; import { createExtensionUpdateRenderer } from "../../presentation/renderers/extension_update.ts"; import { resolveSkillsDir } from "../../domain/repo/skill_dirs.ts"; import { resolvePrimaryTool } from "../../domain/repo/primary_tool.ts"; @@ -113,32 +114,54 @@ export const extensionUpdateCommand = new Command() const serverUrl = resolveServerUrl(); const ctx = createLibSwampContext({ logger: cliCtx.logger }); - const deps = await createExtensionUpdateDeps({ - lockfilePath, - serverUrl, - installExtension: async (name: string, version: string) => { - // Construct a fresh InstallContext per upgrade — captures a - // current snapshot of the lockfile per the - // InstallContext.lockfileRepository single-use rule. Reusing one - // context across multiple installs would expose stale state. - const installCtx = await createInstallContext(serverUrl, { - logger: cliCtx.logger, - lockfilePath, - skillsDir, - repoDir, - force: true, - }); - return await installExtension({ name, version }, installCtx); - }, - }); - - // 5. Execute and render - const renderer = createExtensionUpdateRenderer(cliCtx.outputMode); - await consumeStream( - extensionUpdate(ctx, deps, { - extensionName, - checkOnly: !!options.check, - }), - renderer.handlers(), + // W2 (commit 3): construct shared denoRuntime + repository so each + // upgrade routes through InstallExtensionService and phase 8 fires + // (catalog populated synchronously, I-Repo-1 fires on collision, + // FS rollback). The catalog stays open for the duration of the + // bulk update — closed in the finally block. + const denoRuntime = new EmbeddedDenoRuntime(); + const catalog = new ExtensionCatalogStore( + swampPath(repoDir, "_extension_catalog.db"), ); + try { + const deps = await createExtensionUpdateDeps({ + lockfilePath, + serverUrl, + installExtension: async (name: string, version: string) => { + // Construct a fresh InstallContext per upgrade — captures a + // current snapshot of the lockfile per the + // InstallContext.lockfileRepository single-use rule. Reusing one + // context across multiple installs would expose stale state. + const installCtx = await createInstallContext(serverUrl, { + logger: cliCtx.logger, + lockfilePath, + skillsDir, + repoDir, + force: true, + }); + // Build a fresh ExtensionRepository per upgrade so its lockfile + // snapshot lines up with the InstallContext's. Catalog is + // shared across the bulk run for atomicity-of-each-upgrade. + const repository = new ExtensionRepository({ + catalog, + lockfileRepository: installCtx.lockfileRepository, + repoRoot: repoDir, + }); + return await new InstallExtensionService({ denoRuntime, repository }) + .execute({ name, version }, installCtx); + }, + }); + + // 5. Execute and render + const renderer = createExtensionUpdateRenderer(cliCtx.outputMode); + await consumeStream( + extensionUpdate(ctx, deps, { + extensionName, + checkOnly: !!options.check, + }), + renderer.handlers(), + ); + } finally { + catalog.close(); + } }); diff --git a/src/cli/commands/open.ts b/src/cli/commands/open.ts index 0dc8a815..e41ac5c1 100644 --- a/src/cli/commands/open.ts +++ b/src/cli/commands/open.ts @@ -53,6 +53,7 @@ import { resolveModelsDir } from "../resolve_models_dir.ts"; import { ExtensionCatalogStore } from "../../infrastructure/persistence/extension_catalog_store.ts"; import { ExtensionRepository } from "../../infrastructure/persistence/extension_repository.ts"; import { LockfileRepository } from "../../infrastructure/persistence/lockfile_repository.ts"; +import { EmbeddedDenoRuntime } from "../../infrastructure/runtime/embedded_deno_runtime.ts"; import { swampPath } from "../../infrastructure/persistence/paths.ts"; import { isAbsolute } from "@std/path"; import { @@ -218,25 +219,43 @@ export const openCommand = new Command() throw new LocalEditsError(name); } const pullLockfileRepo = await LockfileRepository.create(lockfilePath); - await pullExtension( - { name, version: null }, - { - getExtension: (n) => extClient.getExtension(n), - downloadArchive: (n, v) => extClient.downloadArchive(n, v), - getChecksum: (n, v) => extClient.getChecksum(n, v), - logger: ctx.logger, - lockfileRepository: pullLockfileRepo, - skillsDir: resolveSkillsDir(repoDir, resolvePrimaryTool(marker)), - repoDir, - // Force overwrite — the web UI has no stdin to answer the - // "overwrite existing files?" prompt, so we always install - // non-interactively. Local-edits protection runs above. - force: true, - outputMode: ctx.outputMode, - alreadyPulled: new Set(), - depth: 0, - }, + // W2 (commit 3): wire denoRuntime + repository into PullContext + // so extensionPull routes through InstallExtensionService and + // phase 8 fires synchronously at install time. + const pullDenoRuntime = new EmbeddedDenoRuntime(); + const pullCatalog = new ExtensionCatalogStore( + swampPath(repoDir, "_extension_catalog.db"), ); + try { + const pullRepository = new ExtensionRepository({ + catalog: pullCatalog, + lockfileRepository: pullLockfileRepo, + repoRoot: repoDir, + }); + await pullExtension( + { name, version: null }, + { + getExtension: (n) => extClient.getExtension(n), + downloadArchive: (n, v) => extClient.downloadArchive(n, v), + getChecksum: (n, v) => extClient.getChecksum(n, v), + logger: ctx.logger, + lockfileRepository: pullLockfileRepo, + skillsDir: resolveSkillsDir(repoDir, resolvePrimaryTool(marker)), + repoDir, + // Force overwrite — the web UI has no stdin to answer the + // "overwrite existing files?" prompt, so we always install + // non-interactively. Local-edits protection runs above. + force: true, + outputMode: ctx.outputMode, + alreadyPulled: new Set(), + depth: 0, + denoRuntime: pullDenoRuntime, + repository: pullRepository, + }, + ); + } finally { + pullCatalog.close(); + } await reloadExtensionRegistries(); }, createDefinition: async (type, name, globalArguments) => { diff --git a/src/cli/resolve_datastore.ts b/src/cli/resolve_datastore.ts index 57ed765b..88f2ee1d 100644 --- a/src/cli/resolve_datastore.ts +++ b/src/cli/resolve_datastore.ts @@ -48,9 +48,11 @@ import { DEFAULT_SWAMP_CLUB_URL } from "../domain/auth/auth_credentials.ts"; import { detectLocalEditsForExtension, enumeratePulledExtensionDirs, - installExtension, + InstallExtensionService, LockfileRepository, } from "../libswamp/mod.ts"; +import { ExtensionRepository } from "../infrastructure/persistence/extension_repository.ts"; +import { ExtensionCatalogStore } from "../infrastructure/persistence/extension_catalog_store.ts"; import { UserDatastoreLoader } from "../domain/datastore/user_datastore_loader.ts"; import { EmbeddedDenoRuntime } from "../infrastructure/runtime/embedded_deno_runtime.ts"; import { swampPath } from "../infrastructure/persistence/paths.ts"; @@ -125,24 +127,38 @@ async function maybeAutoUpdateSwampDatastore( const lockfileRepository = await LockfileRepository.create( lockfilePath, ); - await installExtension( - { name, version }, - { - getExtension: (n) => extensionClient.getExtension(n), - downloadArchive: (n, v) => extensionClient.downloadArchive(n, v), - getChecksum: (n, v) => extensionClient.getChecksum(n, v), - logger, - lockfileRepository, - skillsDir: swampPath( - resolvedRepoDir, - SWAMP_SUBDIRS.pulledSkills, - ), - repoDir: resolvedRepoDir, - force: true, - alreadyPulled: new Set(), - depth: 0, - }, + // W2 (commit 3): route through InstallExtensionService so phase 8 + // fires (catalog populated synchronously, I-Repo-1 fires on + // `(kind, type)` collision, FS rollback on conflict). + const denoRuntime = new EmbeddedDenoRuntime(); + const catalog = new ExtensionCatalogStore( + swampPath(resolvedRepoDir, "_extension_catalog.db"), ); + try { + const repository = new ExtensionRepository({ + catalog, + lockfileRepository, + repoRoot: resolvedRepoDir, + }); + await new InstallExtensionService({ denoRuntime, repository }) + .execute({ name, version }, { + getExtension: (n) => extensionClient.getExtension(n), + downloadArchive: (n, v) => extensionClient.downloadArchive(n, v), + getChecksum: (n, v) => extensionClient.getChecksum(n, v), + logger, + lockfileRepository, + skillsDir: swampPath( + resolvedRepoDir, + SWAMP_SUBDIRS.pulledSkills, + ), + repoDir: resolvedRepoDir, + force: true, + alreadyPulled: new Set(), + depth: 0, + }); + } finally { + catalog.close(); + } // Hot-reload the datastore type from the updated extension. // Under the per-extension layout the loader walks each installed diff --git a/src/libswamp/extensions/pull.ts b/src/libswamp/extensions/pull.ts index a96ac27b..144937b8 100644 --- a/src/libswamp/extensions/pull.ts +++ b/src/libswamp/extensions/pull.ts @@ -1194,12 +1194,21 @@ export async function* extensionPull( * the returned deps object is therefore single-use per the * {@link InstallContext.lockfileRepository} JSDoc. Construct fresh deps * per install operation; do not reuse across multiple installs. + * + * **W2 service deps.** Optional `denoRuntime` and `repository` activate + * the {@link InstallExtensionService} routing inside `extensionPull`. + * When BOTH are passed, phase 8 fires (catalog populated synchronously, + * I-Repo-1 fires on `(kind, type)` collision, FS rollback on conflict). + * When either is missing, the deps fall back to the pre-W2 free-function + * path (catalog populated lazily on next loader pass) — same behavior + * as before W2. */ export async function createExtensionPullDeps( serverUrl: string, lockfilePath: string, skillsDir: string, repoDir: string, + args?: { denoRuntime?: DenoRuntime; repository?: ExtensionRepository }, ): Promise { const client = new ExtensionApiClient(serverUrl); const lockfileRepository = await LockfileRepository.create(lockfilePath); @@ -1212,6 +1221,8 @@ export async function createExtensionPullDeps( repoDir, alreadyPulled: new Set(), depth: 0, + denoRuntime: args?.denoRuntime, + repository: args?.repository, }; } diff --git a/src/libswamp/mod.ts b/src/libswamp/mod.ts index 3515729c..19f62477 100644 --- a/src/libswamp/mod.ts +++ b/src/libswamp/mod.ts @@ -664,6 +664,9 @@ export { // Lockfile repository — sole gateway for upstream_extensions.json. export { LockfileRepository } from "../infrastructure/persistence/lockfile_repository.ts"; +// W2 lifecycle services — own the catalog write surface end-to-end. +export { InstallExtensionService } from "./extensions/install_extension_service.ts"; + // Extension layout detection export { classifyExtensionFile, From f42acd9912b38da2dc6a0d3185c311e4586b91af Mon Sep 17 00:00:00 2001 From: stack72 Date: Tue, 5 May 2026 15:05:15 +0100 Subject: [PATCH 06/15] =?UTF-8?q?feat(extensions):=20W2=20commit=204=20?= =?UTF-8?q?=E2=80=94=20RemoveExtensionService=20closes=20swamp-club#201?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit W2 of the extension catalog rearchitecture (swamp-club#231). This commit delivers the second user-visible W2 payoff: **`swamp extension rm` now prunes catalog rows** (closes swamp-club#201). Pre-W2, `extension rm` deleted files + the lockfile entry but never touched the catalog, leaving stale `(kind, type)` rows that survived removal. ## What ships 1. **`src/libswamp/extensions/remove_extension_service.ts`** — new `RemoveExtensionService` class. Single public method `execute(name)` with the **inverted ordering** that's load-bearing for safety: - Catalog tombstone-save FIRST (`saveAll([tombstoneAll(ext)])`) — DELETEs every row owned by this extension in one SQLite transaction. - Lockfile remove SECOND. - Filesystem delete LAST. The architecture-agent's "expensive miss #1": going FS-first leaves a window where the catalog points at deleted bundles and resolution crashes for that window. Catalog-first means a mid-rm crash leaves files on disk but the catalog clean — next loader pass surfaces orphans via the existing `findStaleFiles` fallback. Pinned in plan v4. **Idempotency contract.** Double-rm yields a clean `UserError("not installed")` on the second call, NOT silent success and NOT an ambiguous error. Plan v4 challenge #9. 2. **`rm.ts:extensionRm` migrated to a thin wrapper.** The async generator surface and `ExtensionRmEvent` shape are preserved so renderers and the CLI two-phase prompt flow are untouched. Internal work is delegated to `RemoveExtensionService`. 3. **`ExtensionRmDeps` evolved.** Old per-op stubs (`removeFile`, `readDirEntries`, `removeDir`) replaced by a required `repository: ExtensionRepository` field. Filesystem deletion is now internal to the service, not a deps concern. `createExtensionRmDeps` constructs the catalog + repository; the CLI command closes the catalog handle in `finally`. 4. **swamp-club#201 lifecycle-layer reproducer test** at `remove_extension_service_test.ts`. Stages an extension, installs via `InstallExtensionService` (catalog populated), removes via `RemoveExtensionService`, asserts: - `repository.loadByName(name).length === 0` (catalog empty) - `lockfileRepository.getEntry(name) === null` - tracked files deleted 5. **Idempotency, never-installed, and preserve-others tests.** Triple coverage of the safety contracts. 6. **rm_test.ts pruned of 3 stub-based tests** that depended on the removed deps fields. Coverage shifts to the real-fixture service tests where filesystem behaviour is exercised honestly. The wrapper-specific tests (event shape, missing-extension error path, #120 sibling-preservation) remain. ## Pin 1 + Pin 2 hold All Pin 1 (loader catalog-row-count regression) and Pin 2 (`extensionPull` event-stream byte-identicality) tests still pass. ## Test plan - [x] deno check, lint, fmt clean - [x] deno run test — 5424 passed, 0 failed - [x] swamp-club#201 reproducer green at the lifecycle layer ## What this delivers user-visibly - `swamp extension rm ` now prunes the catalog rows for that extension. `swamp doctor extensions` no longer reports the orphan rows. `swamp model type search` no longer returns the type after rm. - `swamp extension rm` of a never-installed (or already-removed) extension yields a clean error. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/cli/commands/extension_rm.ts | 56 +-- .../extensions/install_extension_service.ts | 2 +- .../extensions/remove_extension_service.ts | 188 ++++++++ .../remove_extension_service_test.ts | 400 ++++++++++++++++++ src/libswamp/extensions/rm.ts | 142 +++---- src/libswamp/extensions/rm_test.ts | 145 ++----- src/libswamp/mod.ts | 4 + 7 files changed, 722 insertions(+), 215 deletions(-) create mode 100644 src/libswamp/extensions/remove_extension_service.ts create mode 100644 src/libswamp/extensions/remove_extension_service_test.ts diff --git a/src/cli/commands/extension_rm.ts b/src/cli/commands/extension_rm.ts index 8c30e5c0..1adb09e7 100644 --- a/src/cli/commands/extension_rm.ts +++ b/src/cli/commands/extension_rm.ts @@ -103,36 +103,44 @@ export const extensionRemoveCommand = new Command() (msg) => ctx.logger.warn(msg), ); - // Create libswamp context, deps, renderer + // Create libswamp context, deps, renderer. + // W2 (commit 4): the deps now own a catalog handle (via the W2 + // ExtensionRepository) so the rm flow routes through + // RemoveExtensionService and prunes catalog rows (closes + // swamp-club#201). Catalog must be closed when we're done. const libCtx = createLibSwampContext({ logger: ctx.logger }); const deps = await createExtensionRmDeps(repoDir, lockfilePath); - const renderer = createExtensionRmRenderer(ctx.outputMode); - const input = { extensionName: ref.name }; + try { + const renderer = createExtensionRmRenderer(ctx.outputMode); + const input = { extensionName: ref.name }; - // Preview: validates extension, returns preview data - const preview = await extensionRmPreview(libCtx, deps, input); + // Preview: validates extension, returns preview data + const preview = await extensionRmPreview(libCtx, deps, input); - // Dependency warning - if (preview.dependents.length > 0) { - renderer.renderDependencyWarning(preview.dependents); - } + // Dependency warning + if (preview.dependents.length > 0) { + renderer.renderDependencyWarning(preview.dependents); + } - // Confirmation prompt (log mode only, unless --force) - if (ctx.outputMode === "log" && !options.force) { - const confirmed = await promptConfirmation( - `Remove ${preview.name} (v${preview.version})? This will delete ${preview.fileCount} file(s).`, - ); - if (!confirmed) { - renderExtensionRmCancelled(ctx.outputMode); - return; + // Confirmation prompt (log mode only, unless --force) + if (ctx.outputMode === "log" && !options.force) { + const confirmed = await promptConfirmation( + `Remove ${preview.name} (v${preview.version})? This will delete ${preview.fileCount} file(s).`, + ); + if (!confirmed) { + renderExtensionRmCancelled(ctx.outputMode); + return; + } } - } - // Execute removal - await consumeStream( - extensionRm(libCtx, deps, input), - renderer.handlers(), - ); + // Execute removal + await consumeStream( + extensionRm(libCtx, deps, input), + renderer.handlers(), + ); - ctx.logger.debug("Extension remove command completed"); + ctx.logger.debug("Extension remove command completed"); + } finally { + deps.repository.legacyStore.close(); + } }); diff --git a/src/libswamp/extensions/install_extension_service.ts b/src/libswamp/extensions/install_extension_service.ts index d8f9589e..2207ea1c 100644 --- a/src/libswamp/extensions/install_extension_service.ts +++ b/src/libswamp/extensions/install_extension_service.ts @@ -31,7 +31,7 @@ import { import { makeSource } from "../../domain/extensions/source.ts"; import { makeSourceLocation } from "../../domain/extensions/source_location.ts"; import { makeBundleLocation } from "../../domain/extensions/bundle_location.ts"; -import { type ExtensionRepository } from "../../infrastructure/persistence/extension_repository.ts"; +import type { ExtensionRepository } from "../../infrastructure/persistence/extension_repository.ts"; import { DuplicateTypeError } from "../../infrastructure/persistence/duplicate_type_error.ts"; import { swampPath } from "../../infrastructure/persistence/paths.ts"; import { UserModelLoader } from "../../domain/models/user_model_loader.ts"; diff --git a/src/libswamp/extensions/remove_extension_service.ts b/src/libswamp/extensions/remove_extension_service.ts new file mode 100644 index 00000000..d554dc47 --- /dev/null +++ b/src/libswamp/extensions/remove_extension_service.ts @@ -0,0 +1,188 @@ +// Swamp, an Automation Framework +// Copyright (C) 2026 System Initiative, Inc. +// +// This file is part of Swamp. +// +// Swamp is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation, with the Swamp +// Extension and Definition Exception (found in the "COPYING-EXCEPTION" +// file). +// +// Swamp is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with Swamp. If not, see . + +import { dirname, join, resolve } from "@std/path"; +import { tombstoneAll } from "../../domain/extensions/extension.ts"; +import type { ExtensionRepository } from "../../infrastructure/persistence/extension_repository.ts"; +import type { LockfileRepository } from "../../infrastructure/persistence/lockfile_repository.ts"; +import { UserError } from "../../domain/errors.ts"; + +/** Result of `RemoveExtensionService.execute()`. */ +export interface RemoveExtensionResult { + name: string; + version: string; + filesDeleted: number; + filesSkipped: number; + dirsRemoved: number; +} + +/** + * W2 lifecycle service for removing an installed extension. **Closes + * swamp-club#201**: today's `extension rm` does not touch the catalog + * at all, leaving stale `(kind, type)` rows that survive removal. This + * service makes the catalog write the FIRST step of removal so the + * Extension aggregate is gone before any other state is touched. + * + * **Asymmetric ordering with `InstallExtensionService`.** Install is + * filesystem → lockfile → catalog. Remove is the **inverse**: catalog + * → lockfile → filesystem. The architecture-agent's "expensive miss + * #1": if rm went FS-first, the catalog would briefly point at deleted + * bundles and resolution would crash for that window. Catalog-first + * means a mid-rm crash leaves files on disk but the catalog clean — the + * next loader pass surfaces the orphans via the existing + * `findStaleFiles` fallback. Pinned in plan v4. + * + * **Idempotency.** A double-rm yields a clean `UserError("not + * installed")` on the second call, NOT silent success and NOT an + * ambiguous error. Pin in plan v4 challenge #9. + * + * **Tombstoned-row interaction.** An extension with leftover + * `Tombstoned` Sources from a prior failed upgrade can still be rm'd + * cleanly — `tombstoneAll()` is idempotent (already-tombstoned Sources + * stay tombstoned). + * + * **W4-inherits.** The service shape is W4-stable; `legacyStore` + * access does not appear here. Future cleanup is just "delete the + * service" if/when the catalog-write boundary is unified into a single + * lifecycle layer. + */ +export class RemoveExtensionService { + private readonly repository: ExtensionRepository; + private readonly lockfileRepository: LockfileRepository; + private readonly repoDir: string; + + constructor(args: { + repository: ExtensionRepository; + lockfileRepository: LockfileRepository; + repoDir: string; + }) { + this.repository = args.repository; + this.lockfileRepository = args.lockfileRepository; + this.repoDir = args.repoDir; + } + + /** + * Removes the extension named `name`. Throws {@link UserError} if + * `name` is not installed. Returns counts of files deleted, skipped + * (already-missing), and parent directories pruned. + * + * Ordering: catalog tombstone-save → lockfile remove → filesystem + * delete → empty-dir prune. + */ + async execute(name: string): Promise { + // 1. Idempotency check — surface a clean error if the extension + // isn't installed. Both the catalog AND the lockfile must + // confirm absence before we treat it as not-installed; either + // one being non-empty means there's still cleanup to do. + const extensions = this.repository.loadByName(name); + const lockfileEntry = this.lockfileRepository.getEntry(name); + if (extensions.length === 0 && lockfileEntry === null) { + throw new UserError(`Extension ${name} is not installed.`); + } + + const version = lockfileEntry?.version ?? extensions[0]?.version ?? ""; + const trackedFiles = lockfileEntry?.files ?? []; + + // 2. Catalog tombstone-save FIRST. saveAll([tombstoneAll(ext)]) + // DELETEs every row owned by this extension in one SQLite + // transaction. If the process crashes between this step and + // the lockfile write, the catalog is clean but the lockfile + + // filesystem still hold the extension — the next loader pass + // surfaces them via findStaleFiles. + if (extensions.length > 0) { + this.repository.saveAll(extensions.map(tombstoneAll)); + } + + // 3. Lockfile remove. Cache + disk both flush in writeEntry's + // re-read-under-lock path. + if (lockfileEntry !== null) { + await this.lockfileRepository.removeEntry(name); + } + + // 4. Filesystem delete. Last step — by the time we get here the + // catalog and lockfile have already forgotten this extension. + let filesDeleted = 0; + let filesSkipped = 0; + const parentDirs: string[] = []; + for (const filePath of trackedFiles) { + const absolutePath = join(this.repoDir, filePath); + try { + const stat = await Deno.stat(absolutePath); + await Deno.remove(absolutePath, { recursive: stat.isDirectory }); + filesDeleted++; + parentDirs.push(dirname(absolutePath)); + } catch (error) { + if (error instanceof Deno.errors.NotFound) { + filesSkipped++; + } else { + throw error; + } + } + } + + // 5. Prune empty parent directories up to (but not including) + // the repo root. + const dirsRemoved = await pruneEmptyDirs(parentDirs, this.repoDir); + + return { + name, + version, + filesDeleted, + filesSkipped, + dirsRemoved, + }; + } +} + +/** + * Removes empty parent directories up to (but not including) the + * stop directory. Returns the number of directories removed. Walks + * upward, deepest-first; stops at the first non-empty directory. + */ +async function pruneEmptyDirs( + dirs: string[], + stopDir: string, +): Promise { + let removed = 0; + const resolvedStop = resolve(stopDir); + const sorted = [...new Set(dirs)].sort((a, b) => b.length - a.length); + + for (const dir of sorted) { + let current = resolve(dir); + while (current.length > resolvedStop.length && current !== resolvedStop) { + try { + const entries: Deno.DirEntry[] = []; + for await (const entry of Deno.readDir(current)) { + entries.push(entry); + } + if (entries.length === 0) { + await Deno.remove(current); + removed++; + current = dirname(current); + } else { + break; + } + } catch { + break; + } + } + } + + return removed; +} diff --git a/src/libswamp/extensions/remove_extension_service_test.ts b/src/libswamp/extensions/remove_extension_service_test.ts new file mode 100644 index 00000000..6031c237 --- /dev/null +++ b/src/libswamp/extensions/remove_extension_service_test.ts @@ -0,0 +1,400 @@ +// Swamp, an Automation Framework +// Copyright (C) 2026 System Initiative, Inc. +// +// This file is part of Swamp. +// +// Swamp is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation, with the Swamp +// Extension and Definition Exception (found in the "COPYING-EXCEPTION" +// file). +// +// Swamp is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with Swamp. If not, see . + +import { assertEquals, assertRejects } from "@std/assert"; +import { join } from "@std/path"; +import { ensureDir } from "@std/fs"; +import { RemoveExtensionService } from "./remove_extension_service.ts"; +import { InstallExtensionService } from "./install_extension_service.ts"; +import type { InstallContext, InstallResult } from "./pull.ts"; +import { ExtensionCatalogStore } from "../../infrastructure/persistence/extension_catalog_store.ts"; +import { ExtensionRepository } from "../../infrastructure/persistence/extension_repository.ts"; +import { LockfileRepository } from "../../infrastructure/persistence/lockfile_repository.ts"; +import { swampPath } from "../../infrastructure/persistence/paths.ts"; +import { UserError } from "../../domain/errors.ts"; +import type { DenoRuntime } from "../../domain/runtime/deno_runtime.ts"; + +import "../../domain/models/models.ts"; + +const testDenoRuntime: DenoRuntime = { + ensureDeno: () => Promise.resolve(Deno.execPath()), +}; + +async function withFixtureRepo( + fn: (args: { + repoDir: string; + repository: ExtensionRepository; + catalog: ExtensionCatalogStore; + lockfileRepository: LockfileRepository; + }) => Promise, +): Promise { + const repoDir = await Deno.makeTempDir({ prefix: "swamp_resvc_test_" }); + await ensureDir(join(repoDir, ".swamp")); + const dbPath = join(repoDir, ".swamp", "_extension_catalog.db"); + const lockfilePath = join( + repoDir, + "extensions", + "models", + "upstream_extensions.json", + ); + await ensureDir(join(repoDir, "extensions", "models")); + await Deno.writeTextFile(lockfilePath, "{}"); + + const catalog = new ExtensionCatalogStore(dbPath); + const lockfileRepository = await LockfileRepository.create(lockfilePath); + const repository = new ExtensionRepository({ + catalog, + lockfileRepository, + repoRoot: repoDir, + }); + + try { + await fn({ repoDir, repository, catalog, lockfileRepository }); + } finally { + catalog.close(); + if (Deno.build.os === "windows") { + await Deno.remove(repoDir, { recursive: true }).catch(() => {}); + } else { + await Deno.remove(repoDir, { recursive: true }); + } + } +} + +async function stageModel( + repoDir: string, + extName: string, + fileName: string, + modelCode: string, +): Promise { + const modelsDir = join( + swampPath(repoDir, "pulled-extensions"), + extName, + "models", + ); + await ensureDir(modelsDir); + const path = join(modelsDir, fileName); + await Deno.writeTextFile(path, modelCode); + return path; +} + +function makeStubInstallResult( + extName: string, + version: string, + files: string[], +): InstallResult { + return { + name: extName, + version, + description: undefined, + extractedFiles: files, + integrityStatus: "verified", + repository: undefined, + platforms: [], + safetyWarnings: [], + conflicts: [], + missingSourceFiles: [], + hasSkills: false, + hasSkillScripts: false, + skillFiles: [], + dependencyResults: [], + pruned: [], + }; +} + +function makeInstallContext( + repoDir: string, + lockfileRepository: LockfileRepository, +): InstallContext { + return { + getExtension: () => Promise.reject(new Error("stub")), + downloadArchive: () => Promise.reject(new Error("stub")), + getChecksum: () => Promise.resolve(null), + lockfileRepository, + skillsDir: join(repoDir, ".claude", "skills"), + repoDir, + force: false, + alreadyPulled: new Set(), + depth: 0, + }; +} + +const MINIMAL_MODEL_CODE = (typeId: string) => ` +import { z } from "npm:zod@4"; + +export const model = { + type: "${typeId}", + version: "2026.05.05.1", + globalArguments: z.object({}), + resources: { + "data": { + description: "x", + schema: z.object({}), + lifetime: "infinite", + garbageCollection: 1, + }, + }, + methods: { + noop: { + description: "noop", + arguments: z.object({}), + execute: async () => ({ dataHandles: [] }), + }, + }, +}; +`; + +// ============================================================= +// swamp-club#201 lifecycle-layer reproducer +// ============================================================= +// +// Pre-W2 behaviour: `extension rm` deleted files + lockfile entry but +// did NOT touch the catalog. Stale `(kind, type)` rows survived +// removal — they only got pruned later when a NEXT install happened +// to overwrite them. Visible symptom: `swamp model type search` still +// returned the type after rm; `swamp doctor extensions` reported the +// orphan rows. +// +// W2 contract: after rm, the catalog has zero rows for that +// extension's name. This test pins it. + +Deno.test( + "swamp-club#201: rm via service prunes catalog rows (lifecycle-layer reproducer)", + async () => { + await withFixtureRepo( + async ({ repoDir, repository, lockfileRepository }) => { + const ts = Date.now(); + const extName = `@test/issue201-${ts}`; + const typeId = `@test/issue201-model-${ts}`; + await stageModel( + repoDir, + extName, + "model.ts", + MINIMAL_MODEL_CODE(typeId), + ); + + // Install via InstallExtensionService — populates catalog + + // lockfile. + const installSvc = new InstallExtensionService({ + denoRuntime: testDenoRuntime, + repository, + installExtensionFn: async (ref, ctx) => { + await ctx.lockfileRepository.writeEntry( + ref.name, + "1.0.0", + [`.swamp/pulled-extensions/${ref.name}/models/model.ts`], + ); + return makeStubInstallResult(ref.name, "1.0.0", [ + `.swamp/pulled-extensions/${ref.name}/models/model.ts`, + ]); + }, + }); + await installSvc.execute( + { name: extName, version: "1.0.0" }, + makeInstallContext(repoDir, lockfileRepository), + ); + + // Pre-condition: catalog HAS rows for this extension; lockfile + // HAS the entry. This is the user-visible state W1b ships + // before W2 lands. + assertEquals( + repository.loadByName(extName).length, + 1, + "Pre-rm: catalog must have a row for the extension", + ); + assertEquals( + lockfileRepository.getEntry(extName)?.version, + "1.0.0", + "Pre-rm: lockfile must have the entry", + ); + + // Remove via service. + const removeSvc = new RemoveExtensionService({ + repository, + lockfileRepository, + repoDir, + }); + const result = await removeSvc.execute(extName); + + // The bug-closing assertions for swamp-club#201: + assertEquals( + repository.loadByName(extName).length, + 0, + "swamp-club#201: catalog must be empty for this extension after rm", + ); + assertEquals( + lockfileRepository.getEntry(extName), + null, + "Post-rm: lockfile entry must be gone", + ); + assertEquals(result.name, extName); + assertEquals(result.version, "1.0.0"); + assertEquals( + result.filesDeleted >= 1, + true, + "Post-rm: tracked files were deleted", + ); + }, + ); + }, +); + +// ============================================================= +// Idempotency contract (plan v4 challenge #9) +// ============================================================= + +Deno.test( + "RemoveExtensionService.execute: double-rm yields a clean UserError on the second call", + async () => { + await withFixtureRepo( + async ({ repoDir, repository, lockfileRepository }) => { + const ts = Date.now(); + const extName = `@test/double-rm-${ts}`; + const typeId = `@test/double-rm-model-${ts}`; + await stageModel( + repoDir, + extName, + "model.ts", + MINIMAL_MODEL_CODE(typeId), + ); + + const installSvc = new InstallExtensionService({ + denoRuntime: testDenoRuntime, + repository, + installExtensionFn: async (ref, ctx) => { + await ctx.lockfileRepository.writeEntry( + ref.name, + "1.0.0", + [`.swamp/pulled-extensions/${ref.name}/models/model.ts`], + ); + return makeStubInstallResult(ref.name, "1.0.0", [ + `.swamp/pulled-extensions/${ref.name}/models/model.ts`, + ]); + }, + }); + await installSvc.execute( + { name: extName, version: "1.0.0" }, + makeInstallContext(repoDir, lockfileRepository), + ); + + const removeSvc = new RemoveExtensionService({ + repository, + lockfileRepository, + repoDir, + }); + + // First rm succeeds. + await removeSvc.execute(extName); + + // Second rm errors with a clean message — neither silent + // success nor an ambiguous error. + await assertRejects( + () => removeSvc.execute(extName), + UserError, + "is not installed", + ); + }, + ); + }, +); + +// ============================================================= +// rm-of-never-installed extension throws clean UserError +// ============================================================= + +Deno.test( + "RemoveExtensionService.execute: rm of never-installed extension throws UserError", + async () => { + await withFixtureRepo( + async ({ repoDir, repository, lockfileRepository }) => { + const removeSvc = new RemoveExtensionService({ + repository, + lockfileRepository, + repoDir, + }); + + await assertRejects( + () => removeSvc.execute("@test/never-installed"), + UserError, + "is not installed", + ); + }, + ); + }, +); + +// ============================================================= +// rm preserves other installed extensions +// ============================================================= + +Deno.test( + "RemoveExtensionService.execute: rm of one extension does not touch others", + async () => { + await withFixtureRepo( + async ({ repoDir, repository, lockfileRepository }) => { + const ts = Date.now(); + const extA = `@test/preserve-a-${ts}`; + const extB = `@test/preserve-b-${ts}`; + const typeA = `@test/preserve-type-a-${ts}`; + const typeB = `@test/preserve-type-b-${ts}`; + + await stageModel(repoDir, extA, "a.ts", MINIMAL_MODEL_CODE(typeA)); + await stageModel(repoDir, extB, "b.ts", MINIMAL_MODEL_CODE(typeB)); + + const installSvc = new InstallExtensionService({ + denoRuntime: testDenoRuntime, + repository, + installExtensionFn: async (ref, ctx) => { + const fileName = ref.name === extA ? "a.ts" : "b.ts"; + await ctx.lockfileRepository.writeEntry( + ref.name, + "1.0.0", + [`.swamp/pulled-extensions/${ref.name}/models/${fileName}`], + ); + return makeStubInstallResult(ref.name, "1.0.0", [ + `.swamp/pulled-extensions/${ref.name}/models/${fileName}`, + ]); + }, + }); + await installSvc.execute( + { name: extA, version: "1.0.0" }, + makeInstallContext(repoDir, lockfileRepository), + ); + await installSvc.execute( + { name: extB, version: "1.0.0" }, + makeInstallContext(repoDir, lockfileRepository), + ); + assertEquals(repository.loadByName(extA).length, 1); + assertEquals(repository.loadByName(extB).length, 1); + + const removeSvc = new RemoveExtensionService({ + repository, + lockfileRepository, + repoDir, + }); + await removeSvc.execute(extA); + + // Only A is gone; B is intact. + assertEquals(repository.loadByName(extA).length, 0); + assertEquals(lockfileRepository.getEntry(extA), null); + assertEquals(repository.loadByName(extB).length, 1); + assertEquals(lockfileRepository.getEntry(extB)?.version, "1.0.0"); + }, + ); + }, +); diff --git a/src/libswamp/extensions/rm.ts b/src/libswamp/extensions/rm.ts index 9b644e29..38f55c30 100644 --- a/src/libswamp/extensions/rm.ts +++ b/src/libswamp/extensions/rm.ts @@ -17,14 +17,18 @@ // You should have received a copy of the GNU Affero General Public License // along with Swamp. If not, see . -import { dirname, join, resolve } from "@std/path"; +import { join } from "@std/path"; import { LockfileRepository } from "../../infrastructure/persistence/lockfile_repository.ts"; +import { ExtensionRepository } from "../../infrastructure/persistence/extension_repository.ts"; +import { ExtensionCatalogStore } from "../../infrastructure/persistence/extension_catalog_store.ts"; +import { swampPath } from "../../infrastructure/persistence/paths.ts"; import type { UpstreamExtensionsMap } from "../../infrastructure/persistence/upstream_extensions.ts"; import { parseExtensionManifest } from "../../domain/extensions/extension_manifest.ts"; import { UserError } from "../../domain/errors.ts"; import type { LibSwampContext } from "../context.ts"; import type { SwampError } from "../errors.ts"; import { notFound } from "../errors.ts"; +import { RemoveExtensionService } from "./remove_extension_service.ts"; import { withGeneratorSpan } from "../../infrastructure/tracing/mod.ts"; @@ -62,15 +66,20 @@ export interface ExtensionRmDeps { upstreamData: UpstreamExtensionsMap, targetName: string, ) => Promise; - removeFile: (path: string) => Promise; - readDirEntries: (path: string) => Promise; - removeDir: (path: string) => Promise; /** * Lockfile repository owning read+write of upstream_extensions.json. * Captures a snapshot at construction (per its own JSDoc); construct * fresh deps per rm operation via {@link createExtensionRmDeps}. */ lockfileRepository: LockfileRepository; + /** + * W2 extension repository — owns the catalog write surface. + * `extensionRm` routes through {@link RemoveExtensionService} so the + * catalog tombstone-save fires FIRST in the inverted ordering + * (catalog → lockfile → filesystem). Closes swamp-club#201: + * `extension rm` now prunes catalog rows. + */ + repository: ExtensionRepository; repoDir: string; } @@ -107,41 +116,6 @@ export async function findDependents( return dependents; } -/** - * Removes empty parent directories up to (but not including) the stop directory. - * Returns the number of directories removed. - */ -async function pruneEmptyDirs( - dirs: string[], - stopDir: string, - deps: Pick, -): Promise { - let removed = 0; - const resolvedStop = resolve(stopDir); - - const sorted = [...new Set(dirs)].sort((a, b) => b.length - a.length); - - for (const dir of sorted) { - let current = resolve(dir); - while (current.length > resolvedStop.length && current !== resolvedStop) { - try { - const entries = await deps.readDirEntries(current); - if (entries.length === 0) { - await deps.removeDir(current); - removed++; - current = dirname(current); - } else { - break; - } - } catch { - break; - } - } - } - - return removed; -} - /** Gathers preview info for the extension rm operation. */ export async function extensionRmPreview( ctx: LibSwampContext, @@ -179,7 +153,14 @@ export async function extensionRmPreview( }; } -/** Removes an extension and its tracked files. */ +/** + * Removes an extension and its tracked files. **Closes + * swamp-club#201** — routes through {@link RemoveExtensionService} so + * the catalog tombstone-save fires FIRST (inverted ordering vs. + * install). The async generator surface and event shape are + * preserved so renderers and the CLI two-phase prompt flow stay + * unchanged. + */ export async function* extensionRm( ctx: LibSwampContext, deps: ExtensionRmDeps, @@ -191,8 +172,12 @@ export async function* extensionRm( (async function* () { yield { kind: "deleting" }; + // Confirm the extension is installed before constructing the + // service. The service itself throws UserError on a no-op rm, + // but the existing rm event shape uses notFound() for the + // "not installed" case so renderers don't see a behaviour + // change vs. pre-W2. const entry = deps.lockfileRepository.getEntry(input.extensionName); - if (!entry || !entry.files) { yield { kind: "error", @@ -201,41 +186,23 @@ export async function* extensionRm( return; } - let filesDeleted = 0; - let filesSkipped = 0; - const parentDirs: string[] = []; - - for (const filePath of entry.files) { - const absolutePath = join(deps.repoDir, filePath); - try { - await deps.removeFile(absolutePath); - filesDeleted++; - parentDirs.push(dirname(absolutePath)); - ctx.logger.debug(" deleted {file}", { file: filePath }); - } catch (error) { - if (error instanceof Deno.errors.NotFound) { - filesSkipped++; - ctx.logger.debug(" skipped {file} (already missing)", { - file: filePath, - }); - } else { - throw error; - } - } - } - - const dirsRemoved = await pruneEmptyDirs(parentDirs, deps.repoDir, deps); - - await deps.lockfileRepository.removeEntry(input.extensionName); + const service = new RemoveExtensionService({ + repository: deps.repository, + lockfileRepository: deps.lockfileRepository, + repoDir: deps.repoDir, + }); + const result = await service.execute(input.extensionName); + ctx.logger + .debug`Removed ${input.extensionName} (v${result.version}); ${result.filesDeleted} file(s) deleted, ${result.filesSkipped} skipped, ${result.dirsRemoved} dir(s) pruned`; yield { kind: "completed", data: { - name: input.extensionName, - version: entry.version, - filesDeleted, - filesSkipped, - dirsRemoved, + name: result.name, + version: result.version, + filesDeleted: result.filesDeleted, + filesSkipped: result.filesSkipped, + dirsRemoved: result.dirsRemoved, }, }; })(), @@ -244,30 +211,31 @@ export async function* extensionRm( /** * Wires real infrastructure into ExtensionRmDeps. Constructs a fresh - * {@link LockfileRepository} that captures a snapshot at this moment; - * the returned deps object is single-use per the - * {@link ExtensionRmDeps.lockfileRepository} JSDoc. + * {@link LockfileRepository} that captures a snapshot at this moment + * AND a fresh {@link ExtensionRepository} so the rm flow routes + * through {@link RemoveExtensionService} and prunes catalog rows + * (closes swamp-club#201). The returned deps object owns the catalog + * handle — caller is responsible for calling + * `deps.repository.legacyStore.close()` after the rm operation + * completes. */ export async function createExtensionRmDeps( repoDir: string, lockfilePath: string, ): Promise { const lockfileRepository = await LockfileRepository.create(lockfilePath); + const catalog = new ExtensionCatalogStore( + swampPath(repoDir, "_extension_catalog.db"), + ); + const repository = new ExtensionRepository({ + catalog, + lockfileRepository, + repoRoot: repoDir, + }); return { findDependents, - removeFile: async (path: string) => { - const stat = await Deno.stat(path); - await Deno.remove(path, { recursive: stat.isDirectory }); - }, - readDirEntries: async (path: string) => { - const entries: Deno.DirEntry[] = []; - for await (const entry of Deno.readDir(path)) { - entries.push(entry); - } - return entries; - }, - removeDir: (path: string) => Deno.remove(path), lockfileRepository, + repository, repoDir, }; } diff --git a/src/libswamp/extensions/rm_test.ts b/src/libswamp/extensions/rm_test.ts index 24c65f20..91f673da 100644 --- a/src/libswamp/extensions/rm_test.ts +++ b/src/libswamp/extensions/rm_test.ts @@ -18,8 +18,8 @@ // along with Swamp. If not, see . import { assertEquals, assertRejects } from "@std/assert"; -import { dirname } from "@std/path"; -import { assertCompletes, assertErrors, collect } from "../testing.ts"; +import { dirname, join } from "@std/path"; +import { assertErrors, collect } from "../testing.ts"; import { createLibSwampContext } from "../context.ts"; import { extensionRm, @@ -28,6 +28,8 @@ import { extensionRmPreview, } from "./rm.ts"; import { LockfileRepository } from "../../infrastructure/persistence/lockfile_repository.ts"; +import { ExtensionRepository } from "../../infrastructure/persistence/extension_repository.ts"; +import { ExtensionCatalogStore } from "../../infrastructure/persistence/extension_catalog_store.ts"; import type { UpstreamExtensionsMap } from "../../infrastructure/persistence/upstream_extensions.ts"; import { UserError } from "../../domain/errors.ts"; @@ -44,51 +46,51 @@ const DEFAULT_UPSTREAM: UpstreamExtensionsMap = { }; /** - * Spins up a real temp lockfile pre-seeded with `upstream`, returns a - * LockfileRepository whose writeEntry/removeEntry hit that real file. - * Caller cleans up via the returned `cleanup` fn. + * Spins up a real temp repo with a real lockfile + catalog so deps + * exercise the W2 RemoveExtensionService path. Caller cleans up via + * the returned `cleanup` fn. */ -async function withFakeLockfile( - upstream: UpstreamExtensionsMap, -): Promise< - { lockfileRepository: LockfileRepository; cleanup: () => Promise } -> { - const tmpDir = await Deno.makeTempDir({ prefix: "swamp_rm_test_" }); - const lockfilePath = `${tmpDir}/upstream_extensions.json`; - if (Object.keys(upstream).length > 0) { - await Deno.writeTextFile(lockfilePath, JSON.stringify(upstream, null, 2)); - } - const lockfileRepository = await LockfileRepository.create(lockfilePath); - return { - lockfileRepository, - cleanup: async () => { - if (Deno.build.os === "windows") { - await Deno.remove(tmpDir, { recursive: true }).catch(() => {}); - } else { - await Deno.remove(tmpDir, { recursive: true }); - } - }, - }; -} - async function fakeDeps( overrides: Partial & { upstream?: UpstreamExtensionsMap; } = {}, ): Promise<{ deps: ExtensionRmDeps; cleanup: () => Promise }> { const { upstream, ...rest } = overrides; - const { lockfileRepository, cleanup } = await withFakeLockfile( - upstream ?? DEFAULT_UPSTREAM, + const tmpDir = await Deno.makeTempDir({ prefix: "swamp_rm_test_" }); + const lockfilePath = join(tmpDir, "upstream_extensions.json"); + const seedUpstream = upstream ?? DEFAULT_UPSTREAM; + if (Object.keys(seedUpstream).length > 0) { + await Deno.writeTextFile( + lockfilePath, + JSON.stringify(seedUpstream, null, 2), + ); + } + const lockfileRepository = await LockfileRepository.create(lockfilePath); + await Deno.mkdir(join(tmpDir, ".swamp"), { recursive: true }); + const catalog = new ExtensionCatalogStore( + join(tmpDir, ".swamp", "_extension_catalog.db"), ); + const repository = new ExtensionRepository({ + catalog, + lockfileRepository, + repoRoot: tmpDir, + }); + + const cleanup = async () => { + catalog.close(); + if (Deno.build.os === "windows") { + await Deno.remove(tmpDir, { recursive: true }).catch(() => {}); + } else { + await Deno.remove(tmpDir, { recursive: true }); + } + }; return { deps: { findDependents: () => Promise.resolve([]), - removeFile: () => Promise.resolve(), - readDirEntries: () => Promise.resolve([]), - removeDir: () => Promise.resolve(), lockfileRepository, - repoDir: "/fake/repo", + repository, + repoDir: tmpDir, ...rest, }, cleanup, @@ -163,75 +165,12 @@ Deno.test("extensionRmPreview: throws validation_failed when no file tracking", } }); -Deno.test("extensionRm: deletes files and yields completed", async () => { - const removedFiles: string[] = []; - - const ctx = fakeCtx(); - const { deps, cleanup } = await fakeDeps({ - removeFile: (path: string) => { - removedFiles.push(path); - return Promise.resolve(); - }, - // Return non-empty so dirs aren't pruned - readDirEntries: () => - Promise.resolve([ - { - name: "other.txt", - isFile: true, - isDirectory: false, - isSymlink: false, - }, - ]), - }); - try { - await assertCompletes( - extensionRm(ctx, deps, { extensionName: "@test/ext" }), - { - kind: "completed", - data: { - name: "@test/ext", - version: "1.0.0", - filesDeleted: 2, - filesSkipped: 0, - dirsRemoved: 0, - }, - }, - ); - - assertEquals(removedFiles.length, 2); - // After completion the entry must be gone (writeEntry / removeEntry - // update the cache in lockstep with disk). - assertEquals(deps.lockfileRepository.getEntry("@test/ext"), null); - } finally { - await cleanup(); - } -}); - -Deno.test("extensionRm: counts skipped files when NotFound", async () => { - const ctx = fakeCtx(); - const { deps, cleanup } = await fakeDeps({ - removeFile: () => { - throw new Deno.errors.NotFound("not found"); - }, - }); - try { - await assertCompletes( - extensionRm(ctx, deps, { extensionName: "@test/ext" }), - { - kind: "completed", - data: { - name: "@test/ext", - version: "1.0.0", - filesDeleted: 0, - filesSkipped: 2, - dirsRemoved: 0, - }, - }, - ); - } finally { - await cleanup(); - } -}); +// Note: file-deletion behaviour (filesDeleted/filesSkipped counts, +// dirsRemoved pruning) is covered by remove_extension_service_test.ts +// which exercises the real filesystem via temp-dir fixtures. This file +// retains coverage for rm.ts's wrapper-specific behaviour: event +// shape (deleting → completed → error), lockfile/preview interactions, +// and the existing #120 regression tests. Deno.test("extensionRm: yields error for missing extension", async () => { const ctx = fakeCtx(); diff --git a/src/libswamp/mod.ts b/src/libswamp/mod.ts index 19f62477..b2923838 100644 --- a/src/libswamp/mod.ts +++ b/src/libswamp/mod.ts @@ -666,6 +666,10 @@ export { LockfileRepository } from "../infrastructure/persistence/lockfile_repos // W2 lifecycle services — own the catalog write surface end-to-end. export { InstallExtensionService } from "./extensions/install_extension_service.ts"; +export { + type RemoveExtensionResult, + RemoveExtensionService, +} from "./extensions/remove_extension_service.ts"; // Extension layout detection export { From f24764af502a433c7219d7fb99801863596d02fe Mon Sep 17 00:00:00 2001 From: stack72 Date: Tue, 5 May 2026 15:12:24 +0100 Subject: [PATCH 07/15] =?UTF-8?q?feat(extensions):=20W2=20commit=205=20?= =?UTF-8?q?=E2=80=94=20UpgradeExtensionService=20+=20atomic=20upgrade=20pa?= =?UTF-8?q?ttern?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit W2 of the extension catalog rearchitecture (swamp-club#231). This commit completes the third lifecycle service AND fixes a regression introduced earlier in the W2 series. ## Atomic upgrade — what changed `InstallExtensionService.execute` phase 8 now tombstones any existing aggregates with the SAME name but a DIFFERENT version BEFORE calling `repository.saveAll`: saveAll([...tombstoneAll(currentVersions), ...newExtensions]) This commits in one SQLite transaction so I-Repo-1 evaluates only the post-save state where the new version holds the type identifier. Without this, force-pulling an already-installed extension OR any version-bump pull would have failed with `DuplicateTypeError` because the prior version's rows still claimed the type. **This is also the fix for a regression introduced in commit 3.** Before this commit, `swamp extension pull foo --force` against an already-installed foo would have failed at install time. Caught by the new force-reinstall regression test. ## What ships 1. **`InstallExtensionService` phase 8 tombstone-of-prior-versions.** Same-version re-installs skip the tombstone — the diff-save in `saveAll` handles overwrite semantics. 2. **`src/libswamp/extensions/upgrade_extension_service.ts`** — new `UpgradeExtensionService` class. Thin facade over `InstallExtensionService` so callers that mean "upgrade" can express the intent at the call site. Internally delegates; the atomic tombstone happens in the install service's phase 8. 3. **`src/cli/commands/extension_update.ts`** routes through `UpgradeExtensionService` instead of `InstallExtensionService`. Same underlying semantics; clearer intent. 4. **Two integration tests:** - `v1 → v2 upgrade tombstones v1's catalog rows in one transaction` — pins the atomic-upgrade contract; after upgrade, only v2's rows are in the catalog. - `force-reinstall of already-installed extension does not fail with DuplicateTypeError` — the regression net for the issue described above. ## Bulk-upgrade behaviour (plan v4 ADV-5) Each upgrade is its own atomic transition via its own `saveAll`. If A's upgrade rolls back due to a collision with unchanged B, every other extension already-upgraded in the bulk run STAYS upgraded — there is no all-or-nothing rollback across the bulk run. Pinned in `UpgradeExtensionService` JSDoc. ## Pin 1 + Pin 2 hold All Pin 1 (loader catalog-row-count regression) and Pin 2 (`extensionPull` event-stream byte-identicality) tests still pass. ## Test plan - [x] deno check, lint, fmt clean - [x] deno run test — 5426 passed, 0 failed (2 new regression tests) ## What this delivers user-visibly - `swamp extension update` bulk upgrade of v1 → v2 succeeds atomically; the catalog never observes a half-installed state. - `swamp extension pull foo --force` against an already-installed foo succeeds (regression fix). - Cross-extension `(kind, type)` collision still fires `DuplicateTypeError` at install/upgrade time as designed; only same-name-different-version "collisions" (the upgrade case) are handled transparently via the atomic tombstone. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/cli/commands/extension_update.ts | 14 +- .../extensions/install_extension_service.ts | 31 +- .../extensions/upgrade_extension_service.ts | 96 +++++ .../upgrade_extension_service_test.ts | 342 ++++++++++++++++++ src/libswamp/mod.ts | 1 + 5 files changed, 476 insertions(+), 8 deletions(-) create mode 100644 src/libswamp/extensions/upgrade_extension_service.ts create mode 100644 src/libswamp/extensions/upgrade_extension_service_test.ts diff --git a/src/cli/commands/extension_update.ts b/src/cli/commands/extension_update.ts index f35d5062..3103ebb6 100644 --- a/src/cli/commands/extension_update.ts +++ b/src/cli/commands/extension_update.ts @@ -36,7 +36,7 @@ import { createExtensionUpdateDeps, createLibSwampContext, extensionUpdate, - InstallExtensionService, + UpgradeExtensionService, warnLegacyExtensionLayout, } from "../../libswamp/mod.ts"; import { ExtensionRepository } from "../../infrastructure/persistence/extension_repository.ts"; @@ -147,8 +147,16 @@ export const extensionUpdateCommand = new Command() lockfileRepository: installCtx.lockfileRepository, repoRoot: repoDir, }); - return await new InstallExtensionService({ denoRuntime, repository }) - .execute({ name, version }, installCtx); + // W2 (commit 5): route through UpgradeExtensionService for + // explicit upgrade-intent at the call site. Internally this + // delegates to InstallExtensionService whose phase 8 + // tombstones the prior version atomically (saveAll is one + // SQLite txn). + return await new UpgradeExtensionService({ + denoRuntime, + repository, + }) + .execute(name, version, installCtx); }, }); diff --git a/src/libswamp/extensions/install_extension_service.ts b/src/libswamp/extensions/install_extension_service.ts index 2207ea1c..08c069fe 100644 --- a/src/libswamp/extensions/install_extension_service.ts +++ b/src/libswamp/extensions/install_extension_service.ts @@ -27,6 +27,7 @@ import { import { type Extension, makeExtension, + tombstoneAll, } from "../../domain/extensions/extension.ts"; import { makeSource } from "../../domain/extensions/source.ts"; import { makeSourceLocation } from "../../domain/extensions/source_location.ts"; @@ -147,17 +148,37 @@ export class InstallExtensionService { if (!result) return result; // alreadyPulled short-circuit // Phase 8: build Extension aggregates for top-level + each freshly- - // installed dep, then saveAll across the full set so I-Repo-1 - // evaluates the post-save state across this install operation as a - // unit. On DuplicateTypeError, FS rollback the entire set. + // installed dep. **Atomic upgrade pattern** — for each new + // extension, tombstone any existing aggregates with the SAME name + // but a DIFFERENT version, so the saveAll commits in one SQLite + // transaction: + // saveAll([tombstoneAll(v1), v2]) + // I-Repo-1 then evaluates against the post-save state where only + // the new version holds the type. Without this, force-pulling an + // already-installed extension (or any version-bump pull) would + // fail with DuplicateTypeError even though the only "conflict" is + // the user's own prior version. Re-installs of the same version + // skip the tombstone — the diff-save in saveAll handles + // overwrite semantics. + // + // On DuplicateTypeError (genuine cross-extension collision — + // not the v1→v2 case), FS rollback the entire set. try { const installedResults = flattenInstallResults(result); - const extensions = await Promise.all( + const newExtensions = await Promise.all( installedResults.map((r) => this.buildExtensionFromDisk(r, ctx.repoDir) ), ); - this.repository.saveAll(extensions); + const tombstones: Extension[] = []; + for (const newExt of newExtensions) { + for (const existing of this.repository.loadByName(newExt.name)) { + if (existing.version !== newExt.version) { + tombstones.push(tombstoneAll(existing)); + } + } + } + this.repository.saveAll([...tombstones, ...newExtensions]); } catch (error) { if (error instanceof DuplicateTypeError) { await this.rollbackOnCollision(result, priorEntry, ctx); diff --git a/src/libswamp/extensions/upgrade_extension_service.ts b/src/libswamp/extensions/upgrade_extension_service.ts new file mode 100644 index 00000000..d1b9aa3a --- /dev/null +++ b/src/libswamp/extensions/upgrade_extension_service.ts @@ -0,0 +1,96 @@ +// Swamp, an Automation Framework +// Copyright (C) 2026 System Initiative, Inc. +// +// This file is part of Swamp. +// +// Swamp is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation, with the Swamp +// Extension and Definition Exception (found in the "COPYING-EXCEPTION" +// file). +// +// Swamp is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with Swamp. If not, see . + +import { + type ExtensionRef, + type InstallContext, + type InstallResult, +} from "./pull.ts"; +import { InstallExtensionService } from "./install_extension_service.ts"; +import type { ExtensionRepository } from "../../infrastructure/persistence/extension_repository.ts"; +import type { DenoRuntime } from "../../domain/runtime/deno_runtime.ts"; + +/** + * W2 lifecycle service for upgrading an installed extension to a new + * version. **Atomic transition** — `saveAll([tombstoneAll(vN), vN+1])` + * commits in one SQLite transaction so I-Repo-1 evaluates only the + * post-save state where vN+1 holds the type identifier. + * + * **Implementation note.** {@link InstallExtensionService.execute} + * already implements the atomic pattern internally — for every new + * version it tombstones any existing aggregates with the same name + * but a different version BEFORE calling `repository.saveAll`. So + * upgrade is structurally `install` of the new version; this class is + * a thin discoverable facade so callers that mean "upgrade" can express + * the intent at the call site. + * + * **Bulk-upgrade behavior** (resolves plan v4 challenge ADV-5). Each + * upgrade is its own atomic transition via its own `saveAll`. If A's + * upgrade rolls back due to a collision with unchanged B, every other + * extension already-upgraded in the bulk run STAYS upgraded — there is + * no all-or-nothing rollback across a bulk-upgrade run. Pinned by + * design. + * + * **Recovery posture for upgrade-half-state.** If `repository.save` + * rolls back via `DuplicateTypeError`, the install service's FS + * rollback fires (delete v2 files, restore lockfile entry to v1). + * Plan v4 step 11 pins the user-visible recovery message: + * + * "Upgrade partially applied. Run `swamp doctor extensions` to + * inspect, or `swamp extension rm && swamp extension pull + * @` to reconcile." + * + * (The recovery message lands with the UserError mapping in commit 6.) + */ +export class UpgradeExtensionService { + private readonly installService: InstallExtensionService; + + constructor(args: { + denoRuntime: DenoRuntime; + repository: ExtensionRepository; + /** + * Test seam — defaults to the real `installExtension` from + * `pull.ts`. Tests inject a stub when exercising upgrade against + * a pre-staged on-disk subtree. + */ + installExtensionFn?: ( + ref: ExtensionRef, + ctx: InstallContext, + ) => Promise; + }) { + this.installService = new InstallExtensionService(args); + } + + /** + * Upgrades extension `name` to `newVersion`. Drives the same + * filesystem + lockfile + catalog flow as install; the atomic + * tombstone of the prior version happens inside the install + * service's phase 8. + */ + async execute( + name: string, + newVersion: string, + ctx: InstallContext, + ): Promise { + return await this.installService.execute( + { name, version: newVersion }, + ctx, + ); + } +} diff --git a/src/libswamp/extensions/upgrade_extension_service_test.ts b/src/libswamp/extensions/upgrade_extension_service_test.ts new file mode 100644 index 00000000..4a6576f3 --- /dev/null +++ b/src/libswamp/extensions/upgrade_extension_service_test.ts @@ -0,0 +1,342 @@ +// Swamp, an Automation Framework +// Copyright (C) 2026 System Initiative, Inc. +// +// This file is part of Swamp. +// +// Swamp is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation, with the Swamp +// Extension and Definition Exception (found in the "COPYING-EXCEPTION" +// file). +// +// Swamp is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with Swamp. If not, see . + +import { assertEquals } from "@std/assert"; +import { join } from "@std/path"; +import { ensureDir } from "@std/fs"; +import { UpgradeExtensionService } from "./upgrade_extension_service.ts"; +import { InstallExtensionService } from "./install_extension_service.ts"; +import type { InstallContext, InstallResult } from "./pull.ts"; +import { ExtensionCatalogStore } from "../../infrastructure/persistence/extension_catalog_store.ts"; +import { ExtensionRepository } from "../../infrastructure/persistence/extension_repository.ts"; +import { LockfileRepository } from "../../infrastructure/persistence/lockfile_repository.ts"; +import { swampPath } from "../../infrastructure/persistence/paths.ts"; +import type { DenoRuntime } from "../../domain/runtime/deno_runtime.ts"; + +import "../../domain/models/models.ts"; + +const testDenoRuntime: DenoRuntime = { + ensureDeno: () => Promise.resolve(Deno.execPath()), +}; + +async function withFixtureRepo( + fn: (args: { + repoDir: string; + repository: ExtensionRepository; + catalog: ExtensionCatalogStore; + lockfileRepository: LockfileRepository; + }) => Promise, +): Promise { + const repoDir = await Deno.makeTempDir({ prefix: "swamp_upsvc_test_" }); + await ensureDir(join(repoDir, ".swamp")); + const dbPath = join(repoDir, ".swamp", "_extension_catalog.db"); + const lockfilePath = join( + repoDir, + "extensions", + "models", + "upstream_extensions.json", + ); + await ensureDir(join(repoDir, "extensions", "models")); + await Deno.writeTextFile(lockfilePath, "{}"); + const catalog = new ExtensionCatalogStore(dbPath); + const lockfileRepository = await LockfileRepository.create(lockfilePath); + const repository = new ExtensionRepository({ + catalog, + lockfileRepository, + repoRoot: repoDir, + }); + try { + await fn({ repoDir, repository, catalog, lockfileRepository }); + } finally { + catalog.close(); + if (Deno.build.os === "windows") { + await Deno.remove(repoDir, { recursive: true }).catch(() => {}); + } else { + await Deno.remove(repoDir, { recursive: true }); + } + } +} + +async function stageModel( + repoDir: string, + extName: string, + fileName: string, + modelCode: string, +): Promise { + const modelsDir = join( + swampPath(repoDir, "pulled-extensions"), + extName, + "models", + ); + await ensureDir(modelsDir); + await Deno.writeTextFile(join(modelsDir, fileName), modelCode); +} + +function makeStubInstallResult( + extName: string, + version: string, + files: string[], +): InstallResult { + return { + name: extName, + version, + description: undefined, + extractedFiles: files, + integrityStatus: "verified", + repository: undefined, + platforms: [], + safetyWarnings: [], + conflicts: [], + missingSourceFiles: [], + hasSkills: false, + hasSkillScripts: false, + skillFiles: [], + dependencyResults: [], + pruned: [], + }; +} + +function makeInstallContext( + repoDir: string, + lockfileRepository: LockfileRepository, +): InstallContext { + return { + getExtension: () => Promise.reject(new Error("stub")), + downloadArchive: () => Promise.reject(new Error("stub")), + getChecksum: () => Promise.resolve(null), + lockfileRepository, + skillsDir: join(repoDir, ".claude", "skills"), + repoDir, + force: false, + alreadyPulled: new Set(), + depth: 0, + }; +} + +const MINIMAL_MODEL_CODE = (typeId: string, version: string) => ` +import { z } from "npm:zod@4"; + +export const model = { + type: "${typeId}", + version: "${version}", + globalArguments: z.object({}), + resources: { + "data": { + description: "x", + schema: z.object({}), + lifetime: "infinite", + garbageCollection: 1, + }, + }, + methods: { + noop: { + description: "noop", + arguments: z.object({}), + execute: async () => ({ dataHandles: [] }), + }, + }, +}; +`; + +// ============================================================= +// Atomic upgrade contract — saveAll([tombstoneAll(v1), v2]) in one txn +// ============================================================= +// +// Without the tombstone of v1 in the same saveAll, I-Repo-1 fires +// `DuplicateTypeError` on every upgrade because v1 still claims the +// type when v2's save attempts to add a row for the same type. This +// test pins the atomic contract: after upgrade, ONLY v2's rows are in +// the catalog; v1's are gone. + +Deno.test( + "UpgradeExtensionService.execute: v1 → v2 upgrade tombstones v1's catalog rows in one transaction", + async () => { + await withFixtureRepo( + async ({ repoDir, repository, lockfileRepository }) => { + const ts = Date.now(); + const extName = `@test/upgrade-${ts}`; + const typeId = `@test/upgrade-model-${ts}`; + + const v1Cal = "2026.05.05.1"; + const v2Cal = "2026.05.05.2"; + + // Install v1 first via InstallExtensionService. + await stageModel( + repoDir, + extName, + "model.ts", + MINIMAL_MODEL_CODE(typeId, v1Cal), + ); + const installSvc = new InstallExtensionService({ + denoRuntime: testDenoRuntime, + repository, + installExtensionFn: async (ref, ctx) => { + await ctx.lockfileRepository.writeEntry( + ref.name, + ref.version ?? v1Cal, + [`.swamp/pulled-extensions/${ref.name}/models/model.ts`], + ); + return makeStubInstallResult( + ref.name, + ref.version ?? v1Cal, + [`.swamp/pulled-extensions/${ref.name}/models/model.ts`], + ); + }, + }); + await installSvc.execute( + { name: extName, version: v1Cal }, + makeInstallContext(repoDir, lockfileRepository), + ); + const v1Extensions = repository.loadByName(extName); + assertEquals(v1Extensions.length, 1); + assertEquals(v1Extensions[0].version, v1Cal); + + // Upgrade to v2 — overwrite the source file with v2 content + // (mirrors what install does on disk). + await stageModel( + repoDir, + extName, + "model.ts", + MINIMAL_MODEL_CODE(typeId, v2Cal), + ); + const upgradeSvc = new UpgradeExtensionService({ + denoRuntime: testDenoRuntime, + repository, + installExtensionFn: async (ref, ctx) => { + await ctx.lockfileRepository.writeEntry( + ref.name, + ref.version ?? v2Cal, + [`.swamp/pulled-extensions/${ref.name}/models/model.ts`], + ); + return makeStubInstallResult( + ref.name, + ref.version ?? v2Cal, + [`.swamp/pulled-extensions/${ref.name}/models/model.ts`], + ); + }, + }); + await upgradeSvc.execute( + extName, + v2Cal, + makeInstallContext(repoDir, lockfileRepository), + ); + + // Atomic upgrade contract: only v2's aggregate is present; + // v1's rows tombstoned and DELETEd by the diff-save. No + // DuplicateTypeError fired (the tombstone in the same saveAll + // means I-Repo-1 evaluates only v2 in the post-save state). + const afterUpgrade = repository.loadByName(extName); + assertEquals(afterUpgrade.length, 1); + assertEquals(afterUpgrade[0].version, v2Cal); + assertEquals( + lockfileRepository.getEntry(extName)?.version, + v2Cal, + ); + }, + ); + }, +); + +// ============================================================= +// Force-pull regression — re-pulling an already-installed extension +// must not fail with DuplicateTypeError. +// ============================================================= +// +// Pre-this-fix: `swamp extension pull foo --force` against an already- +// installed foo would fail because phase 8 saved v2's aggregate while +// v1's rows still claimed the type → I-Repo-1 fired. Now the atomic +// tombstone-of-prior-version pattern handles this transparently. + +Deno.test( + "InstallExtensionService.execute: force-reinstall of already-installed extension does not fail with DuplicateTypeError", + async () => { + await withFixtureRepo( + async ({ repoDir, repository, lockfileRepository }) => { + const ts = Date.now(); + const extName = `@test/force-pull-${ts}`; + const typeId = `@test/force-pull-model-${ts}`; + + const v1Cal = "2026.05.05.1"; + const v2Cal = "2026.05.05.2"; + + // Install v1. + await stageModel( + repoDir, + extName, + "model.ts", + MINIMAL_MODEL_CODE(typeId, v1Cal), + ); + const svc = new InstallExtensionService({ + denoRuntime: testDenoRuntime, + repository, + installExtensionFn: async (ref, ctx) => { + await ctx.lockfileRepository.writeEntry( + ref.name, + ref.version ?? v1Cal, + [`.swamp/pulled-extensions/${ref.name}/models/model.ts`], + ); + return makeStubInstallResult( + ref.name, + ref.version ?? v1Cal, + [`.swamp/pulled-extensions/${ref.name}/models/model.ts`], + ); + }, + }); + await svc.execute( + { name: extName, version: v1Cal }, + makeInstallContext(repoDir, lockfileRepository), + ); + + // Force-pull v2 (same name, different version) via the SAME + // service. The atomic-tombstone pattern handles it. + await stageModel( + repoDir, + extName, + "model.ts", + MINIMAL_MODEL_CODE(typeId, v2Cal), + ); + const svc2 = new InstallExtensionService({ + denoRuntime: testDenoRuntime, + repository, + installExtensionFn: async (ref, ctx) => { + await ctx.lockfileRepository.writeEntry( + ref.name, + ref.version ?? v2Cal, + [`.swamp/pulled-extensions/${ref.name}/models/model.ts`], + ); + return makeStubInstallResult( + ref.name, + ref.version ?? v2Cal, + [`.swamp/pulled-extensions/${ref.name}/models/model.ts`], + ); + }, + }); + // Should not throw. + await svc2.execute( + { name: extName, version: v2Cal }, + makeInstallContext(repoDir, lockfileRepository), + ); + + const after = repository.loadByName(extName); + assertEquals(after.length, 1); + assertEquals(after[0].version, v2Cal); + }, + ); + }, +); diff --git a/src/libswamp/mod.ts b/src/libswamp/mod.ts index b2923838..11281f1f 100644 --- a/src/libswamp/mod.ts +++ b/src/libswamp/mod.ts @@ -670,6 +670,7 @@ export { type RemoveExtensionResult, RemoveExtensionService, } from "./extensions/remove_extension_service.ts"; +export { UpgradeExtensionService } from "./extensions/upgrade_extension_service.ts"; // Extension layout detection export { From ef0e1cb31b6b7c1a99915f0ee85636dc0b11e082 Mon Sep 17 00:00:00 2001 From: stack72 Date: Tue, 5 May 2026 15:25:46 +0100 Subject: [PATCH 08/15] =?UTF-8?q?feat(extensions):=20W2=20commit=206=20?= =?UTF-8?q?=E2=80=94=20DuplicateTypeUserError=20surface=20+=20structured?= =?UTF-8?q?=20JSON=20shape?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan v4 step 11: surface I-Repo-1 collisions as a UserError subclass that the top-level error handler renders as a clean single-line message in log mode (no stack trace) and as a structured `duplicateType` object in JSON mode. - New `DuplicateTypeUserError` extends `UserError`, carries `kind`, `typeNormalized`, `existing`, `conflicting`. Pinned message format names both extensions and points the user at `swamp extension rm ` to recover. - Lives under `src/domain/extensions/` (not next to the persistence- internal `DuplicateTypeError`) so the presentation layer can import it without violating DDD layer rules. - `install_extension_service.ts` `mapDuplicateTypeErrorToUserError` returns the new subclass so phase 8 collisions surface with full context after FS rollback. - `error_output.ts` `buildErrorJson` recognises the subclass and emits the pinned `duplicateType` shape for `--json` consumers (jq, AI agents, CI). Return type widened from `Record` to `Record` to allow the nested object. - 2 new tests in `error_output_test.ts`: structured shape + log/JSON parity (the human-readable message and the structured fields agree). Verification: deno fmt / lint / check / test (5428 passed). --- .../extensions/duplicate_type_user_error.ts | 107 +++++++++++++++++ .../persistence/duplicate_type_error.ts | 4 + .../extensions/install_extension_service.ts | 29 +++-- .../extensions/upgrade_extension_service.ts | 6 +- src/presentation/output/error_output.ts | 40 +++++-- src/presentation/output/error_output_test.ts | 113 +++++++++++++++++- 6 files changed, 271 insertions(+), 28 deletions(-) create mode 100644 src/domain/extensions/duplicate_type_user_error.ts diff --git a/src/domain/extensions/duplicate_type_user_error.ts b/src/domain/extensions/duplicate_type_user_error.ts new file mode 100644 index 00000000..26156eb1 --- /dev/null +++ b/src/domain/extensions/duplicate_type_user_error.ts @@ -0,0 +1,107 @@ +// Swamp, an Automation Framework +// Copyright (C) 2026 System Initiative, Inc. +// +// This file is part of Swamp. +// +// Swamp is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation, with the Swamp +// Extension and Definition Exception (found in the "COPYING-EXCEPTION" +// file). +// +// Swamp is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with Swamp. If not, see . + +import { UserError } from "../errors.ts"; + +/** + * The kind of bundle entry — mirrors `ExtensionKind` in + * `infrastructure/persistence/extension_catalog_store.ts`. Duplicated + * here so {@link DuplicateTypeUserError} (which presentation needs to + * import) can stay in the domain layer without pulling in infrastructure. + */ +export type DuplicateTypeKind = + | "model" + | "extension" + | "vault" + | "driver" + | "datastore" + | "report"; + +/** + * Carries enough information to point a user at one of the two + * extensions sharing the conflicting `(kind, typeNormalized)` so they + * can resolve the conflict by hand. + */ +export interface DuplicateTypeOccupant { + readonly extensionName: string; + readonly extensionVersion: string; + readonly canonicalPath: string; +} + +/** + * User-facing wrapper thrown by the W2 lifecycle services after FS + * rollback completes. Extends {@link UserError} so the top-level error + * renderer formats a clean single-line message in log mode (no stack + * trace), and carries the structured fields so JSON mode emits them + * alongside the message. + * + * **JSON shape pinned by plan v4 step 11:** + * + * ```json + * { + * "error": "", + * "duplicateType": { + * "kind": "model", + * "type": "@scope/foo", + * "existing": { + * "extensionName": "@scopeA/aa", + * "extensionVersion": "1.0.0", + * "canonicalPath": "/repo/.swamp/pulled-extensions/@scopeA/aa/models/foo.ts" + * }, + * "conflicting": { + * "extensionName": "@scopeB/bb", + * "extensionVersion": "1.0.0", + * "canonicalPath": "/repo/.swamp/pulled-extensions/@scopeB/bb/models/foo.ts" + * } + * } + * } + * ``` + * + * `presentation/output/error_output.ts` recognises this subclass and + * adds the `duplicateType` field to the JSON output. Log mode uses + * just the message, as for any other UserError. + */ +export class DuplicateTypeUserError extends UserError { + readonly kind: DuplicateTypeKind; + readonly typeNormalized: string; + readonly existing: DuplicateTypeOccupant; + readonly conflicting: DuplicateTypeOccupant; + + constructor(args: { + kind: DuplicateTypeKind; + typeNormalized: string; + existing: DuplicateTypeOccupant; + conflicting: DuplicateTypeOccupant; + }) { + super( + `Type "${args.typeNormalized}" (kind=${args.kind}) is already claimed by ` + + `${args.existing.extensionName}@${args.existing.extensionVersion} ` + + `at ${args.existing.canonicalPath}. Cannot install ` + + `${args.conflicting.extensionName}@${args.conflicting.extensionVersion} ` + + `at ${args.conflicting.canonicalPath} — filesystem changes rolled back. ` + + `Run \`swamp extension rm ${args.existing.extensionName}\` first if ` + + `you intended to replace it.`, + ); + this.name = "DuplicateTypeUserError"; + this.kind = args.kind; + this.typeNormalized = args.typeNormalized; + this.existing = args.existing; + this.conflicting = args.conflicting; + } +} diff --git a/src/infrastructure/persistence/duplicate_type_error.ts b/src/infrastructure/persistence/duplicate_type_error.ts index c88fc5a8..50f8fa4d 100644 --- a/src/infrastructure/persistence/duplicate_type_error.ts +++ b/src/infrastructure/persistence/duplicate_type_error.ts @@ -74,3 +74,7 @@ export class DuplicateTypeError extends Error { this.secondSource = args.secondSource; } } + +// `DuplicateTypeUserError` (the user-facing wrapper) lives in +// `src/domain/extensions/duplicate_type_user_error.ts` so the +// presentation layer can import it without violating DDD layer rules. diff --git a/src/libswamp/extensions/install_extension_service.ts b/src/libswamp/extensions/install_extension_service.ts index 08c069fe..b9a17bbc 100644 --- a/src/libswamp/extensions/install_extension_service.ts +++ b/src/libswamp/extensions/install_extension_service.ts @@ -34,6 +34,7 @@ import { makeSourceLocation } from "../../domain/extensions/source_location.ts"; import { makeBundleLocation } from "../../domain/extensions/bundle_location.ts"; import type { ExtensionRepository } from "../../infrastructure/persistence/extension_repository.ts"; import { DuplicateTypeError } from "../../infrastructure/persistence/duplicate_type_error.ts"; +import { DuplicateTypeUserError } from "../../domain/extensions/duplicate_type_user_error.ts"; import { swampPath } from "../../infrastructure/persistence/paths.ts"; import { UserModelLoader } from "../../domain/models/user_model_loader.ts"; import { UserDriverLoader } from "../../domain/drivers/user_driver_loader.ts"; @@ -42,7 +43,6 @@ import { UserDatastoreLoader } from "../../domain/datastore/user_datastore_loade import { UserReportLoader } from "../../domain/reports/user_report_loader.ts"; import type { DenoRuntime } from "../../domain/runtime/deno_runtime.ts"; import type { UpstreamExtensionEntry } from "../../infrastructure/persistence/upstream_extensions.ts"; -import { UserError } from "../../domain/errors.ts"; /** Subdirectories of a per-extension subtree, paired with their kind. */ const KIND_DIRS = [ @@ -411,21 +411,20 @@ async function collectTsFiles(dir: string): Promise { } /** - * Wraps a {@link DuplicateTypeError} in a {@link UserError} so the - * top-level CLI error handler renders a clean message rather than a - * stack trace. Both source paths are named — the W2 user-visible - * payoff that replaces W1b's silent first-wins. + * Wraps a {@link DuplicateTypeError} in a + * {@link DuplicateTypeUserError} so the top-level CLI error handler + * renders a clean single-line message in log mode AND emits the + * pinned JSON shape in JSON mode (plan v4 step 11). Both source paths + * are named — the W2 user-visible payoff that replaces W1b's silent + * first-wins. */ function mapDuplicateTypeErrorToUserError( error: DuplicateTypeError, -): UserError { - return new UserError( - `Type "${error.typeNormalized}" (kind=${error.kind}) is already claimed by ` + - `${error.firstSource.extensionName}@${error.firstSource.extensionVersion} ` + - `at ${error.firstSource.canonicalPath}. Cannot install ` + - `${error.secondSource.extensionName}@${error.secondSource.extensionVersion} ` + - `at ${error.secondSource.canonicalPath} — filesystem changes rolled back. ` + - `Run \`swamp extension rm ${error.firstSource.extensionName}\` first if ` + - `you intended to replace it.`, - ); +): DuplicateTypeUserError { + return new DuplicateTypeUserError({ + kind: error.kind, + typeNormalized: error.typeNormalized, + existing: error.firstSource, + conflicting: error.secondSource, + }); } diff --git a/src/libswamp/extensions/upgrade_extension_service.ts b/src/libswamp/extensions/upgrade_extension_service.ts index d1b9aa3a..f2d4045c 100644 --- a/src/libswamp/extensions/upgrade_extension_service.ts +++ b/src/libswamp/extensions/upgrade_extension_service.ts @@ -17,11 +17,7 @@ // You should have received a copy of the GNU Affero General Public License // along with Swamp. If not, see . -import { - type ExtensionRef, - type InstallContext, - type InstallResult, -} from "./pull.ts"; +import type { ExtensionRef, InstallContext, InstallResult } from "./pull.ts"; import { InstallExtensionService } from "./install_extension_service.ts"; import type { ExtensionRepository } from "../../infrastructure/persistence/extension_repository.ts"; import type { DenoRuntime } from "../../domain/runtime/deno_runtime.ts"; diff --git a/src/presentation/output/error_output.ts b/src/presentation/output/error_output.ts index 3bf34db7..dd8f8784 100644 --- a/src/presentation/output/error_output.ts +++ b/src/presentation/output/error_output.ts @@ -20,6 +20,7 @@ import { ValidationError } from "@cliffy/command"; import { getSwampLogger } from "../../infrastructure/logging/logger.ts"; import { UserError } from "../../domain/errors.ts"; +import { DuplicateTypeUserError } from "../../domain/extensions/duplicate_type_user_error.ts"; import type { OutputMode } from "./output.ts"; const logger = getSwampLogger(["error"]); @@ -27,14 +28,23 @@ const logger = getSwampLogger(["error"]); /** * Builds the JSON error object for structured output. * - * Shape: `{ error: string, stack?: string, code?: string }`. The `code` - * field is set when the underlying error carries a machine-readable - * identifier (e.g. `UserError.code` or any error object exposing a - * string `code` property — `SwampError`-like). Both `code` and `stack` - * are optional; consumers must tolerate their presence or absence. + * Default shape: `{ error: string, stack?: string, code?: string }`. The + * `code` field is set when the underlying error carries a machine- + * readable identifier (e.g. `UserError.code` or any error object + * exposing a string `code` property — `SwampError`-like). Both `code` + * and `stack` are optional; consumers must tolerate their presence or + * absence. + * + * Specific {@link UserError} subclasses extend the default shape with + * structured fields: + * + * - {@link DuplicateTypeUserError} adds a `duplicateType` object with + * `kind`, `type`, `existing`, and `conflicting` (per plan v4 step + * 11). Lets `--json` consumers (jq, AI agents, CI scripts) read the + * collision details without re-parsing the message. */ -export function buildErrorJson(err: Error): Record { - const data: Record = { error: err.message }; +export function buildErrorJson(err: Error): Record { + const data: Record = { error: err.message }; if ( !(err instanceof UserError) && !(err instanceof ValidationError) && err.stack @@ -50,6 +60,22 @@ export function buildErrorJson(err: Error): Record { if (typeof maybeCode === "string" && maybeCode.length > 0) { data.code = maybeCode; } + if (err instanceof DuplicateTypeUserError) { + data.duplicateType = { + kind: err.kind, + type: err.typeNormalized, + existing: { + extensionName: err.existing.extensionName, + extensionVersion: err.existing.extensionVersion, + canonicalPath: err.existing.canonicalPath, + }, + conflicting: { + extensionName: err.conflicting.extensionName, + extensionVersion: err.conflicting.extensionVersion, + canonicalPath: err.conflicting.canonicalPath, + }, + }; + } return data; } diff --git a/src/presentation/output/error_output_test.ts b/src/presentation/output/error_output_test.ts index 32f8e893..f3adb5f1 100644 --- a/src/presentation/output/error_output_test.ts +++ b/src/presentation/output/error_output_test.ts @@ -22,6 +22,7 @@ import { ValidationError } from "@cliffy/command"; import { initializeLogging } from "../../infrastructure/logging/logger.ts"; import { buildErrorJson, renderError } from "./error_output.ts"; import { UserError } from "../../domain/errors.ts"; +import { DuplicateTypeUserError } from "../../domain/extensions/duplicate_type_user_error.ts"; await initializeLogging({}); @@ -296,7 +297,7 @@ Deno.test("buildErrorJson: system Error includes stack", () => { const result = buildErrorJson(new Error("Something broke")); assertEquals(result.error, "Something broke"); assertEquals(typeof result.stack, "string"); - assertStringIncludes(result.stack!, "at "); + assertStringIncludes(result.stack as string, "at "); }); Deno.test("buildErrorJson: Cliffy ValidationError has no stack", () => { @@ -323,3 +324,113 @@ Deno.test("buildErrorJson: omits code field when not present", () => { const result = buildErrorJson(new UserError("plain")); assertEquals(result.code, undefined); }); + +// ============================================================= +// Plan v4 step 11 — DuplicateTypeUserError JSON shape +// ============================================================= +// +// Pinned shape: log mode emits a single-line message; JSON mode emits +// the same message PLUS a structured `duplicateType` object so jq / +// AI agents / CI scripts can read the collision details without +// re-parsing the message. This test pins both the structural shape +// and the parity (same fields available across modes). + +Deno.test( + "buildErrorJson: DuplicateTypeUserError emits structured duplicateType field (W2 plan v4 step 11)", + () => { + const err = new DuplicateTypeUserError({ + kind: "model", + typeNormalized: "@scope/foo", + existing: { + extensionName: "@scopeA/aa", + extensionVersion: "1.0.0", + canonicalPath: + "/repo/.swamp/pulled-extensions/@scopeA/aa/models/foo.ts", + }, + conflicting: { + extensionName: "@scopeB/bb", + extensionVersion: "1.0.0", + canonicalPath: + "/repo/.swamp/pulled-extensions/@scopeB/bb/models/foo.ts", + }, + }); + + const result = buildErrorJson(err); + + // Default UserError treatment: message present, no stack trace. + assertStringIncludes( + result.error as string, + `Type "@scope/foo" (kind=model)`, + ); + assertStringIncludes( + result.error as string, + "@scopeA/aa@1.0.0", + ); + assertStringIncludes( + result.error as string, + "@scopeB/bb@1.0.0", + ); + assertEquals(result.stack, undefined); + + // Structured field — pinned shape. + const dup = result.duplicateType as Record; + assertEquals(dup.kind, "model"); + assertEquals(dup.type, "@scope/foo"); + const existing = dup.existing as Record; + assertEquals(existing.extensionName, "@scopeA/aa"); + assertEquals(existing.extensionVersion, "1.0.0"); + assertEquals( + existing.canonicalPath, + "/repo/.swamp/pulled-extensions/@scopeA/aa/models/foo.ts", + ); + const conflicting = dup.conflicting as Record; + assertEquals(conflicting.extensionName, "@scopeB/bb"); + assertEquals(conflicting.extensionVersion, "1.0.0"); + assertEquals( + conflicting.canonicalPath, + "/repo/.swamp/pulled-extensions/@scopeB/bb/models/foo.ts", + ); + }, +); + +Deno.test( + "buildErrorJson: DuplicateTypeUserError log+JSON share the same structured fields (output parity)", + () => { + const err = new DuplicateTypeUserError({ + kind: "driver", + typeNormalized: "@scope/parity", + existing: { + extensionName: "@scopeA/aa", + extensionVersion: "1.0.0", + canonicalPath: "/path/a.ts", + }, + conflicting: { + extensionName: "@scopeB/bb", + extensionVersion: "2.0.0", + canonicalPath: "/path/b.ts", + }, + }); + + // Log mode: the message line names both extensions and the + // colliding type — no need for a separate test since the + // top-level renderer logs `err.message` verbatim. + assertStringIncludes(err.message, "@scopeA/aa@1.0.0"); + assertStringIncludes(err.message, "@scopeB/bb@2.0.0"); + assertStringIncludes(err.message, "@scope/parity"); + assertStringIncludes(err.message, "(kind=driver)"); + + // JSON mode: same fields available structurally. + const result = buildErrorJson(err); + const dup = result.duplicateType as Record; + assertEquals(dup.kind, "driver"); + assertEquals(dup.type, "@scope/parity"); + assertEquals( + (dup.existing as Record).extensionName, + "@scopeA/aa", + ); + assertEquals( + (dup.conflicting as Record).extensionVersion, + "2.0.0", + ); + }, +); From 75942fdda2341844ce7802567ac9e68684238115 Mon Sep 17 00:00:00 2001 From: stack72 Date: Tue, 5 May 2026 15:30:54 +0100 Subject: [PATCH 09/15] =?UTF-8?q?feat(extensions):=20W2=20commit=207=20?= =?UTF-8?q?=E2=80=94=20order-independence=20+=20CLI=20routing=20regression?= =?UTF-8?q?=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan v4 step 9 + step 12. - `extension_repository_test.ts`: bulk-upgrade order-independence test. Two extensions A and B each upgrading v1 → v2 in one saveAll. Submit the four entries as `[tombstone(A1), A2, tombstone(B1), B2]` and as the inverted order; assert the loaded catalog is identical. Guards against a future refactor where saveAll lets an intermediate diff state leak across extensions or fires I-Repo-1 mid-loop on a transient state. (Source paths are deliberately distinct between v1 and v2 so the test exercises the cross-extension ordering, not the within- extension path-collision case which is already pinned by the canonical upgrade test.) - `extension_update_test.ts`: CLI routing regression. Asserts the command source imports and constructs `UpgradeExtensionService` so a future refactor can't silently route through `installExtension(...)` directly and bypass phase 8's atomic-tombstone pattern. Verification: deno fmt / lint / check / test (5430 passed). --- src/cli/commands/extension_update_test.ts | 30 +++++++ .../persistence/extension_repository_test.ts | 89 +++++++++++++++++++ 2 files changed, 119 insertions(+) diff --git a/src/cli/commands/extension_update_test.ts b/src/cli/commands/extension_update_test.ts index e6330f60..48ba65a7 100644 --- a/src/cli/commands/extension_update_test.ts +++ b/src/cli/commands/extension_update_test.ts @@ -18,6 +18,8 @@ // along with Swamp. If not, see . import { assertEquals } from "@std/assert/equals"; +import { assertStringIncludes } from "@std/assert/string-includes"; +import { fromFileUrl } from "@std/path"; import { buildUpdateResult, checkExtensionVersion, @@ -96,6 +98,34 @@ Deno.test("extension update: buildUpdateResult counts failed status in failed bu assertEquals(result.summary.failed, 1); }); +// W2 plan v4 step 12 — CLI routing regression. +// +// `swamp extension update` MUST dispatch each per-extension upgrade +// through `UpgradeExtensionService` (which delegates to +// `InstallExtensionService`'s phase 8 atomic-tombstone pattern). A +// future refactor that replaces the `UpgradeExtensionService` call with +// a direct `installExtension(...)` invocation would silently break the +// `(kind, type)` collision handling for upgrades — every collision in a +// bulk update would surface as `DuplicateTypeError` instead of the +// pinned tombstone-and-replace behavior. +// +// This is a source-text assertion rather than a runtime test because +// the wiring lives inside a CLI-layer closure constructed only by +// Cliffy `.action(...)`. The closure isn't exposed for unit testing, +// and an integration test that spins up a real registry/repository for +// a one-line wiring check would be overkill. A regex-style check on +// the command file catches the regression mechanically and runs in +// milliseconds. +Deno.test("extension update: command wires UpgradeExtensionService (plan v4 step 12)", async () => { + const source = await Deno.readTextFile( + fromFileUrl(new URL("./extension_update.ts", import.meta.url)), + ); + // The import must be present... + assertStringIncludes(source, "UpgradeExtensionService"); + // ...and an instance must actually be constructed inside the action. + assertStringIncludes(source, "new UpgradeExtensionService("); +}); + Deno.test("extension update: buildUpdateResult aggregates mixed statuses including failed", () => { const result = buildUpdateResult([ { diff --git a/src/infrastructure/persistence/extension_repository_test.ts b/src/infrastructure/persistence/extension_repository_test.ts index 9ffa7ba8..1899130c 100644 --- a/src/infrastructure/persistence/extension_repository_test.ts +++ b/src/infrastructure/persistence/extension_repository_test.ts @@ -278,6 +278,95 @@ Deno.test("ExtensionRepository: saveAll([vN.tombstoneAll(), vN+1]) succeeds when }); }); +// ===== Test #5b: bulk-upgrade order-independence (W2 plan v4 step 9) ===== +// +// Bulk extension upgrade processes N independent +// `(tombstoneAll(vN), vN+1)` pairs in one saveAll. Different extensions +// occupy disjoint pulled-extensions subtrees, so the order in which +// pairs are submitted to saveAll must NOT change the final catalog +// state — and I-Repo-1 must evaluate the same post-save state either +// way. +// +// Guards against a future regression where saveAll iterates in a way +// that lets one extension's mid-loop intermediate state leak into +// another's diff (e.g., I-Repo-1 fired mid-loop on a transient state). +Deno.test("ExtensionRepository: saveAll bulk-upgrade is order-independent across extensions", () => { + function bulkUpgrade(repoRoot: string): { + pairs: ReadonlyArray; + } { + const a1 = pulledExtension({ + repoRoot, + name: "@scope/a", + version: "1.0.0", + sources: [{ relPath: "models/a-old.ts", type: "@scope/a/instance" }], + }); + const a2 = pulledExtension({ + repoRoot, + name: "@scope/a", + version: "2.0.0", + sources: [{ relPath: "models/a-new.ts", type: "@scope/a/instance" }], + }); + const b1 = pulledExtension({ + repoRoot, + name: "@scope/b", + version: "1.0.0", + sources: [{ relPath: "models/b-old.ts", type: "@scope/b/instance" }], + }); + const b2 = pulledExtension({ + repoRoot, + name: "@scope/b", + version: "2.0.0", + sources: [{ relPath: "models/b-new.ts", type: "@scope/b/instance" }], + }); + return { + pairs: [ + [a1, a2], + [b1, b2], + ], + }; + } + + // Canonical order: A's tombstone+save, then B's tombstone+save. + let canonicalState: { name: string; version: string }[] = []; + withRepository((repo, _cat, repoRoot) => { + const { pairs } = bulkUpgrade(repoRoot); + for (const [v1, _] of pairs) repo.save(v1); + repo.saveAll([ + tombstoneAll(pairs[0][0]), + pairs[0][1], + tombstoneAll(pairs[1][0]), + pairs[1][1], + ]); + canonicalState = repo.loadAll().map((e) => ({ + name: e.name, + version: e.version, + })).sort((a, b) => a.name.localeCompare(b.name)); + }); + + // Inverted order: B's tombstone+save, then A's tombstone+save. + let invertedState: { name: string; version: string }[] = []; + withRepository((repo, _cat, repoRoot) => { + const { pairs } = bulkUpgrade(repoRoot); + for (const [v1, _] of pairs) repo.save(v1); + repo.saveAll([ + tombstoneAll(pairs[1][0]), + pairs[1][1], + tombstoneAll(pairs[0][0]), + pairs[0][1], + ]); + invertedState = repo.loadAll().map((e) => ({ + name: e.name, + version: e.version, + })).sort((a, b) => a.name.localeCompare(b.name)); + }); + + assertEquals(canonicalState, invertedState); + assertEquals(canonicalState, [ + { name: "@scope/a", version: "2.0.0" }, + { name: "@scope/b", version: "2.0.0" }, + ]); +}); + // ===== Test #6: saveAll cross-extension DuplicateType reject + ROLLBACK ===== Deno.test("ExtensionRepository: saveAll rejects cross-extension (kind, type) with ROLLBACK and names both paths", () => { withRepository((repo, cat, repoRoot) => { From 66765cb65502977b1a3a2cad6bd43039881e1b4e Mon Sep 17 00:00:00 2001 From: stack72 Date: Tue, 5 May 2026 15:36:42 +0100 Subject: [PATCH 10/15] =?UTF-8?q?feat(extensions):=20W2=20commit=208=20?= =?UTF-8?q?=E2=80=94=20crash-state=20recovery=20tests=20+=20FaultingStubRe?= =?UTF-8?q?pository?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan v4 step 10. Pin the SQLite transaction-rollback contract that the W2 lifecycle services depend on: a generic non-`DuplicateTypeError` failure inside `repository.saveAll` must leave the catalog in its pre-save state so a retry succeeds. - New `FaultingStubRepository` wrapper (~50 LOC under the audit's ≤150 threshold). Subclasses `ExtensionRepository` and overrides `saveAll` with a one-shot fault-injection slot. Slots into the lifecycle services without an interface refactor. - Install crash-recovery test: inject fault on `saveAll`. Assert the catalog is clean (SQLite ROLLBACK), lockfile + FS still hold the partial install (intentional — only DuplicateTypeError triggers FS rollback), and a retry succeeds via the diff-save reconciling the catalog against the on-disk + lockfile state. - Remove crash-recovery test: install via the real repository, then swap in the faulting wrapper for rm. Inject fault on the tombstone saveAll (the FIRST mutation of rm, before lockfile + FS are touched). Assert all state survives the fault and a retry yields a clean rm. The other two enumerated boundary states (install pre-save FS fault, remove post-save FS fault) live below the catalog layer and aren't usefully covered by a stub repository — they're mechanical operations on the local filesystem and have no transaction-semantics claim to pin. Verification: deno fmt / lint / check / test (5432 passed, +2 from commit 7). --- .../test_helpers/faulting_stub_repository.ts | 66 ++++++++++++++ .../install_extension_service_test.ts | 89 ++++++++++++++++++ .../remove_extension_service_test.ts | 90 +++++++++++++++++++ 3 files changed, 245 insertions(+) create mode 100644 src/infrastructure/persistence/test_helpers/faulting_stub_repository.ts diff --git a/src/infrastructure/persistence/test_helpers/faulting_stub_repository.ts b/src/infrastructure/persistence/test_helpers/faulting_stub_repository.ts new file mode 100644 index 00000000..d2ede76a --- /dev/null +++ b/src/infrastructure/persistence/test_helpers/faulting_stub_repository.ts @@ -0,0 +1,66 @@ +// Swamp, an Automation Framework +// Copyright (C) 2026 System Initiative, Inc. +// +// This file is part of Swamp. +// +// Swamp is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation, with the Swamp +// Extension and Definition Exception (found in the "COPYING-EXCEPTION" +// file). +// +// Swamp is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with Swamp. If not, see . + +import type { Extension } from "../../../domain/extensions/extension.ts"; +import { ExtensionRepository } from "../extension_repository.ts"; +import type { ExtensionCatalogStore } from "../extension_catalog_store.ts"; +import type { LockfileRepository } from "../lockfile_repository.ts"; + +/** + * Fault-injection wrapper around an {@link ExtensionRepository}. Used by + * crash-state recovery tests (W2 plan v4 step 10) to simulate a generic + * non-`DuplicateTypeError` failure inside `saveAll` and verify that: + * + * - SQLite transaction rollback leaves the catalog in its pre-save + * state (no half-applied diff). + * - The lifecycle service's caller-visible behavior is "throw, don't + * leave catalog half-written". + * - Retrying the operation succeeds because the catalog is clean. + * + * Only `saveAll` (the only call site through which lifecycle services + * mutate the catalog) is fault-injectable. Reads delegate unchanged. + * + * Subclasses {@link ExtensionRepository} so it slots into lifecycle + * services without an interface refactor. + */ +export class FaultingStubRepository extends ExtensionRepository { + private faultOnNextSaveAll: Error | null = null; + + constructor(args: { + catalog: ExtensionCatalogStore; + lockfileRepository: LockfileRepository; + repoRoot: string; + }) { + super(args); + } + + /** Schedules the next `saveAll` call to throw `error`. One-shot. */ + injectSaveAllFault(error: Error): void { + this.faultOnNextSaveAll = error; + } + + override saveAll(extensions: readonly Extension[]): void { + if (this.faultOnNextSaveAll) { + const err = this.faultOnNextSaveAll; + this.faultOnNextSaveAll = null; + throw err; + } + super.saveAll(extensions); + } +} diff --git a/src/libswamp/extensions/install_extension_service_test.ts b/src/libswamp/extensions/install_extension_service_test.ts index 965c656c..3674c91d 100644 --- a/src/libswamp/extensions/install_extension_service_test.ts +++ b/src/libswamp/extensions/install_extension_service_test.ts @@ -24,6 +24,7 @@ import { InstallExtensionService } from "./install_extension_service.ts"; import type { ExtensionRef, InstallContext, InstallResult } from "./pull.ts"; import { ExtensionCatalogStore } from "../../infrastructure/persistence/extension_catalog_store.ts"; import { ExtensionRepository } from "../../infrastructure/persistence/extension_repository.ts"; +import { FaultingStubRepository } from "../../infrastructure/persistence/test_helpers/faulting_stub_repository.ts"; import { LockfileRepository } from "../../infrastructure/persistence/lockfile_repository.ts"; import { swampPath } from "../../infrastructure/persistence/paths.ts"; import { UserError } from "../../domain/errors.ts"; @@ -323,3 +324,91 @@ Deno.test( ); }, ); + +// ============================================================= +// Crash-state recovery (W2 plan v4 step 10) +// ============================================================= +// +// Generic non-`DuplicateTypeError` failures inside `repository.saveAll` +// (process kill, SQLite I/O error, OOM, etc.) must leave the catalog +// in its pre-save state so a retry succeeds. This pins the SQLite +// transaction-rollback contract that the lifecycle service depends on. +// FS + lockfile are NOT auto-rolled-back for generic errors — that's a +// known and intentional behavior (only DuplicateTypeError triggers FS +// rollback, because the user is provably going to want the prior state +// restored). The retry resolves the FS+lockfile-vs-catalog drift via +// the diff-save in saveAll. + +Deno.test( + "InstallExtensionService.execute: catalog saveAll fault leaves catalog clean and retry succeeds", + async () => { + await withFixtureRepo( + async ({ repoDir, catalog, lockfileRepository }) => { + const ts = Date.now(); + const extName = `@test/crash-install-${ts}`; + const typeId = `@test/crash-install-model-${ts}`; + await stageModel( + repoDir, + extName, + "noop.ts", + MINIMAL_MODEL_CODE(typeId), + ); + + const faultingRepo = new FaultingStubRepository({ + catalog, + lockfileRepository, + repoRoot: repoDir, + }); + faultingRepo.injectSaveAllFault( + new Error("simulated SQLite I/O fault"), + ); + + const service = new InstallExtensionService({ + denoRuntime: testDenoRuntime, + repository: faultingRepo, + installExtensionFn: async (ref, ctx) => { + await ctx.lockfileRepository.writeEntry( + ref.name, + ref.version ?? "1.0.0", + [`.swamp/pulled-extensions/${ref.name}/models/noop.ts`], + ); + return makeStubInstallResult(ref.name, ref.version ?? "1.0.0", [ + `.swamp/pulled-extensions/${ref.name}/models/noop.ts`, + ]); + }, + }); + + // First attempt: faults inside saveAll, propagates the error. + await assertRejects( + () => + service.execute( + { name: extName, version: "1.0.0" } as ExtensionRef, + makeInstallContext(repoDir, lockfileRepository), + ), + Error, + "simulated SQLite I/O fault", + ); + + // Catalog state: SQLite txn rolled back, no rows survive. + assertEquals(faultingRepo.loadByName(extName).length, 0); + assertEquals(catalog.findAll().length, 0); + // Lockfile + FS state: the install service does NOT roll these + // back for generic errors. Both still hold the partial install. + assertEquals( + lockfileRepository.getEntry(extName)?.version, + "1.0.0", + ); + + // Second attempt: no fault scheduled. The diff-save in saveAll + // reconciles the catalog against the (still-on-disk) FS+lockfile + // state, so the retry succeeds and the catalog now matches. + const retry = await service.execute( + { name: extName, version: "1.0.0" } as ExtensionRef, + makeInstallContext(repoDir, lockfileRepository), + ); + assertEquals(retry?.name, extName); + assertEquals(faultingRepo.loadByName(extName).length, 1); + }, + ); + }, +); diff --git a/src/libswamp/extensions/remove_extension_service_test.ts b/src/libswamp/extensions/remove_extension_service_test.ts index 6031c237..91924587 100644 --- a/src/libswamp/extensions/remove_extension_service_test.ts +++ b/src/libswamp/extensions/remove_extension_service_test.ts @@ -25,6 +25,7 @@ import { InstallExtensionService } from "./install_extension_service.ts"; import type { InstallContext, InstallResult } from "./pull.ts"; import { ExtensionCatalogStore } from "../../infrastructure/persistence/extension_catalog_store.ts"; import { ExtensionRepository } from "../../infrastructure/persistence/extension_repository.ts"; +import { FaultingStubRepository } from "../../infrastructure/persistence/test_helpers/faulting_stub_repository.ts"; import { LockfileRepository } from "../../infrastructure/persistence/lockfile_repository.ts"; import { swampPath } from "../../infrastructure/persistence/paths.ts"; import { UserError } from "../../domain/errors.ts"; @@ -398,3 +399,92 @@ Deno.test( ); }, ); + +// ============================================================= +// Crash-state recovery (W2 plan v4 step 10) +// ============================================================= +// +// Order: catalog tombstone-save → lockfile remove → FS delete. A fault +// inside the catalog `saveAll` (the very FIRST mutation of rm) must +// roll back via SQLite ROLLBACK so retry sees the extension still +// installed. Lockfile + FS are not yet touched, so the retry is a +// clean re-rm. + +Deno.test( + "RemoveExtensionService.execute: catalog saveAll fault leaves install state unchanged and retry succeeds", + async () => { + await withFixtureRepo( + async ({ repoDir, repository, catalog, lockfileRepository }) => { + const ts = Date.now(); + const extName = `@test/crash-rm-${ts}`; + const typeId = `@test/crash-rm-model-${ts}`; + await stageModel( + repoDir, + extName, + "model.ts", + MINIMAL_MODEL_CODE(typeId), + ); + + // Install first via the real repository so the on-disk + + // lockfile + catalog state is real. + const installSvc = new InstallExtensionService({ + denoRuntime: testDenoRuntime, + repository, + installExtensionFn: async (ref, ctx) => { + await ctx.lockfileRepository.writeEntry( + ref.name, + "1.0.0", + [`.swamp/pulled-extensions/${ref.name}/models/model.ts`], + ); + return makeStubInstallResult( + ref.name, + "1.0.0", + [`.swamp/pulled-extensions/${ref.name}/models/model.ts`], + ); + }, + }); + await installSvc.execute( + { name: extName, version: "1.0.0" }, + makeInstallContext(repoDir, lockfileRepository), + ); + + // Pre-fault snapshot. + const preFaultRows = catalog.findAll().length; + assertEquals(repository.loadByName(extName).length, 1); + assertEquals(lockfileRepository.getEntry(extName)?.version, "1.0.0"); + + // Wrap the same catalog in a faulting repo for the rm path. + const faultingRepo = new FaultingStubRepository({ + catalog, + lockfileRepository, + repoRoot: repoDir, + }); + faultingRepo.injectSaveAllFault( + new Error("simulated SQLite I/O fault during rm"), + ); + + const removeSvc = new RemoveExtensionService({ + repository: faultingRepo, + lockfileRepository, + repoDir, + }); + + // First attempt: faults inside the tombstone saveAll. SQLite + // ROLLBACK preserves all rows; lockfile + FS untouched. + await assertRejects( + () => removeSvc.execute(extName), + Error, + "simulated SQLite I/O fault during rm", + ); + assertEquals(catalog.findAll().length, preFaultRows); + assertEquals(repository.loadByName(extName).length, 1); + assertEquals(lockfileRepository.getEntry(extName)?.version, "1.0.0"); + + // Second attempt: no fault. Clean rm; everything cleared. + await removeSvc.execute(extName); + assertEquals(repository.loadByName(extName).length, 0); + assertEquals(lockfileRepository.getEntry(extName), null); + }, + ); + }, +); From 970c684103e99f0ceb1d609491b8323a451c2298 Mon Sep 17 00:00:00 2001 From: stack72 Date: Tue, 5 May 2026 15:38:44 +0100 Subject: [PATCH 11/15] =?UTF-8?q?docs(extensions):=20W2=20commit=209=20?= =?UTF-8?q?=E2=80=94=20design/extension.md=20lifecycle=20services=20sectio?= =?UTF-8?q?n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents the W2 lifecycle layer end-to-end so a reader can understand the catalog-write surface without reading the source. - Removal section: updated to describe catalog → lockfile → filesystem ordering (closes swamp-club#201) and the lifecycle's "not installed" semantics for partial-state extensions. - New "Lifecycle Services" section covers: - The three services (Install / Remove / Upgrade) and the rule that CLI commands construct services rather than reaching into the catalog directly. - Asymmetric ordering and the rationale (catalog-first rm vs catalog-last install — pinned in plan v4 challenge #3). - Phase 8: synchronous type extraction at install via per-loader `bundleAndIndexOne`, with the Pin 1 contract that loaders never write to the catalog. - The atomic upgrade pattern `saveAll([tombstoneAll(v1), v2])` and the I-Repo-1-evaluates-post-state semantics that make it safe. - FS rollback on DuplicateTypeError + the pinned `DuplicateTypeUserError` JSON shape consumers can rely on. - Bounded atomicity: per-extension is the unit, not the bulk run. - Crash-state recovery posture for both install and rm. - W3+ inheritance: services persist; per-loader methods are the eventual deletion surface. No code changes. --- design/extension.md | 138 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 135 insertions(+), 3 deletions(-) diff --git a/design/extension.md b/design/extension.md index ff6a41f1..e5b1b20f 100644 --- a/design/extension.md +++ b/design/extension.md @@ -873,17 +873,149 @@ ignored by migration rather than swept. ## Removal -Installed extensions can be removed with `extension rm`. Removal deletes all -files tracked in the extension's `files` array in `upstream_extensions.json`, -prunes empty parent directories, and removes the entry from the tracking file. +Installed extensions can be removed with `extension rm`. Removal first +tombstones the extension's catalog rows (so its `(kind, type)` slots are +released atomically in one SQLite transaction), then removes the lockfile +entry, then deletes the on-disk files tracked in +`upstream_extensions.json` and prunes empty parent directories. If other installed extensions list the target as a dependency (detected by scanning their `manifest.yaml` files on disk), a warning is displayed before proceeding. +A double-rm yields a clean `Extension is not installed.` user +error on the second call — the lifecycle service decides "not installed" +when both the catalog AND the lockfile confirm absence, so partial-state +extensions still rm cleanly. + Extensions pulled before file tracking was added cannot be removed cleanly — the user is prompted to re-pull with `--force` to populate the file list first. +## Lifecycle Services + +`InstallExtensionService`, `RemoveExtensionService`, and +`UpgradeExtensionService` (in `src/libswamp/extensions/`) are the three +narrow seams through which the catalog gets written. The CLI command +files do not call the catalog directly — they construct the appropriate +service and call `execute(...)`. This is the W2 split that closed +[swamp-club#201](https://swamp-club.com/issues/201) (rm now prunes +catalog rows) and unblocks the W3+ unified-loader work. + +### Asymmetric ordering + +Install is **filesystem → lockfile → catalog**. Remove is the +**inverse**: **catalog → lockfile → filesystem**. + +The asymmetry is not aesthetic. If rm went filesystem-first, the catalog +would briefly point at deleted bundle files and any concurrent type +resolution would crash for that window. Catalog-first means a mid-rm +crash leaves files on disk but the catalog clean — the next loader pass +surfaces the orphans via `findStaleFiles`. Symmetrically, install +catalog-last means a mid-install crash leaves on-disk files + lockfile +entry but no catalog rows; the next loader pass rebuilds the catalog +from the on-disk content via the existing cold-start path. + +### Phase 8: synchronous type extraction at install + +After the install service has written files to disk and the lockfile +entry, **phase 8** walks the per-extension subtree, calls each loader's +`bundleAndIndexOne(args)` for every source file, builds an `Extension` +aggregate whose Sources land in `Indexed` state with +`(kind, typeNormalized, bundlePath)` populated, and commits via +`repository.saveAll([extension])` in one SQLite transaction. The +repository's I-Repo-1 invariant — no two non-tombstoned Sources may +share `(kind, typeNormalized)` — fires synchronously at install time +rather than at the next steady-state loader pass. This is the +user-visible payoff of the W2 split: a cross-extension type collision +surfaces as a clean `DuplicateTypeUserError` *before* the user sees a +"successfully pulled" message. + +`bundleAndIndexOne` is a strict per-loader contract: it bundles + type- +extracts + returns metadata, but **does not write to the catalog**. The +lifecycle service is the catalog-write owner — keeping it that way is +what lets I-Repo-1 fire on every install consistently. + +### Atomic upgrade pattern + +For every new aggregate the install service is about to save, it +tombstones any existing aggregate with the *same name* but a *different +version*, then submits everything to `saveAll` in one transaction: + +``` +saveAll([tombstoneAll(v1), ..., v2]) +``` + +I-Repo-1 evaluates the **post-save** state, so the slot is held by +exactly one occupant: the new version. Without this pattern, +force-pulling an already-installed extension (or any version-bump pull) +would fail with `DuplicateTypeError` even though the only "conflict" is +the user's own prior version. Re-installs of the same version skip the +tombstone — the diff-save in `saveAll` handles overwrite semantics. + +`UpgradeExtensionService` is a thin facade over +`InstallExtensionService.execute(...)` that lets call sites express +upgrade intent at the call site; the atomic-tombstone logic lives +inside the install service's phase 8. + +### FS rollback on DuplicateTypeError + +A genuine cross-extension `DuplicateTypeError` (two different extensions +trying to claim the same `(kind, typeNormalized)`) triggers an explicit +filesystem rollback before the error propagates: extracted files are +deleted and the lockfile entry is restored to its pre-install state. +SQLite ROLLBACK does not undo filesystem mutations, so the service does +this work explicitly. The error then propagates as a +`DuplicateTypeUserError` (a `UserError` subclass) so the top-level CLI +handler renders a clean single-line message in log mode and a structured +`duplicateType` object in `--json` mode: + +```json +{ + "error": "Type \"@scope/foo\" (kind=model) is already claimed by ...", + "duplicateType": { + "kind": "model", + "type": "@scope/foo", + "existing": { "extensionName": "...", "extensionVersion": "...", "canonicalPath": "..." }, + "conflicting": { "extensionName": "...", "extensionVersion": "...", "canonicalPath": "..." } + } +} +``` + +The user-visible message points the user at +`swamp extension rm ` to recover. + +### Bounded atomicity + +Each `execute(...)` is its own transaction. Bulk operations +(`extension update` over N extensions) run N independent transactions — +not one all-or-nothing batch. If extension A's upgrade rolls back due +to a collision with unchanged extension B, every other extension +already-upgraded in the bulk run **stays upgraded**. This is the +explicit bounded-atomicity contract: the unit of atomicity is the +single extension, never a multi-extension run. + +### Crash-state recovery + +A generic non-`DuplicateTypeError` failure inside `repository.saveAll` +(SQLite I/O error, OOM, process kill mid-commit) leaves the catalog in +its pre-save state via SQLite ROLLBACK. The filesystem and lockfile are +**not** auto-rolled-back — only `DuplicateTypeError` triggers FS +rollback. A retry succeeds: the diff-save in `saveAll` reconciles the +catalog against the on-disk + lockfile state. + +For rm, the catalog tombstone is the **first** mutation, so a fault +inside that `saveAll` leaves all three layers (catalog, lockfile, FS) +in their pre-rm state — a retry is a clean re-rm. + +### W3+ inheritance + +The lifecycle services' shape is W3-stable. The unified loader +(`KindAdapter`) work in W3 collapses the five per-loader +`bundleAndIndexOne` methods to one dispatch, but the install/remove/ +upgrade services keep their current public surface. CLI command files' +direct service construction (in `extension_pull.ts`, +`extension_update.ts`, `extension_rm.ts`, etc.) persists past W3. + ## Lazy Per-Bundle Loading Extension bundles are loaded lazily — individual bundles are imported on demand From 5196ae77c4b3334ff559755fc38c0d5704e60fd7 Mon Sep 17 00:00:00 2001 From: stack72 Date: Tue, 5 May 2026 17:11:20 +0100 Subject: [PATCH 12/15] =?UTF-8?q?fix(extensions):=20W2=20commit=2010=20?= =?UTF-8?q?=E2=80=94=20wire=20half-state=20UserError=20on=20phase=208=20fa?= =?UTF-8?q?ult?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the documented-but-unimplemented gap from commit 6: a non- DuplicateTypeError fault inside InstallExtensionService phase 8 (or the RemoveExtensionService catalog tombstone-save) previously rethrew a plain Error with no recovery guidance. The service's JSDoc had pinned a user-visible message; this commit actually surfaces it. - InstallExtensionService.execute: wrap non-DuplicateTypeError saveAll faults in a UserError. Message names the extension, the underlying fault for diagnostics, and points at `swamp doctor extensions` / retry `swamp extension pull ` to reconcile. - RemoveExtensionService.execute: wrap saveAll faults in a UserError. SQLite ROLLBACK already kept the catalog in its pre-rm state, so the message tells the user nothing changed and to retry. - Crash-state recovery tests now assert the new UserError type and that the message names the extension, includes the underlying fault for diagnostics, and carries the recovery action. Verification: deno fmt / lint / check / test (5432 passed) + compile + swamp-uat extension suite (87 passed) against the freshly compiled binary. --- .../extensions/install_extension_service.ts | 15 +++++++++- .../install_extension_service_test.ts | 28 ++++++++++++++++--- .../extensions/remove_extension_service.ts | 15 +++++++++- .../remove_extension_service_test.ts | 27 +++++++++++++++--- 4 files changed, 75 insertions(+), 10 deletions(-) diff --git a/src/libswamp/extensions/install_extension_service.ts b/src/libswamp/extensions/install_extension_service.ts index b9a17bbc..76b0da20 100644 --- a/src/libswamp/extensions/install_extension_service.ts +++ b/src/libswamp/extensions/install_extension_service.ts @@ -35,6 +35,7 @@ import { makeBundleLocation } from "../../domain/extensions/bundle_location.ts"; import type { ExtensionRepository } from "../../infrastructure/persistence/extension_repository.ts"; import { DuplicateTypeError } from "../../infrastructure/persistence/duplicate_type_error.ts"; import { DuplicateTypeUserError } from "../../domain/extensions/duplicate_type_user_error.ts"; +import { UserError } from "../../domain/errors.ts"; import { swampPath } from "../../infrastructure/persistence/paths.ts"; import { UserModelLoader } from "../../domain/models/user_model_loader.ts"; import { UserDriverLoader } from "../../domain/drivers/user_driver_loader.ts"; @@ -184,7 +185,19 @@ export class InstallExtensionService { await this.rollbackOnCollision(result, priorEntry, ctx); throw mapDuplicateTypeErrorToUserError(error); } - throw error; + // Half-state from a non-DuplicateTypeError fault during phase 8. + // Files + lockfile entry are on disk (the install service does + // NOT auto-roll-back FS for generic faults — see crash-state + // recovery posture in design/extension.md). Surface a UserError + // with the pinned recovery message so log-mode shows a clean + // single-line guidance instead of a stack trace. + throw new UserError( + `Install partially applied for ${ref.name} — files extracted but the ` + + `catalog write failed (${ + error instanceof Error ? error.message : String(error) + }). Run \`swamp doctor extensions\` to inspect, or retry ` + + `\`swamp extension pull ${ref.name}\` to reconcile.`, + ); } return result; diff --git a/src/libswamp/extensions/install_extension_service_test.ts b/src/libswamp/extensions/install_extension_service_test.ts index 3674c91d..d35a3386 100644 --- a/src/libswamp/extensions/install_extension_service_test.ts +++ b/src/libswamp/extensions/install_extension_service_test.ts @@ -378,16 +378,36 @@ Deno.test( }, }); - // First attempt: faults inside saveAll, propagates the error. - await assertRejects( + // First attempt: faults inside saveAll. The lifecycle service + // wraps the underlying error in a UserError with the pinned + // half-state recovery message so log-mode shows clean guidance + // and `--json` consumers see the recovery hint in `error`. + const thrown = await assertRejects( () => service.execute( { name: extName, version: "1.0.0" } as ExtensionRef, makeInstallContext(repoDir, lockfileRepository), ), - Error, - "simulated SQLite I/O fault", + UserError, + "Install partially applied", ); + // The pinned message names the extension and the recovery action. + if (!(thrown.message.includes(extName))) { + throw new Error( + `expected message to name extension ${extName}, got: ${thrown.message}`, + ); + } + if (!(thrown.message.includes("swamp doctor extensions"))) { + throw new Error( + `expected message to mention \`swamp doctor extensions\`, got: ${thrown.message}`, + ); + } + // The underlying fault message is preserved for diagnostics. + if (!(thrown.message.includes("simulated SQLite I/O fault"))) { + throw new Error( + `expected message to include underlying fault, got: ${thrown.message}`, + ); + } // Catalog state: SQLite txn rolled back, no rows survive. assertEquals(faultingRepo.loadByName(extName).length, 0); diff --git a/src/libswamp/extensions/remove_extension_service.ts b/src/libswamp/extensions/remove_extension_service.ts index d554dc47..9c7b2cde 100644 --- a/src/libswamp/extensions/remove_extension_service.ts +++ b/src/libswamp/extensions/remove_extension_service.ts @@ -106,7 +106,20 @@ export class RemoveExtensionService { // filesystem still hold the extension — the next loader pass // surfaces them via findStaleFiles. if (extensions.length > 0) { - this.repository.saveAll(extensions.map(tombstoneAll)); + try { + this.repository.saveAll(extensions.map(tombstoneAll)); + } catch (error) { + // SQLite ROLLBACK kept the catalog in its pre-rm state, so a + // retry is a clean re-rm. Surface a UserError so log-mode + // shows the guidance without a stack trace. + throw new UserError( + `Remove failed for ${name} during catalog write ` + + `(${ + error instanceof Error ? error.message : String(error) + }). The catalog was rolled back; the extension is unchanged. ` + + `Retry \`swamp extension rm ${name}\`.`, + ); + } } // 3. Lockfile remove. Cache + disk both flush in writeEntry's diff --git a/src/libswamp/extensions/remove_extension_service_test.ts b/src/libswamp/extensions/remove_extension_service_test.ts index 91924587..6a486f74 100644 --- a/src/libswamp/extensions/remove_extension_service_test.ts +++ b/src/libswamp/extensions/remove_extension_service_test.ts @@ -470,12 +470,31 @@ Deno.test( }); // First attempt: faults inside the tombstone saveAll. SQLite - // ROLLBACK preserves all rows; lockfile + FS untouched. - await assertRejects( + // ROLLBACK preserves all rows; lockfile + FS untouched. The + // lifecycle service surfaces the failure as a UserError with a + // clean retry hint instead of a raw stack trace. + const thrown = await assertRejects( () => removeSvc.execute(extName), - Error, - "simulated SQLite I/O fault during rm", + UserError, + "Remove failed", ); + if (!(thrown.message.includes(extName))) { + throw new Error( + `expected message to name extension ${extName}, got: ${thrown.message}`, + ); + } + if (!(thrown.message.includes("Retry"))) { + throw new Error( + `expected message to suggest retry, got: ${thrown.message}`, + ); + } + if ( + !(thrown.message.includes("simulated SQLite I/O fault during rm")) + ) { + throw new Error( + `expected message to include underlying fault, got: ${thrown.message}`, + ); + } assertEquals(catalog.findAll().length, preFaultRows); assertEquals(repository.loadByName(extName).length, 1); assertEquals(lockfileRepository.getEntry(extName)?.version, "1.0.0"); From 58336716c93ceee0e59db6bf99863b24ba91af4d Mon Sep 17 00:00:00 2001 From: stack72 Date: Tue, 5 May 2026 18:01:56 +0100 Subject: [PATCH 13/15] =?UTF-8?q?fix(extensions):=20W2=20commit=2011=20?= =?UTF-8?q?=E2=80=94=20phase=208=20writes=20absolute=20source=5Fpath?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the cosmetic "Dropping orphan row" warning observed during `extension rm` in real-world smoke testing on a fresh repo. **Root cause.** Phase 8's `buildExtensionFromDisk` joined the per-extension subtree off the user-supplied `repoDir`, which the CLI passes through unchanged from `Deno.cwd()`. With a relative `repoDir` (or one whose canonicalization differs from a later filesystem walk), the `source_path` written to the catalog was relative — but the legacy `user_*_loader.ts` `populateCatalogFromDir` bootstrap (which runs the first time any model command fires after pull) walks with absolute paths. The two forms didn't UPSERT onto each other, leaving: - 8 rows with relative source_path + full identity (from phase 8) - 8 rows with absolute source_path + EMPTY identity (from the legacy bootstrap; legacy `upsert()` never touches identity columns) `loadByName` materialised the full table and ran the empty-identity fallback for the 8 stragglers — which on macOS where /tmp symlinks to /private/tmp couldn't always resolve, producing one "Dropping orphan row" warning per source file before the rm succeeded. **Fix.** Resolve `repoDir` to absolute in `buildExtensionFromDisk` before computing `extRoot`. Phase 8 now writes the same absolute `source_path` form the legacy bootstrap uses; the second `upsert()` hits the existing row's `ON CONFLICT(source_path)` clause and the identity columns survive (they're not in the legacy upsert's UPDATE list). No more empty-identity duplicates, no more orphan warnings on rm. Smoke test result: catalog goes from 16 rows (8 named + 8 ghost) to 8 rows; `extension rm` now produces 0 orphan warnings. Regression test: new `catalog source_path is absolute` test pins the contract so a future refactor can't reintroduce relative paths. Verification: deno fmt / lint / check / test (5433 passed, +1 for the new regression) + recompiled binary + UAT extension suite (87 passed) + smoke retest (0 warnings on rm). --- .../extensions/install_extension_service.ts | 16 +++- .../install_extension_service_test.ts | 73 +++++++++++++++++++ 2 files changed, 87 insertions(+), 2 deletions(-) diff --git a/src/libswamp/extensions/install_extension_service.ts b/src/libswamp/extensions/install_extension_service.ts index 76b0da20..9dfe6aed 100644 --- a/src/libswamp/extensions/install_extension_service.ts +++ b/src/libswamp/extensions/install_extension_service.ts @@ -17,7 +17,7 @@ // You should have received a copy of the GNU Affero General Public License // along with Swamp. If not, see . -import { join, relative } from "@std/path"; +import { join, relative, resolve as resolvePath } from "@std/path"; import { type ExtensionRef, type InstallContext, @@ -215,7 +215,19 @@ export class InstallExtensionService { result: InstallResult, repoDir: string, ): Promise { - const extRoot = join(swampPath(repoDir, "pulled-extensions"), result.name); + // Resolve to an absolute path so the source_path written to the + // catalog matches the absolute paths the legacy user_*_loader + // bootstrap path uses (`populateCatalogFromDir`). Without this, a + // relative `repoDir` (e.g. `.`) would have phase 8 writing relative + // source_paths while the loader writes absolute ones, leaving + // duplicate rows in the catalog and the legacy upsert's empty- + // identity rows would shadow phase 8's properly-identified rows on + // the next `loadByName` call. + const absoluteRepoDir = resolvePath(repoDir); + const extRoot = join( + swampPath(absoluteRepoDir, "pulled-extensions"), + result.name, + ); const sources: ReturnType[] = []; for (const kindDir of KIND_DIRS) { diff --git a/src/libswamp/extensions/install_extension_service_test.ts b/src/libswamp/extensions/install_extension_service_test.ts index d35a3386..921648d6 100644 --- a/src/libswamp/extensions/install_extension_service_test.ts +++ b/src/libswamp/extensions/install_extension_service_test.ts @@ -232,6 +232,79 @@ Deno.test( }, ); +// ============================================================= +// Catalog source_path absolute-form regression +// ============================================================= +// +// Phase 8 must write the catalog's `source_path` column as the absolute +// canonical path so that the legacy `populateCatalogFromDir` bootstrap +// (in `user_*_loader.ts`) — which uses absolute paths from a filesystem +// walk — UPSERTs onto the same row instead of inserting a duplicate +// empty-identity row at the absolute form. +// +// Pre-fix symptom: a brand-new repo + `extension pull` + any subsequent +// model command produced two rows per source file (one relative, +// identity-populated; one absolute, identity-empty). The empty- +// identity rows triggered cosmetic "Dropping orphan row" warnings on +// the next `loadByName` because the lockfile-fallback couldn't match +// them when the user passed `repoDir` as a relative path. +Deno.test( + "InstallExtensionService.execute: catalog source_path is absolute (legacy populateCatalogFromDir compat)", + async () => { + await withFixtureRepo( + async ({ repoDir, repository, catalog, lockfileRepository }) => { + const ts = Date.now(); + const extName = `@test/abs-path-${ts}`; + const typeId = `@test/abs-path-model-${ts}`; + await stageModel( + repoDir, + extName, + "noop.ts", + MINIMAL_MODEL_CODE(typeId), + ); + + const service = new InstallExtensionService({ + denoRuntime: testDenoRuntime, + repository, + installExtensionFn: async (ref, ctx) => { + await ctx.lockfileRepository.writeEntry( + ref.name, + "1.0.0", + [`.swamp/pulled-extensions/${ref.name}/models/noop.ts`], + ); + return makeStubInstallResult(ref.name, "1.0.0", [ + `.swamp/pulled-extensions/${ref.name}/models/noop.ts`, + ]); + }, + }); + + await service.execute( + { name: extName, version: "1.0.0" } as ExtensionRef, + makeInstallContext(repoDir, lockfileRepository), + ); + + // The row's source_path must be absolute (start with `/` on + // POSIX, or contain a drive letter on Windows). Relative paths + // would leave the legacy loader's UPSERT key-mismatched and + // create duplicate empty-identity rows. + const rows = catalog.findAll().filter((r) => + r.extension_name === extName + ); + assertEquals(rows.length, 1); + const sourcePath = rows[0].source_path; + const isAbsolute = Deno.build.os === "windows" + ? /^[a-zA-Z]:[\\/]/.test(sourcePath) + : sourcePath.startsWith("/"); + assertEquals( + isAbsolute, + true, + `source_path must be absolute, got: ${sourcePath}`, + ); + }, + ); + }, +); + Deno.test( "InstallExtensionService.execute: DuplicateTypeError triggers FS rollback and surfaces as UserError", async () => { From f594361ca99888d89734ea6ae6fbc4f133ab2eb0 Mon Sep 17 00:00:00 2001 From: stack72 Date: Tue, 5 May 2026 18:13:35 +0100 Subject: [PATCH 14/15] docs(extensions): explain why plan v4 step 9's literal form isn't tested MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The order-independence test exercises the bulk-upgrade case (two extensions, distinct rows). Plan v4 step 9's literal form `saveAll([v2, tombstoneAll(v1)])` within a single extension is a no-op concept under the catalog's `source_path` primary key — v1 and v2 of the same extension at the same path share the same row. Document the reason inline so a future reader doesn't try to re-add the literal test and find it untestable. Per architecture-agent review of W2. --- .../persistence/extension_repository_test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/infrastructure/persistence/extension_repository_test.ts b/src/infrastructure/persistence/extension_repository_test.ts index 1899130c..296688e1 100644 --- a/src/infrastructure/persistence/extension_repository_test.ts +++ b/src/infrastructure/persistence/extension_repository_test.ts @@ -290,6 +290,16 @@ Deno.test("ExtensionRepository: saveAll([vN.tombstoneAll(), vN+1]) succeeds when // Guards against a future regression where saveAll iterates in a way // that lets one extension's mid-loop intermediate state leak into // another's diff (e.g., I-Repo-1 fired mid-loop on a transient state). +// +// **Note on plan v4 step 9's literal form.** The plan describes +// `saveAll([v2, tombstoneAll(v1)])` (inverted order, single extension). +// That form isn't tested separately because the catalog's primary key +// is `source_path`: v1 and v2 of the same extension share the same +// source_path, which means they share the same row. "Order matters / +// doesn't matter" within a single source_path is a no-op concept. +// This bulk-upgrade test (two distinct extensions, distinct rows) +// exercises the meaningful generalization of the order-independence +// claim. Deno.test("ExtensionRepository: saveAll bulk-upgrade is order-independent across extensions", () => { function bulkUpgrade(repoRoot: string): { pairs: ReadonlyArray; From 3ec655f42f3e2d2567bcddefc51530d81949ec5a Mon Sep 17 00:00:00 2001 From: stack72 Date: Tue, 5 May 2026 19:08:12 +0100 Subject: [PATCH 15/15] fix(extensions): close catalog handle in two rm_test.ts tests for Windows EBUSY MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two tests in `rm_test.ts` (\`extensionRm: removing one sibling…\` and \`extensionRmPreview: resolves dependents…\`) call \`createExtensionRmDeps\` directly instead of going through the \`fakeDeps\` helper. After commit 4 the deps factory opens an \`ExtensionCatalogStore\` (\`_extension_catalog.db\`), which on Windows holds the file open until \`.close()\` is called. The tests' \`Deno.remove(tmpDir, { recursive: true })\` then fails with \`os error 32: The process cannot access the file because it is being used by another process\`. Fix: close \`deps.repository.legacyStore\` before the recursive remove and wrap the remove in the standard Windows-only \`.catch(() => {})\` per the CLAUDE.md guidance — same pattern \`fakeDeps\` already uses. CI green on ubuntu-latest; Windows job (windows-latest) was the sole failing leg of PR #1310. --- src/libswamp/extensions/rm_test.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/libswamp/extensions/rm_test.ts b/src/libswamp/extensions/rm_test.ts index 91f673da..cec59d58 100644 --- a/src/libswamp/extensions/rm_test.ts +++ b/src/libswamp/extensions/rm_test.ts @@ -306,8 +306,15 @@ Deno.test("extensionRm: removing one sibling leaves the other intact under a sha eksFiles, "eks lockfile entry must be untouched", ); + // Close the catalog opened by createExtensionRmDeps; on Windows the + // open DB handle blocks the recursive remove below with EBUSY. + deps.repository.legacyStore.close(); } finally { - await Deno.remove(tmpDir, { recursive: true }); + if (Deno.build.os === "windows") { + await Deno.remove(tmpDir, { recursive: true }).catch(() => {}); + } else { + await Deno.remove(tmpDir, { recursive: true }); + } } }); @@ -370,7 +377,14 @@ Deno.test("extensionRmPreview: resolves dependents via the tracked per-extension }); assertEquals(preview.dependents, ["@fake/consumer"]); + // Close the catalog opened by createExtensionRmDeps; on Windows the + // open DB handle blocks the recursive remove below with EBUSY. + deps.repository.legacyStore.close(); } finally { - await Deno.remove(tmpDir, { recursive: true }); + if (Deno.build.os === "windows") { + await Deno.remove(tmpDir, { recursive: true }).catch(() => {}); + } else { + await Deno.remove(tmpDir, { recursive: true }); + } } });