Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ jobs:
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
path: plugin
# ui/persistence-compat.test.mjs loads every published bundle.
fetch-depth: 0

- name: Check out kandev SDK
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,8 @@ jobs:
with:
path: plugin
ref: ${{ github.event_name == 'push' && github.ref || needs.prepare.outputs.tag }}
# Release verification replays every published Notes bundle.
fetch-depth: 0

- name: Checkout kandev SDK
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
Expand Down
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ run: build
test:
go test ./server/...
node --test "ui/**/*.test.mjs"
sh scripts/test-host-upgrade-state.sh

fmt:
gofmt -l .
Expand Down
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,28 @@ Sideloaded plugins register disabled/unverified; enable it in
version in `manifest.yaml` (and `Makefile`) first. To use "Enhance with AI",
select an agent profile as described in "Setting up Enhance with AI" above.

## Updating or downgrading without losing notes

Install the different Notes version directly over the existing installation,
using Kandev's Update/version action or the install endpoint above. Kandev
replaces that plugin version in place, and Notes keeps addressing the same
per-user task and workspace rows. The automated release gate replays every
published Notes bundle in both directions and verifies the saved Markdown
byte-for-byte.

Do **not** click Uninstall as a step in changing versions. Explicit uninstall
has different semantics: Kandev intentionally purges that plugin's user state,
including all Notes rows, before removing the package. Uninstall/reinstall is
therefore not an update or downgrade path.

For a disposable manual smoke check:

1. Save distinct task and workspace notes, including Markdown formatting.
2. Install a different Notes version directly over the active version.
3. Reopen both notes and compare their exact content.
4. Install the original version directly over it and verify both notes again.
5. Optionally repeat as a second user to confirm the two users remain isolated.

## Development

The Kandev plugin SDK (`pkg/pluginsdk`) is not yet published as a standalone Go
Expand Down
50 changes: 50 additions & 0 deletions integration/host_upgrade_preserves_user_state_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package plugins

// This file is applied to Kandev's internal/plugins package through a Go
// overlay by scripts/test-host-upgrade-state.sh. Keeping the test in this
// repository lets every Notes release gate the exact host lifecycle it
// depends on without modifying the sibling Kandev checkout.

import (
"context"
"testing"
)

const notesPersistencePluginID = "kandev-plugin-notes"

type notesUserStateCleanupSpy struct {
pluginIDs []string
}

func (s *notesUserStateCleanupSpy) DeleteAllForPlugin(_ context.Context, pluginID string) error {
s.pluginIDs = append(s.pluginIDs, pluginID)
return nil
}

func TestNotesPluginVersionReplacementPreservesUserState(t *testing.T) {
svc, _, _ := newTestService(t)
cleanup := &notesUserStateCleanupSpy{}
svc.setUserStateCleanupStore(cleanup)

installTestPlugin(t, svc, notesPersistencePluginID)

for _, version := range []string{"1.1.0", "0.9.0"} {
rec, err := svc.Install(
t.Context(),
testPackage(t, notesPersistencePluginID, version, false),
)
if err != nil {
t.Fatalf("Install(%s) over existing Notes version: %v", version, err)
}
if rec.Version != version {
t.Fatalf("Install(%s) recorded version %q", version, rec.Version)
}
if len(cleanup.pluginIDs) != 0 {
t.Fatalf(
"Install(%s) purged plugin user state for %v; version replacement must never call DeleteAllForPlugin",
version,
cleanup.pluginIDs,
)
}
}
}
8 changes: 6 additions & 2 deletions manifest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
# plugin and a kandev host. `id` MUST match the Go module name in go.mod and
# the id passed to window.registerKandevPlugin(...) in ui/bundle.js, and it is
# the prefix of every route kandev serves for this plugin
# (/api/plugins/<id>/...). Keep the three in sync when bumping version.
# (/api/plugins/<id>/...). It also namespaces every plugin_user_state row:
# changing it would strand every saved note. Keep it immutable across releases
# unless a host-backed data migration ships first.
id: "kandev-plugin-notes"
api_version: 1
version: "0.6.0"
Expand All @@ -26,7 +28,9 @@ runtime:
windows-amd64: "server/plugin-windows-amd64.exe"

# capabilities gate what the host will do for this plugin. This plugin needs
# per-user browser-reachable storage (host.storage) for the note itself, plus
# per-user browser-reachable storage (host.storage) for the note itself. This
# capability is part of the persistence contract: removing it makes all saved
# notes inaccessible. The plugin also needs
# agent_invoke so the "Enhance with AI" button can run a one-shot proofreading
# completion via Host.InvokeUtilityAgent (see server/plugin.go and the
# `agent_profile` config property below).
Expand Down
28 changes: 28 additions & 0 deletions scripts/test-host-upgrade-state.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
#!/bin/sh
set -eu

script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
repo_root=$(CDPATH= cd -- "$script_dir/.." && pwd)
kandev_backend=${KANDEV_NOTES_KANDEV_BACKEND:-"$repo_root/../kandev/apps/backend"}
contract_test="$repo_root/integration/host_upgrade_preserves_user_state_test.go"

if [ ! -f "$kandev_backend/internal/plugins/service_install.go" ]; then
echo "Notes host persistence gate requires a Kandev backend checkout at: $kandev_backend" >&2
echo "Set KANDEV_NOTES_KANDEV_BACKEND to apps/backend when using another sibling layout." >&2
exit 1
fi

overlay=$(mktemp "${TMPDIR:-/tmp}/notes-host-overlay.XXXXXX")
trap 'rm -f "$overlay"' EXIT HUP INT TERM

target="$kandev_backend/internal/plugins/notes_persistence_contract_test.go"
printf '{"Replace":{"%s":"%s"}}\n' "$target" "$contract_test" >"$overlay"

(
cd "$kandev_backend"
go test \
-overlay "$overlay" \
./internal/plugins \
-run '^TestNotesPluginVersionReplacementPreservesUserState$' \
-count=1
)
27 changes: 26 additions & 1 deletion server/manifest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ import (
"gopkg.in/yaml.v3"
)

const repoRoot = ".."
const (
repoRoot = ".."
releasedPluginID = "kandev-plugin-notes"
)

type manifestYAML struct {
ID string `yaml:"id"`
Expand Down Expand Up @@ -108,6 +111,28 @@ func TestManifestIdentity_MatchesBundleRegistrationID(t *testing.T) {
require.Equal(t, m.ID, bundleRegistrationID(t))
}

func TestManifestPersistenceContract_PreservesReleasedUserStateNamespace(t *testing.T) {
m := loadManifest(t)
require.Equal(
t,
releasedPluginID,
m.ID,
"the plugin id namespaces every saved note; changing it requires an explicit data migration",
)
require.Equal(
t,
releasedPluginID,
bundleRegistrationID(t),
"the UI registration id must keep addressing the released note namespace",
)
require.Equal(
t,
true,
m.Capabilities["user_state"],
"removing user_state makes existing notes inaccessible",
)
}

func TestManifestVersion_MatchesMakefileVersion(t *testing.T) {
m := loadManifest(t)
require.Equal(t, m.Version, makefileVersion(t))
Expand Down
Loading
Loading