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

254 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
```text
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** — `MigrationScript`s run against the staged tree (their
`MigrationContext.fs` is rooted at the tmp directory). They can mutate
staged files or emit additional `FileOp`s, 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 `rename`d 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
```ts
// 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
```jsonc
{
"$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
```text
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.