Skip to content

feat(plugins/lifecycle): state machine — closes #45#339

Merged
tayebmokni merged 1 commit into
mainfrom
feat/45-plugin-lifecycle
May 17, 2026
Merged

feat(plugins/lifecycle): state machine — closes #45#339
tayebmokni merged 1 commit into
mainfrom
feat/45-plugin-lifecycle

Conversation

@tayebmokni
Copy link
Copy Markdown
Contributor

Summary

Implements the plugin lifecycle state machine — install / activate / deactivate / uninstall with persistence — per docs/02-plugin-system.md §3.

This is the state-machine + persistence half of the plugin system. The WASM runtime (issue #6) and bundle verification (issue #44) are out of scope; the Manager wires them through Runtime and Migrator seams that default to NoopRuntime / NoopMigrator.

What landed in packages/go/plugins/lifecycle/

  • State enum: Installed, Active, Inactive, PendingUninstall, Errored.
  • Plugin struct: slug / version / abi_version / manifest (raw JSON) / state / capabilities / last_error / error_at / installed_at / activated_at / row_version / updated_at.
  • Manager with Install, Activate, Deactivate, Uninstall(removeData), Reset, Get, List.
  • Storage interface with two implementations:
    • MemoryStorage for unit tests + dev.
    • PostgresStorage against the plugins table (CREATE TABLE migration deferred; schema documented in doc.go).
  • Atomic state transitions: every transition goes through Storage.UpdateState, which performs a conditional UPDATE … WHERE slug = $? AND state = $expected. Concurrent callers race at the database (or, for MemoryStorage, at the mutex) — exactly one wins, the rest receive ErrInvalidTransition.
  • Audit emission on every transition: plugin.installed, plugin.activated, plugin.deactivated, plugin.uninstalled, plugin.errored, plugin.reset. Failed audit emission is logged (never blocks the transition).
  • Errored state captures LastError + ErrorAt on any failed transition. Reset is the only path back to Inactive.

Test plan

  • All valid transitions exercised end-to-end against MemoryStorage.
  • Invalid transitions return ErrInvalidTransition (e.g. Activate from PendingUninstall, Uninstall from Active, Reset from Active).
  • 64-goroutine concurrent Activate race: exactly one wins, the rest return ErrInvalidTransition. Clean under -race.
  • Errored recovery: install → fail-activate (runtime error injected) → ResetActivateActive. Audit chain intact.
  • Audit events emitted for every transition; verified via in-memory audit.MemoryStore.
  • PostgresStorage: insert / get / list / update-state / delete paths covered with a fake PgxQuerier. Unique-violation translation tested. CAS lost-race produces ErrInvalidTransition.
  • go build ./plugins/lifecycle/... clean.
  • go vet ./plugins/lifecycle/... clean.
  • go test -race -count=1 -cover ./plugins/lifecycle/...92.9 % coverage (83 tests, all passing).

🤖 Generated with Claude Code

tayebmokni added a commit that referenced this pull request May 17, 2026
## Summary
- `go list -m all` walks transitive deps; vetting downloaded module
source from `$GOPATH/pkg/mod` fails on packages that reference deps we
don't have (testcontainers → docker → containerd/v2; some testutil paths
→ dgryski/trifles)
- Replace with iteration over `go.work` `use` directives — the canonical
first-party module list
- Same fix applied to both `lint-go: go vet` and `test-go: go test
-race`

This is the root cause of lint-go failures on the entire Wave C: PRs
#328, #329, #334, #335, #336, #338, #339.

## Test plan
- [ ] CI passes on this PR
- [ ] After merge, rebase Wave C PRs and verify they go green

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: Mohamed Tayeb Mokni <tayeb.mokni@gmail.com>
Co-authored-by: Mohamed Tayeb Mokni <tayeb.mokni@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
@tayebmokni tayebmokni force-pushed the feat/45-plugin-lifecycle branch from 6f18602 to fc9864d Compare May 17, 2026 12:50
tayebmokni added a commit that referenced this pull request May 17, 2026
## Summary
- Workspace is `go 1.25.0`; golangci-lint v1.61.0 was built with go1.23
and refuses to lint
- Bump to v2.12.2 (built with go1.25)
- No `.golangci.yml` in repo, so the v1→v2 config-format change has no
migration cost

After this lands, rebase Wave C PRs to pick it up. Should unblock #328,
#329, #334, #335, #336, #338, #339, #341.

## Test plan
- [ ] CI passes here
- [ ] Wave C PRs pass after rebase

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Mohamed Tayeb Mokni <tayeb.mokni@gmail.com>
Co-authored-by: Mohamed Tayeb Mokni <tayeb.mokni@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
@tayebmokni tayebmokni force-pushed the feat/45-plugin-lifecycle branch from fc9864d to 6131729 Compare May 17, 2026 12:55
…machine — closes #45

Signed-off-by: Claude (for tayebmokni) <tayeb.mokni@gmail.com>
@tayebmokni tayebmokni force-pushed the feat/45-plugin-lifecycle branch from 6131729 to c62950e Compare May 17, 2026 13:05
@tayebmokni tayebmokni merged commit 4950891 into main May 17, 2026
8 checks passed
@tayebmokni tayebmokni deleted the feat/45-plugin-lifecycle branch May 17, 2026 13:10
tayebmokni added a commit that referenced this pull request May 18, 2026
…loses #245 (#371)

## Summary

Closes #245. Adds the `gonext plugin dev <project-dir>` subcommand for
plugin authors: auto-detect the toolchain, build to WASM, upload to a
running dev host, watch for changes, and hot-reload. Lives in
`cli/gonext/cmd/plugin/`.

Foundation merged so the plugin platform now has: wazero runtime (#350),
manifest validator (#345), capability registry (#344), instance pool
(#357), resource limits (#356), hook ABI (#364), lifecycle (#339). This
PR is the productive author-side dev loop on top of all that.

### Flags

```
gonext plugin dev [flags] <project-dir>

  --host         URL of the running gonext dev host (default http://localhost:8080)
  --watch        Watch the project tree and hot-reload on change (default true)
  --build-only   Build only; skip upload and watch
  --lang         auto | go | tinygo | rust (default auto)
```

### Architecture

The orchestrator (`runDevLoop`) runs four phases — detect → build →
upload → watch — and every I/O dependency funnels through an injectable
seam:

| Seam | Production | Test |
|---|---|---|
| `CommandRunner` | `execRunner` (exec.CommandContext) | `fakeRunner`
recording argv + optional Hook to plant artifacts |
| `Uploader` | `httpUploader` (multipart POST) | `stubUploader` counter
/ `httptest.Server` |
| `Watcher` (factory) | `fsnotifyWatcher` (recursive Add) |
`fakeWatcher` (buffered chans) |
| `Now func()` | `time.Now` | frozen clock |

That lets the watch+rebuild loop be unit-tested end-to-end without
forking, real filesystem races, or network round-trips. 64 test cases
across six files.

### Per-language build commands

| Language | Detection | Command | Output |
|---|---|---|---|
| TinyGo | `go.mod` present | `tinygo build -o build/plugin.wasm
-target=wasi .` | `build/plugin.wasm` |
| Rust | `Cargo.toml` present | `cargo build --target wasm32-wasi
--release` | Copied from `target/wasm32-wasi/release/*.wasm` to
`build/plugin.wasm` |

Both markers present produces an ambiguity error rather than a silent
pick. The Rust path errors if cargo produces more than one `.wasm` (asks
the author to set `[lib].name`).

### Watch loop

- `fsnotify` recursively registers the project tree at startup, skipping
`build/`, `target/`, `node_modules/`, `.git/`, `.idea/`, `.vscode/`,
`vendor/`, `dist/`, `__pycache__/`.
- Generic `debounce[T any]` with a 200ms trailing-edge window collapses
bursty inotify events (single save → single rebuild). Tested with
20–30ms windows and synthetic event sources.
- Editor tempfiles (`*~`, `.#*`) and our own `build/` writes are
filtered so the loop doesn't feed itself.
- First-pass build/upload errors propagate out (non-zero exit). Errors
during watch are printed but the loop keeps running — the operator's
next save is probably the fix.
- SIGINT / SIGTERM → graceful exit 0.

### Capability diff

After each successful build the manifest's `capabilities` list is
compared against the previous snapshot:

```
[19:11:29] detected language: tinygo
[19:11:29] build: tinygo
[19:11:29] upload: http://localhost:8080
capabilities:
  = http.fetch
  = db.read
[19:11:30] uploaded successfully
[19:11:30] watching /path/to/plugin (Ctrl-C to stop)
[19:11:42] change detected — rebuilding
capabilities changed:
  + media.write
  - db.read
```

This is what gives the operator a clear before/after view between hot
reloads — the capability set is what the host is being asked to grant.

## TODO — host-side endpoint follow-up

This CLI uploads to `POST ${host}/_/plugins/dev/install` but the
host-side endpoint is **not in this PR**. The contract this CLI assumes:

- Path: `/_/plugins/dev/install`
- Method: `POST`
- Content-Type: `multipart/form-data`
- Parts:
- `manifest` — file part, `application/json`, the `manifest.json` bytes
  - `wasm` — file part, `application/wasm`, the compiled module
- Success: any 2xx (body ignored)
- Failure: any non-2xx; body's first 1KiB is included verbatim in the
CLI's error message

The natural host-side implementation calls the existing lifecycle
Manager's `Install(ctx, bundle io.Reader)` — that function already
validates the manifest and handles slug/version/abi extraction. The dev
install endpoint would also need to bypass signature verification
(dev-only) and idempotently re-activate on every upload. Followup issue
suggested: \"feat(server): host-side dev plugin install endpoint\".

## Test plan

- [x] `cd cli/gonext && go vet ./...` — clean
- [x] `cd cli/gonext && go test -race -count=1 ./cmd/plugin/...` — 64
cases pass under `-race`
- [x] `cd cli/gonext && go test -race -count=1 ./...` — entire CLI
module green
- [x] `cd cli/gonext && go build ./...` — builds
- [x] Language detector: tinygo via `go.mod`, rust via `Cargo.toml`,
ambiguous error when both present, missing-marker error, dir-not-file
rejected
- [x] Build orchestrator: TinyGo argv = `[build -o
<abs>/build/plugin.wasm -target=wasi .]`, Rust artifact normalisation,
multiple `.wasm` error path, runner failure propagates
- [x] Multipart upload: httptest server roundtrip verifies path,
Content-Type, both parts, byte fidelity; host trailing-slash + subpath
join correctly; 4xx body preview surfaces; missing wasm errors; relative
host rejected
- [x] Debounce: 5-event burst collapses to 1 emission; two bursts 60ms
apart fire twice; ctx-cancel closes out-channel; input-close closes
out-channel
- [x] Capability diff: first build prints `=` list, no-change emits
nothing, add/remove deltas use `+`/`-`
- [x] Dispatcher: `--build-only` skips upload + watch, watch+rebuild on
event, watch keeps running through build error and recovers on next
event, usage/help/missing-arg/extra-arg/not-a-directory exit codes
- [ ] Manual smoke against a running dev host — gated on the host-side
endpoint follow-up

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: Mohamed Tayeb Mokni <tayeb.mokni@gmail.com>
Co-authored-by: Mohamed Tayeb Mokni <tayeb.mokni@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
tayebmokni added a commit that referenced this pull request May 19, 2026
…406)

## Summary

Wires `apps/web/` as a real Next.js App Router app that turns a merged
block tree + active theme + resolved template into rendered HTML pages.
All foundations (blocks-core #352/#378, theme parser #319, template
hierarchy #328, theme seeding #381, gn-hello #354, gn-pro #358, block
render walker #339) already landed — this PR ties them together so a
visitor on the site sees something.

- **Catch-all route** (`src/app/[...slug]/page.tsx`): fetch post by slug
  → resolve template → walk blocks → wrap in theme parts. Falls through
  to the 404 template path when no post matches.
- **Homepage** (`src/app/page.tsx`): renders the latest-posts archive.
- **Block walker** (`src/lib/blocks.ts`): mirrors the Go walker. Seeds
  the 16 core handlers from a new `@gonext/blocks-core/server`
  sub-entry (see below); plugins extend via `registerBlock(name, fn)`.
- **Typed API client** (`src/lib/api.ts`): four endpoints with inline
  fallbacks + documented TODOs so the renderer is testable end-to-end
  while the Go endpoints are wired.
- **Theme helper** (`src/lib/theme.ts`): `defaultActiveTheme()` matches
  gn-hello so the site still paints when the API is offline.
- **Renderer** (`src/lib/render.ts`): centralises cache policy:
- logged-out singular/archive: `public, s-maxage=300,
stale-while-revalidate=86400`
  - logged-out 404: `public, s-maxage=60, stale-while-revalidate=300`
  - logged-in (any session cookie present): `private, no-store`

Also adds **`@gonext/blocks-core/server`** — a new SSR-only sub-entry
that exposes just the pure `serverRender` / `save` hints (no React,
no editor surface). The main barrel re-exports `Edit` components, each
of which transitively imports React hooks without `'use client'`;
pulling those into a server component fails the Next.js build. The
new entry sidesteps that for any SSR consumer.

## Endpoints assumed (documented TODOs in `src/lib/api.ts`)

- `GET /api/v1/posts/by-slug/{slug}`
- `GET /api/v1/themes/active`
- `GET /api/v1/themes/active/template?type=...&postType=...&...`
- `GET /api/v1/posts?status=published&postType=...&limit=...`

Each call site treats network errors / 404 as "endpoint unavailable"
and falls back to a documented `<template>.fallback` basename, so e2e
tests can distinguish API path from fallback path without parsing
headers.

## Test plan

- [x] `pnpm --filter @gonext/web test` — 44 tests pass
- [x] `pnpm --filter @gonext/web typecheck` — clean
- [x] `pnpm --filter @gonext/web lint` — no warnings
- [x] `pnpm --filter @gonext/web build` — succeeds, standalone bundle
emitted
- [x] `pnpm --filter @gonext/blocks-core test` — 163 tests still pass
(new `./server` entry doesn't break anything)
- [ ] Wire the four documented endpoints on the Go side and verify the
      fallback paths drop out of e2e snapshots

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: Mohamed Tayeb Mokni <tayeb.mokni@gmail.com>
Co-authored-by: Mohamed Tayeb Mokni <tayeb.mokni@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants