Files
suaveplan/openspec/changes/_superseded-narrow-deferrals-2026-05-17/add-installer-upgrade/design.md
2026-05-27 23:29:53 +01:00

11 KiB
Raw Blame History

Design: add-installer-upgrade

This document captures the non-normative design for the deferred upgrade flow. Normative requirements live in specs/installer/spec.md.

Flow diagram

suaveplan-install upgrade <plugin> [--from X] [--to Y] [--dry-run] [--force]
        │
        ▼
┌────────────────────────────────────────────────────────────────────────┐
│ Host.upgrade(plugin, options)                                          │
└────────────────────────────────────────────────────────────────────────┘
        │
        ├─► loadState(fs, cwd)
        │       state-file: .suaveplan/installer-state.json
        │       missing entry → INSTALLER.UPGRADE_NOT_INSTALLED
        │
        ├─► resolveTargetVersion(plugin, cwd)
        │       reads node_modules/@<scope>/<pkg>/package.json
        │       missing → INSTALLER.UPGRADE_VERSION_NOT_FOUND
        │
        ├─► if installed === target && !force
        │       → INSTALLER.UPGRADE_SAME_VERSION   (idempotent no-op)
        │
        ├─► preflight
        │       ├─ detectLockfile()  → UPGRADE_LOCKFILE_MISSING
        │       └─ detectDirtyTree() → UPGRADE_DIRTY_TREE (unless --allow-dirty)
        │
        ├─► hashTree(targetDir) → currentHashes (rollback snapshot key)
        │
        ├─► plan(answers, ctx) at fromVersion → fromOps
        │   plan(answers, ctx) at toVersion   → toOps
        │
        ├─► computeDiff(fromOps, toOps)
        │       { added, removed, changed, unchanged }
        │
        ├─► materialiseDiff(diff) → FileOp[]
        │       additions → write
        │       removals  → delete
        │       changes   → write (overwrite) | patch-json (merge)
        │
        ├─► Host.applyOps(ops)                ◄── EXISTING TRANSACTIONAL PATH
        │       staged-tmp + atomic rename (INSTALLER-04)
        │
        ├─► runMigrations(selected, ctx)
        │       throws → rollback transaction → UPGRADE_MIGRATION_FAILED
        │
        ├─► writeState({ pluginId, version: to, installedAt, fileHashes })
        │       atomic write → fsync → rename
        │
        └─► OTel: span "installer.upgrade" closes with attributes
                  plugin.id, plugin.version.from, plugin.version.to, dry_run,
                  files.added, files.removed, files.changed

Atomicity model

The upgrade is one transaction, not two. The diff-apply step and the migration-run step share the same staged-tmp directory created by Host.applyOps. The window is:

  1. Stage — every write/patch-* op writes its content to <targetDir>/.tmp-<txn>/<path>. Deletions are recorded as "move-aside to <targetDir>/.tmp-<txn>/.deleted/<path>" — files are moved, not removed, so rollback can restore them. No live files have been mutated yet.
  2. Migrate — MigrationScripts run against the staged tree (their MigrationContext.fs is rooted at the tmp directory). They can mutate staged files or emit additional FileOps, which are appended to the plan and re-staged into the same tmp directory.
  3. Commit — once every stage step has succeeded, the host issues atomic rename(tmp, live) for every staged path in dependency order (parent dirs before children for write, children before parents for delete).
  4. Persist state — only after every rename commits does the host write the new installer-state.json (itself via the same write-fsync-rename dance scoped to one file).

Any failure in steps 1–3 walks the journal in reverse: staged tmp files are unlinked; moved-aside files are renamed back; the state-file is untouched. The user's tree is bit-identical to the pre-upgrade state.

A rollback failure (e.g. fs returns EIO on the restore rename) surfaces as InstallerError(INSTALLER.ROLLBACK_FAILED, { cause: <original apply error> }) exactly as INSTALLER-04 already prescribes for the add flow.

Snapshot / rollback strategy

The "snapshot" is not a copy of the tree — that would be linear in target size and unacceptable for a skills bundle. Instead, the snapshot is:

  1. The pre-upgrade hashTree — recorded before staging so the upgrade has a content-addressed manifest of what existed.
  2. The move-aside journal — every file the diff intends to delete or overwrite is moved to <tmp>/.deleted/<path> (rename is O(1) on the same filesystem). Disk usage during upgrade equals size(diff.changed ∪ diff.removed) rather than size(entire tree).
  3. The new staged content — every write lands in <tmp>/<path> and is renamed in only on commit.

Rollback walks the journal in reverse: rename moved-aside files back to their live paths, unlink staged files, rmdir the tmp directory.

Migration script contract

// In a plugin: src/upgrade.ts
import type { MigrationTable, MigrationScript } from "@suaveplan/installer/migrations";

const v1_to_v2: MigrationScript = async (ctx) => {
    // ctx.fs is rooted at the staged tmp directory.
    // ctx.signal honours --abort.
    // ctx.logger is the host's structured logger.
    // ctx.from = "1.0.0", ctx.to = "2.0.0"
    const old = await ctx.fs.readFile("skills/legacy.json");
    await ctx.fs.writeFile("skills/legacy.yaml", convert(old));
    await ctx.fs.delete("skills/legacy.json");
};

export const upgradeTable: MigrationTable = {
    "1.0.0->2.0.0": v1_to_v2,
};

Rules:

  • Keys are "<from>-><to>" semver strings. The selector finds the shortest path of contiguous edges from the installed version to the target.
  • Scripts MUST be pure with respect to outside-the-tmp state. Network access is permitted (e.g. fetching upstream renames) but discouraged.
  • Scripts MAY throw. A throw rolls the transaction back and surfaces INSTALLER.UPGRADE_MIGRATION_FAILED with { cause }.
  • Scripts MAY return additional FileOp[] that the host appends to the plan and re-stages before commit.

CLI behaviour

Flag Effect
--dry-run Computes diff + migration list, prints the plan, exits before staging.
--force Collapses conflict policies to overwrite AND bypasses the dirty-tree guard. Equivalent to --allow-dirty + per-file overwrite.
--allow-dirty Bypasses dirty-tree guard but does NOT change conflict policies. Used when the operator knows their edits are reconcilable.
--no-interactive Any prompt op fails with INSTALLER.APPLY_CONFLICT (existing semantics).
--from <version> Overrides the version detected from the state file. Useful when the state file is missing or stale.
--to <version> Overrides the version resolved from node_modules. Useful for pin-rollforward simulations.
--answers <file> Same as add — JSON file supplying answers for any prompts the plan emits.

Idempotency

Two notions, both held:

  1. Same-version upgrade is a no-op. installed == target exits with INSTALLER.UPGRADE_SAME_VERSION without staging anything.
  2. Successful upgrade is replayable. If the user runs the same upgrade again, the state file already reflects target, so step 1 fires. There is no way to "double-apply" a migration script.

State file shape

{
    "$schema": "installer-state/v1",
    "plugins": {
        "agent-skills": {
            "version": "0.7.0",
            "installedAt": "2026-05-17T12:34:56Z",
            "targetDir": ".suaveplan/skills",
            "fileHashes": {
                "skills/crypto/SKILL.md": "blake3:...",
                "skills/events/SKILL.md": "blake3:..."
            }
        }
    }
}

The schema version ($schema) is bumped any time the shape changes; the loader rejects unknown versions with INSTALLER.UPGRADE_STATE_INVALID.

Coverage strategy

Two fixture plugins live under src/testing/fixtures/:

  • upgrade-fixture-plugin@1.0.0 — emits two files (a.txt, b.txt) and one patch-json op against data.json.
  • upgrade-fixture-plugin@2.0.0 — drops b.txt, renames a.txt → a-renamed.txt, adds c.txt, changes the patch-json merge, and ships one migration script 1.0.0->2.0.0 that rewrites data.json schema.

Together they exercise every branch in computeDiff, every conflict policy in the diff materialiser, both the migration-success and migration-throw paths, and both the same-version-noop and version-not-found paths. 100% istanbul falls out naturally.

File layout

packages/tooling/installer/src/
    state/
        state.types.ts
        state.schemas.ts
        state-file.ts
        state-file.md
        state-file.spec-conformance.test.ts
        hash-tree.ts
        hash-tree.md
    host/
        upgrade/
            upgrade.ts
            upgrade.md
            upgrade.spec-conformance.test.ts
            preflight.ts
            preflight.md
            preflight.spec-conformance.test.ts
            dirty-tree.spec-conformance.test.ts
            diff.ts
            diff.md
            apply-diff.spec-conformance.test.ts
            migrations.ts
            migrations.types.ts
            migrations.md
            migrations.spec-conformance.test.ts
            index.ts
    testing/
        upgrade-plugin.ts
        upgrade-plugin.md
        fixtures/
            upgrade-fixture-plugin.ts
            upgrade-fixture-plugin.md

Decisions and trade-offs

  • Move-aside vs full snapshot. Move-aside wastes one rename per affected file but is O(1) per file. A full snapshot would be safer against concurrent writes but the installer already requires the user to not be editing files concurrently — INSTALLER-04 makes the same trade-off for add. Consistency with add wins.
  • BLAKE3 over SHA-256. @suaveplan/crypto-hash ships both. BLAKE3 is ~3× faster for the small-file case (skill files average <4 KB) and parallelisable. No security requirement (this is a change-detection hash, not a tamper-proof signature).
  • Migration scripts run AFTER diff stage, not before. Running them before would mean diffing against a tree the migration script has already mutated, which makes the "did the user modify this file?" question ambiguous. The order is: diff first (against the unmodified staged tree), then migrate.
  • No network resolution. Resolving "latest" from npm would couple the installer to a registry shape, force network access in CI, and conflict with the project's air-gapped use cases. The target version is whatever resolves from node_modules; the operator bumps the dep with their package manager first.