Initial Commit
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user