FN-5796: make plugin same-state transitions idempotent

Prevent duplicate-state plugin updates from throwing invalid transition errors.

- treat updatePluginState calls where next state equals current state as idempotent no-ops
- allow same-state updates with an explicit error payload to persist error changes and emit plugin:updated
- add regression tests for same-state no-op behavior, no extra stateChanged event emission, and error payload updates
- add a patch changeset documenting the Dependency Graph plugin fix

Files changed:
 .changeset/fn-5796-plugin-state-noop.md          |  7 +++++
 packages/core/src/__tests__/plugin-store.test.ts | 34 ++++++++++++++++++++++++
 packages/core/src/plugin-store.ts                | 15 +++++++++++
 3 files changed, 56 insertions(+)

Fusion-Task-Id: FN-5796

Fusion-Task-Lineage: 0da1d035-383f-456c-a212-0903dc029874
This commit is contained in:
gsxdsm
2026-05-31 20:50:09 -07:00
parent a8c920daf9
commit 77a109958b
3 changed files with 56 additions and 0 deletions

View File

@@ -663,6 +663,40 @@ describe("PluginStore", () => {
).rejects.toThrow("Invalid state transition");
});
it("treats same-state transitions as no-op", async () => {
const manifest = makeManifest();
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
await store.updatePluginState("test-plugin", "started");
await expect(store.updatePluginState("test-plugin", "started")).resolves.toMatchObject({
id: "test-plugin",
state: "started",
});
});
it("does not emit plugin:stateChanged for same-state transitions", async () => {
const stateChanged = vi.fn();
store.on("plugin:stateChanged", stateChanged);
const manifest = makeManifest();
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
await store.updatePluginState("test-plugin", "started");
expect(stateChanged).toHaveBeenCalledTimes(1);
await store.updatePluginState("test-plugin", "started");
expect(stateChanged).toHaveBeenCalledTimes(1);
});
it("updates error on same-state transition when error payload is provided", async () => {
const manifest = makeManifest();
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
await store.updatePluginState("test-plugin", "started");
const plugin = await store.updatePluginState("test-plugin", "started", "Recovered warning");
expect(plugin.state).toBe("started");
expect(plugin.error).toBe("Recovered warning");
});
it("allows restarting from stopped", async () => {
const manifest = makeManifest();
await store.registerPlugin({ manifest, path: "/path/to/plugin" });

View File

@@ -487,6 +487,21 @@ export class PluginStore extends EventEmitter<PluginStoreEvents> {
throw new Error(`Invalid state: ${state}`);
}
if (state === oldState) {
// Same-state transitions are idempotent by design. Only emit plugin:updated
// when a provided error payload actually changes persisted plugin fields.
if (error === undefined || plugin.error === error) {
return plugin;
}
this.upsertProjectState(id, { state, error });
this.centralDb.bumpLastModified();
const updated = await this.getPlugin(id);
this.emit("plugin:updated", updated);
return updated;
}
if (state !== "error") {
const validTransitions: Record<PluginState, PluginState[]> = {
installed: ["started", "stopped", "error"],