254 lines
11 KiB
Markdown
254 lines
11 KiB
Markdown
# 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.
|