Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,74 @@
# `@suaveplan/api-lifecycle` — plugin lifecycle FSM definition
**Package**: `@suaveplan/api-lifecycle`
**Status**: PROPOSED
**Type**: new-package
**Stage**: 1 (Contracts) — see umbrella `openspec/changes/api-plugin-foundation/`
## Why
Design §4 specifies the plugin lifecycle as a finite-state machine with 13 states (`uninstalled`, `installing`, `installed`, `starting`, `started`, `rolled_back`, `crashed`, `stopping`, `stopped`, `uninstalling`, `install_failed`, `start_failed`, `uninstall_failed`) and a documented set of transitions. The host driver, the admin API status renderer, the hot-reload primitive, and operator tooling all need to agree on which transitions are legal — and a graph that lives in only one place. This package owns the FSM definition: the states (as a discriminated union), the transition function, the side-effect descriptors per transition (so the host can wire them), and the per-plugin mutex contract that guarantees at-most-one transition at a time. Reuses `@suaveplan/fsm` for the state-machine primitive — this package is the FSM *definition*, not a new FSM runtime.
Per design §4: "The FSM has a per-plugin mutex — at most one transition at a time. Concurrent triggers queue."
## What changes
Net-new package. Pure definition: states + transitions + side-effect descriptors + retry policies. No concrete plugin code; that lives in `api-host` (Stage 2).
- ADDED: `./states` — `PluginState` discriminated union (re-imported from `@suaveplan/api-types` for runtime introspection); state-shape descriptors.
- ADDED: `./transitions` — `PluginTransitions`: the legal `(from, to, trigger)` set as a frozen table. Trigger names: `install`, `installCompleted`, `installFailed`, `start`, `startCompleted`, `startFailed`, `rollbackTriggered`, `stop`, `stopCompleted`, `uninstall`, `uninstallCompleted`, `uninstallFailed`, `crashDetected`, `crashRestart`, `clearRollback`, `retryInstall`, `retryStart`, `forceUninstall`, `repair`.
- ADDED: `./fsm-factory` — `createPluginFsm({ initial, sideEffects, mutex })` returns an FSM bound to the genesis `@suaveplan/fsm` runtime, initialised with `PluginTransitions` and the side-effect bus from design §4's transition table.
- ADDED: `./side-effects` — descriptors for each transition's side effects (per §4 table): `installing → installed` emits `plugin.installed`, completes OTel span; `stopping → stopped` force-cancels stragglers, disposes capabilities reverse-registration order; etc. Descriptors are data; the host runtime executes them.
- ADDED: `./retry-policies` — `start_failed` auto-retry × 3 with `@suaveplan/retry` exponential backoff; `crashed` auto-restart × `crashRestartMax` (default 3); restart budget reset after `crashRestartCooldownMs` of healthy operation. Default values exported.
- ADDED: `./mutex-contract` — `PluginMutex` interface: at most one transition per plugin in flight; concurrent triggers queue (FIFO).
- ADDED: `./reinstall-criteria` — `requiresReinstall(prev, next): boolean` returning true when the new manifest bumps `database.schemaVersion`, declares new `scopes`/`events`/`jobs`/`secrets`, or changes `dependsOn` (per §4 reload semantics).
- ADDED: `./error-codes` — frozen `LIFECYCLE_CODES`: `ILLEGAL_TRANSITION`, `MUTEX_HELD`, `RETRY_BUDGET_EXHAUSTED`, `REINSTALL_REQUIRED_VERSION_DOWNGRADE`.
- ADDED: `./testing` — `runLifecycleConformance(factory)` reusable harness.
## Out of scope
- The host's lifecycle DRIVER (the code that actually CALLS `install`/`start`/`stop` hooks and reads side-effect descriptors) — lives in `api-host` (Stage 2).
- Plugin ledger persistence — `api-host`.
- Worker-tier `crashed` state restart implementation — `api-host` + `api-worker-host` (Stage 3).
## Source mapping
Genesis-original. Source of design intent: `docs/superpowers/specs/2026-05-13-api-plugin-foundation-design.md`.
| Requirement | Design doc section |
|---|---|
| State machine (13 states) | §4 (FSM ASCII diagram, lines 409-473) |
| Transition side-effects table | §4 (table, lines 475-484) |
| Reinstall criteria (`schemaVersion` bumped, new `scopes`/`events`/`jobs`/`secrets`, `dependsOn` changed) | §4 (line 486) |
| `start_failed` auto-retry × 3 + `rolled_back` path | §4 (failure recovery section, lines 491-493) |
| `crashed` auto-restart × `crashRestartMax`, cooldown | §4 (line 493) |
| Per-plugin mutex; concurrent triggers queue | §4 (final paragraph) |
| `VERSION_DOWNGRADE` failure | §4 (line 486) |
### Requirements with no source line
| Requirement | Justification |
|---|---|
| Trigger naming (`installCompleted` etc.) | Mechanically derived from the §4 table; pinned here so the host and admin renderers agree. |
| `runLifecycleConformance` harness | Stage-5 deliverable (§13.4). |
## Verification approach
- `src/states/states.test.ts` — `PluginState` union matches design §4 exactly (no missing / extra states).
- `src/transitions/transitions.test.ts` — exhaustive transition table: every documented `(from, to)` pair is legal; an undocumented pair throws `LIFECYCLE.ILLEGAL_TRANSITION`.
- `src/fsm-factory/fsm-factory.test.ts` — `createPluginFsm` wires `@suaveplan/fsm` correctly; transition emits the right side-effect descriptors.
- `src/side-effects/side-effects.test.ts` — every documented side effect is in the table for the right transition (`installed → starting` mounts routes, etc.).
- `src/retry-policies/retry-policies.test.ts` — `start_failed` retried up to 3 times then parked; `crashed` restarted up to `crashRestartMax`; budget reset after cooldown.
- `src/mutex-contract/mutex-contract.test.ts` — concurrent triggers queue FIFO; second trigger waits until first releases mutex.
- `src/reinstall-criteria/reinstall-criteria.test.ts` — every documented trigger (`schemaVersion`, new scope/event/job/secret, `dependsOn` change) yields `true`; identical manifest yields `false`; version downgrade yields `REINSTALL_REQUIRED_VERSION_DOWNGRADE`.
- `tests/dep-graph.test.ts` — deps: `@suaveplan/api-types`, `@suaveplan/fsm`, `@suaveplan/retry`, `@suaveplan/error`, `@suaveplan/types`.
## Migration impact
None — additive only.
## Impact
### Affected dependents
- (none) — [x] no action needed (reason: net-new; first consumer is `api-host` in Stage 2)

View File

@@ -0,0 +1,165 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: api-lifecycle (api-lifecycle)
**Package**: `@suaveplan/api-lifecycle`
**Status**: PROPOSED
> Every requirement carries a `**Verified by:**` line.
## ADDED Requirements
### Requirement: PluginState union pinned to design §4
The package SHALL export `PluginState` (re-exported from `@suaveplan/api-types`) with exactly: `"uninstalled" | "installing" | "installed" | "starting" | "started" | "rolled_back" | "crashed" | "stopping" | "stopped" | "uninstalling" | "install_failed" | "start_failed" | "uninstall_failed"`. The package SHALL also export `PLUGIN_STATES` as a runtime tuple matching the union.
#### Scenario: union matches design §4 exactly
- **WHEN** `expectTypeOf<PluginState>()` is checked against the 13 literals
- **THEN** the union is exactly those 13 — no more, no less
#### Scenario: runtime tuple matches the union
- **WHEN** the test asserts `PLUGIN_STATES` covers every union member
- **THEN** every member is present in the tuple
**Verified by:** `src/states/states.spec-conformance.test.ts::"PluginState union matches design §4 exactly"` AND `src/states/states.test.ts::"PLUGIN_STATES tuple covers every union member"`
---
### Requirement: Transitions table is exhaustive
The package SHALL export `PluginTransitions` — a frozen `(from, to, trigger)` table. Every transition documented in design §4's table MUST be present. A transition not in the table MUST throw `LIFECYCLE.ILLEGAL_TRANSITION` when attempted.
#### Scenario: every documented transition is legal
- **WHEN** the test enumerates the §4 table
- **THEN** every documented `(from, to)` pair has an entry in `PluginTransitions`
#### Scenario: undocumented transition rejected
- **WHEN** `transition({ from: "installed", to: "stopped" })` is attempted (illegal: must pass through `starting`/`stopping`)
- **THEN** `LIFECYCLE.ILLEGAL_TRANSITION` is thrown AND `details.from === "installed"` AND `details.to === "stopped"`
**Verified by:** `src/transitions/transitions.test.ts::"every documented transition is legal"` AND `src/transitions/transitions.test.ts::"undocumented transition rejected"`
---
### Requirement: createPluginFsm wires `@suaveplan/fsm`
The package SHALL export `createPluginFsm({ initial, sideEffects, mutex })` returning a `Fsm<PluginState, PluginTrigger>` backed by `@suaveplan/fsm`. Transitions MUST honour the mutex contract (FIFO queue, at most one in-flight per plugin).
#### Scenario: fsm starts in declared initial state
- **WHEN** `createPluginFsm({ initial: "uninstalled", ... })` is created
- **THEN** `fsm.state === "uninstalled"`
#### Scenario: concurrent triggers queue
- **WHEN** two `install` triggers are dispatched concurrently
- **THEN** the second waits until the first completes AND completes against the post-first state (likely rejecting if the FSM left `uninstalled`)
**Verified by:** `src/fsm-factory/fsm-factory.test.ts::"fsm starts in declared initial state"` AND `src/mutex-contract/mutex-contract.test.ts::"concurrent triggers queue FIFO"`
---
### Requirement: Side-effect descriptors per transition
The package SHALL export `SIDE_EFFECTS`: a map from `(from, to)` to the documented side-effect list. `installing → installed` MUST emit `plugin.installed` and complete the OTel span. `started → stopping` MUST pause jobs, unsubscribe events, unmount routes with 503 + Retry-After, call user `stop` hook with `gracefulMs` (default 30000). `stopping → stopped` MUST force-cancel stragglers AND dispose capabilities in reverse-registration order. Every transition documented in §4's table MUST appear here.
#### Scenario: every documented side-effect is in the table
- **WHEN** the test compares `SIDE_EFFECTS` keys against §4's transition table
- **THEN** every documented transition has an entry
#### Scenario: capability disposal order is reverse-registration
- **WHEN** the `stopping → stopped` descriptor is inspected
- **THEN** it declares `disposalOrder: "reverse-registration"`
**Verified by:** `src/side-effects/side-effects.test.ts::"every documented side-effect is in the table"` AND `src/side-effects/side-effects.test.ts::"stopping→stopped disposes in reverse-registration order"`
---
### Requirement: Retry policies
The package SHALL export `RETRY_POLICIES.startFailed` (3 attempts, exponential backoff via `@suaveplan/retry`) and `RETRY_POLICIES.crashed` (default `crashRestartMax: 3`, `crashRestartWindowMs`, `crashRestartCooldownMs`). The `crashed` budget MUST reset after `crashRestartCooldownMs` of healthy operation. Exhausting any budget MUST surface `LIFECYCLE.RETRY_BUDGET_EXHAUSTED`.
#### Scenario: start_failed retried 3 times then parked
- **WHEN** the start hook throws 4 times in a row
- **THEN** the 4th attempt does NOT retry; the plugin is parked AND `LIFECYCLE.RETRY_BUDGET_EXHAUSTED` is the failure code
#### Scenario: crashed budget resets after cooldown
- **WHEN** a plugin crashes twice within the window, then runs healthily for `crashRestartCooldownMs`, then crashes again
- **THEN** the post-cooldown crash is treated as the first crash in a new budget window
**Verified by:** `src/retry-policies/retry-policies.test.ts::"start_failed retried 3 times then parked"` AND `src/retry-policies/retry-policies.test.ts::"crashed budget resets after cooldown"`
---
### Requirement: requiresReinstall criteria
The package SHALL export `requiresReinstall(prev: PluginManifest, next: PluginManifest): boolean` returning `true` IFF any of: `next.database.schemaVersion > prev.database.schemaVersion`; `next.scopes` adds names not in `prev.scopes`; `next.events.publishes` or `next.events.subscribes` add new topics; `next.jobs.cron` or `next.jobs.queued` add new names; `next.secrets` adds a new secret; `next.dependsOn` differs from `prev.dependsOn`. A downgrade (`next.version < prev.version`) MUST throw `LIFECYCLE.REINSTALL_REQUIRED_VERSION_DOWNGRADE`.
#### Scenario: identical manifest returns false
- **WHEN** `prev` and `next` are deep-equal
- **THEN** `requiresReinstall(prev, next) === false`
#### Scenario: schemaVersion bump returns true
- **WHEN** `next.database.schemaVersion` is greater than `prev`'s
- **THEN** the result is `true`
#### Scenario: version downgrade rejected
- **WHEN** `semver.lt(next.version, prev.version)` is true
- **THEN** the call throws `LIFECYCLE.REINSTALL_REQUIRED_VERSION_DOWNGRADE`
**Verified by:** `src/reinstall-criteria/reinstall-criteria.test.ts::"identical manifest returns false"` AND `src/reinstall-criteria/reinstall-criteria.test.ts::"schemaVersion bump returns true"` AND `src/reinstall-criteria/reinstall-criteria.test.ts::"version downgrade throws"`
---
### Requirement: Mutex contract
The package SHALL export `PluginMutex` interface: `acquire(plugin: string): Promise<Release>`, `tryAcquire(plugin: string): Release | null`. At most one acquired release per plugin. Concurrent acquires queue FIFO. The interface is types-only; concrete impl lives in `api-host`.
#### Scenario: every documented method present
- **WHEN** `expectTypeOf<PluginMutex>()` is checked
- **THEN** `acquire` and `tryAcquire` are declared
#### Scenario: tryAcquire returns null when held
- **WHEN** the conformance harness exercises a stub: acquire then `tryAcquire`
- **THEN** the second call returns `null`
**Verified by:** `src/mutex-contract/mutex-contract.spec-conformance.test.ts::"PluginMutex declares acquire tryAcquire"` AND `src/mutex-contract/mutex-contract.test.ts::"tryAcquire returns null when held"`
---
### Requirement: Error codes registry frozen
The package SHALL export `LIFECYCLE_CODES` containing exactly: `ILLEGAL_TRANSITION`, `MUTEX_HELD`, `RETRY_BUDGET_EXHAUSTED`, `REINSTALL_REQUIRED_VERSION_DOWNGRADE`.
#### Scenario: every code reachable
- **WHEN** the test enumerates `LIFECYCLE_CODES`
- **THEN** every code has a production throw site
**Verified by:** `src/error-codes/error-codes.spec-conformance.test.ts::"every LIFECYCLE_CODES entry thrown by production path"`
---
### Requirement: Conformance harness for host driver
The package SHALL export `runLifecycleConformance({ factory, label })`. The Stage-2 `api-host` driver MUST pass it.
#### Scenario: harness covers states, transitions, side-effects, retry budgets, mutex, reinstall criteria
- **WHEN** the harness runs against a stub driver
- **THEN** test categories exist for each documented behavior
**Verified by:** `src/testing/conformance.test.ts::"harness covers states transitions side-effects retry-budgets mutex reinstall-criteria"`

View File

@@ -0,0 +1,76 @@
# Tasks — `@suaveplan/api-lifecycle`
> Every checkbox MUST reflect work actually done in this change.
## 0. Pre-flight (non-negotiable)
- [x] 0.1 Confirm clean working tree (`git status` reports nothing) and no in-flight worktrees with uncommitted work (`git worktree list` then `git status` in each)
- [x] 0.2 If any worktrees exist under `.claude/worktrees/`, account for each one: still-active agent, work-pending-merge, or stale-and-prune. Stale worktrees MUST be removed (`git worktree remove`) before opening a new one — code that has been lost twice on main is the failure mode this guards against.
- [x] 0.3 Confirm the package set in `MANIFEST.md` matches `find packages -maxdepth 3 -name package.json` count. Drift here means a prior change archived without regenerating the manifest.
## 1. Spec authoring
- [x] 1.1 Read design doc §4 (full section)
- [x] 1.2 Read `openspec/specs/api-lifecycle/spec.md` (does not exist yet)
- [x] 1.3 Read `openspec/AGENTS.md` Sections 13, 14
- [x] 1.4 Draft `proposal.md`
- [x] 1.5 Source-mapping table
- [x] 1.6 Draft `specs/api-lifecycle/spec.md`
- [x] 1.7 `**Verified by:**` line on every requirement
## 2. Implementation
- [x] 2.1 Scaffold `packages/api/api-lifecycle/` (`"version": "0.1.0"`, `"sideEffects": false`, deps: `@suaveplan/api-types`, `@suaveplan/fsm`, `@suaveplan/retry`, `@suaveplan/error`, `@suaveplan/types`)
- [x] 2.2 `src/states/` — `PluginState` constants + runtime introspection helpers
- [x] 2.3 `src/transitions/` — `PluginTransitions` frozen table
- [x] 2.4 `src/fsm-factory/` — `createPluginFsm` binding to `@suaveplan/fsm`
- [x] 2.5 `src/side-effects/` — descriptors per transition
- [x] 2.6 `src/retry-policies/` — `start_failed` + `crashed` retry policies
- [x] 2.7 `src/mutex-contract/` — `PluginMutex` interface (impl in `api-host`)
- [x] 2.8 `src/reinstall-criteria/` — `requiresReinstall(prev, next)` pure function
- [x] 2.9 `src/error-codes/` — `LIFECYCLE_CODES`
- [x] 2.10 `src/testing/` — `runLifecycleConformance(factory)`
- [x] 2.11 `src/index.ts` — explicit named re-exports
- [x] 2.12 `package.json` `exports` per subpath
## 3. Tests
- [x] 3.1 Unit tests per feature
- [x] 3.2 Spec-conformance type tests (states union matches design §4 exactly)
- [x] 3.3 Property test on `requiresReinstall` (identical manifests → false; any reinstall-triggering diff → true)
- [x] 3.4 Every `it(...)` carries `{ timeout: 5000 }`
## 4. Documentation
- [x] 4.1 Sibling `.md` for every implementation `.ts`
- [x] 4.2 `README.md` linking every `.md`
- [x] 4.3 No CLAUDE.md update
## 5. Pre-archive verification
- [x] 5.1 `bun run check` — 0/0/0
- [x] 5.2 `bunx turbo lint typecheck test --filter=@suaveplan/api-lifecycle` — green
- [x] 5.3 `bunx turbo test:coverage --filter=@suaveplan/api-lifecycle` — 100/100/100/100 (mutex-contract types-only file — N/A)
- [x] 5.4 `bun scripts/openspec-conformance.ts @suaveplan/api-lifecycle` — exit 0
- [x] 5.5 `bunx turbo build --filter=@suaveplan/api-lifecycle` — green
## 6. Pre-archive 7-row checklist
- [x] 6.1 Generics — none required at lifecycle level; `expectTypeOf` pins state union exactness
- [x] 6.2 Zod — N/A (no inputs to validate at this layer; manifest is validated by `api-manifest`)
- [x] 6.3 Every `LIFECYCLE_CODES` entry thrown
- [x] 6.4 Retry on/off behaviors tested; `crashRestartCooldownMs` budget reset tested
- [x] 6.5 OTel — N/A (host driver emits)
- [x] 6.6 Lifecycle dedup — mutex contract pins at-most-one-in-flight; concurrent-trigger queue test
- [x] 6.7 No retroactive tick
## 7. Archive
- [x] 7.1 Fold spec delta
- [x] 7.2 Move to `openspec/archive/<yyyy-mm-dd>-api-lifecycle/`
- [x] 7.3 Tick umbrella `1.12`
- [x] 7.4 Refresh `MANIFEST.md`
## Status
STATUS ✅ SHIPPED 2026-05-25 — @suaveplan/api-lifecycle 0.1.0 (lint+typecheck+test green, 103 tests, 100/100/100/100 coverage)