Skip to content

Commit b5d4eba

Browse files
author
Eva
committed
feat(workspaces): persist widget state in SQLite
1 parent 843e3c7 commit b5d4eba

12 files changed

Lines changed: 658 additions & 9 deletions

File tree

docs/web/workspaces.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,12 @@ An agent can author a real HTML widget with `workspace_widget_scaffold` (or you
8383
Sending a prompt into chat from a widget additionally requires a manifest capability, a
8484
per-invocation confirmation quoting the exact text, and passes a rate limit.
8585

86+
Widgets that declare the `state:persist` capability may keep up to 64 KB of JSON state.
87+
The iframe sends `workspace:getState` or `workspace:setState` over its document-bound
88+
bridge; the trusted parent supplies the rendered widget's ID, so child code cannot select
89+
another widget's record. A set may include the version returned by the last read as
90+
`expectedVersion`; stale writes fail instead of silently overwriting newer state.
91+
8692
## CLI
8793

8894
```sh
@@ -97,8 +103,8 @@ the Control UI does not, because the browser already holds it.
97103

98104
## Storage
99105

100-
The workspace document, the custom-widget registry, and a 20-entry undo ring live in
101-
`<stateDir>/workspaces/workspaces.sqlite`. Agent-authored widget assets stay on disk under
106+
The workspace document, the custom-widget registry, widget state, and a 20-entry undo ring
107+
live in `<stateDir>/workspaces/workspaces.sqlite`. Agent-authored widget assets stay on disk under
102108
`<stateDir>/workspaces/widgets/<name>/`, and file-binding data under
103109
`<stateDir>/workspaces/data/`, because an agent authors those with ordinary file tools and
104110
the widget route serves their bytes.

extensions/workspaces/src/gateway.test.ts

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ describe("workspace gateway methods", () => {
6969
"workspaces.widget.setLayout",
7070
"workspaces.widget.scaffold",
7171
"workspaces.widget.approve",
72+
"workspaces.widget.state.get",
73+
"workspaces.widget.state.set",
7274
"workspaces.replace",
7375
"workspaces.undo",
7476
"workspaces.data.read",
@@ -81,7 +83,12 @@ describe("workspace gateway methods", () => {
8183
expect(methods.get("workspaces.widget.approve")?.opts).toEqual({
8284
scope: "operator.approvals",
8385
});
84-
const readOnly = new Set(["workspaces.get", "workspaces.widget.frame", "workspaces.data.read"]);
86+
const readOnly = new Set([
87+
"workspaces.get",
88+
"workspaces.widget.frame",
89+
"workspaces.widget.state.get",
90+
"workspaces.data.read",
91+
]);
8592
for (const [name, method] of methods) {
8693
if (readOnly.has(name) || name === "workspaces.widget.approve") {
8794
continue;
@@ -90,6 +97,66 @@ describe("workspace gateway methods", () => {
9097
}
9198
});
9299

100+
it("persists only the trusted-parent widget id with optimistic concurrency", async () => {
101+
await withTempStateDir(async (stateDir) => {
102+
const { api, methods } = createApi();
103+
registerWorkspaceGatewayMethods({ api, store: new WorkspaceStore({ stateDir }) });
104+
const broadcast = vi.fn();
105+
106+
const empty = await callMethod(
107+
methods.get("workspaces.widget.state.get")!,
108+
{ widgetId: "cost-today" },
109+
broadcast,
110+
);
111+
expect(empty.response?.[1]).toEqual({ state: null, version: 0 });
112+
113+
const first = await callMethod(
114+
methods.get("workspaces.widget.state.set")!,
115+
{ widgetId: "cost-today", state: { text: "a" }, expectedVersion: 0 },
116+
broadcast,
117+
);
118+
expect(first.response?.[0]).toBe(true);
119+
expect(first.response?.[1]).toEqual({ widgetId: "cost-today", version: 1 });
120+
expect(broadcast).toHaveBeenCalledWith("plugin.workspaces.widget-state.changed", {
121+
widgetId: "cost-today",
122+
version: 1,
123+
});
124+
125+
const read = await callMethod(
126+
methods.get("workspaces.widget.state.get")!,
127+
{ widgetId: "cost-today" },
128+
broadcast,
129+
);
130+
expect(read.response?.[1]).toMatchObject({ state: { text: "a" }, version: 1 });
131+
132+
broadcast.mockClear();
133+
const stale = await callMethod(
134+
methods.get("workspaces.widget.state.set")!,
135+
{ widgetId: "cost-today", state: { text: "clobber" }, expectedVersion: 0 },
136+
broadcast,
137+
);
138+
expect(stale.response?.[0]).toBe(false);
139+
expect(stale.response?.[2]?.message).toContain("version conflict");
140+
expect(broadcast).not.toHaveBeenCalled();
141+
142+
const malformed = await callMethod(
143+
methods.get("workspaces.widget.state.set")!,
144+
{ widgetId: "cost-today", state: null, expectedVersion: -1 },
145+
broadcast,
146+
);
147+
expect(malformed.response?.[0]).toBe(false);
148+
expect(malformed.response?.[2]?.message).toContain("non-negative integer");
149+
150+
const smuggled = await callMethod(
151+
methods.get("workspaces.widget.state.set")!,
152+
{ widgetId: "cost-today", state: {}, targetWidgetId: "other" },
153+
broadcast,
154+
);
155+
expect(smuggled.response?.[0]).toBe(false);
156+
expect(smuggled.response?.[2]?.message).toContain("unexpected param");
157+
});
158+
});
159+
93160
it("returns the workspace without broadcasting and broadcasts successful writes", async () => {
94161
await withTempStateDir(async (stateDir) => {
95162
const { api, methods } = createApi();

extensions/workspaces/src/gateway.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -765,6 +765,56 @@ export function registerWorkspaceGatewayMethods(options: WorkspaceGatewayMethodO
765765
{ scope: APPROVE_SCOPE },
766766
);
767767

768+
// The browser parent calls these on behalf of a sandboxed custom widget. The
769+
// parent supplies the widget id from its trusted render context; iframe input
770+
// is never allowed to select another widget's state record.
771+
api.registerGatewayMethod(
772+
"workspaces.widget.state.get",
773+
async ({ params: requestParams, respond }) => {
774+
try {
775+
const params = readParams(requestParams, ["widgetId"]);
776+
const widgetId = readWidgetId(params, "widgetId");
777+
const record = store.readWidgetState(widgetId);
778+
respond(true, record ?? { state: null, version: 0 });
779+
} catch (error) {
780+
respondError(respond, error);
781+
}
782+
},
783+
{ scope: READ_SCOPE },
784+
);
785+
786+
api.registerGatewayMethod(
787+
"workspaces.widget.state.set",
788+
async (opts) => {
789+
try {
790+
const params = readParams(opts.params, ["widgetId", "state", "expectedVersion"]);
791+
const widgetId = readWidgetId(params, "widgetId");
792+
if (!Object.hasOwn(params, "state")) {
793+
throw new Error("state is required");
794+
}
795+
let expectedVersion: number | undefined;
796+
if (params.expectedVersion !== undefined) {
797+
if (
798+
typeof params.expectedVersion !== "number" ||
799+
!Number.isInteger(params.expectedVersion) ||
800+
params.expectedVersion < 0
801+
) {
802+
throw new Error("expectedVersion must be a non-negative integer");
803+
}
804+
expectedVersion = params.expectedVersion;
805+
}
806+
const { version } = store.writeWidgetState(widgetId, params.state as JsonValue, {
807+
expectedVersion,
808+
});
809+
opts.context.broadcast("plugin.workspaces.widget-state.changed", { widgetId, version });
810+
opts.respond(true, { widgetId, version });
811+
} catch (error) {
812+
respondError(opts.respond, error);
813+
}
814+
},
815+
{ scope: WRITE_SCOPE },
816+
);
817+
768818
api.registerGatewayMethod(
769819
"workspaces.replace",
770820
async (opts) => {

extensions/workspaces/src/manifest.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,21 @@ const VALID_MANIFEST = {
3636
};
3737

3838
describe("validateWidgetManifest", () => {
39+
it("accepts the state:persist capability", () => {
40+
expect(
41+
validateWidgetManifest(
42+
{
43+
schemaVersion: 1,
44+
name: "chart",
45+
title: "Chart",
46+
entrypoint: "index.html",
47+
bindings: [],
48+
capabilities: ["state:persist"],
49+
},
50+
"chart",
51+
).capabilities,
52+
).toEqual(["state:persist"]);
53+
});
3954
it("accepts a well-formed manifest", () => {
4055
expect(validateWidgetManifest(VALID_MANIFEST)).toEqual(VALID_MANIFEST);
4156
});

extensions/workspaces/src/manifest.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ export function matchesApprovedFile(
200200
return expected !== undefined && expected === hashBytes(bytes);
201201
}
202202
const BINDING_ID_PATTERN = /^(?!__proto__$)[A-Za-z0-9._-]{1,64}$/;
203-
const WIDGET_CAPABILITIES = ["data:read", "prompt:send"] as const;
203+
const WIDGET_CAPABILITIES = ["data:read", "prompt:send", "state:persist"] as const;
204204

205205
type WidgetCapability = (typeof WIDGET_CAPABILITIES)[number];
206206

extensions/workspaces/src/store.test.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,111 @@ function docWithPendingWidget(store: WorkspaceStore): WorkspaceDoc {
2727
}
2828

2929
describe("WorkspaceStore", () => {
30+
it("persists size-capped widget state in SQLite with monotonic versions", async () => {
31+
await withStore((store) => {
32+
expect(store.readWidgetState("cost-today")).toBeNull();
33+
34+
expect(store.writeWidgetState("cost-today", { text: "hello" })).toMatchObject({ version: 1 });
35+
expect(store.readWidgetState("cost-today")).toMatchObject({
36+
state: { text: "hello" },
37+
version: 1,
38+
updatedAt: expect.any(String),
39+
});
40+
41+
expect(
42+
store.writeWidgetState("cost-today", { text: "updated" }, { expectedVersion: 1 }),
43+
).toMatchObject({ version: 2 });
44+
expect(store.readWidgetState("cost-today")).toMatchObject({
45+
state: { text: "updated" },
46+
version: 2,
47+
});
48+
});
49+
});
50+
51+
it("keeps widget state after the store is reopened", async () => {
52+
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-workspace-reopen-"));
53+
try {
54+
const first = new WorkspaceStore({ stateDir });
55+
first.writeWidgetState("cost-today", { text: "durable" });
56+
first.close();
57+
58+
const reopened = new WorkspaceStore({ stateDir });
59+
try {
60+
expect(reopened.readWidgetState("cost-today")).toMatchObject({
61+
state: { text: "durable" },
62+
version: 1,
63+
});
64+
} finally {
65+
reopened.close();
66+
}
67+
} finally {
68+
await fs.rm(stateDir, { recursive: true, force: true });
69+
}
70+
});
71+
72+
it("rejects stale, invalid, and oversized widget state without changing prior state", async () => {
73+
await withStore((store) => {
74+
store.writeWidgetState("cost-today", { text: "safe" }, { expectedVersion: 0 });
75+
76+
expect(() =>
77+
store.writeWidgetState("cost-today", { text: "stale" }, { expectedVersion: 0 }),
78+
).toThrow("widget state version conflict: expected 0, found 1");
79+
expect(() => store.writeWidgetState("../escape", { nope: true })).toThrow(
80+
"widget id is invalid",
81+
);
82+
expect(() => store.writeWidgetState("cost-today", { text: "x".repeat(70 * 1024) })).toThrow(
83+
"widget state exceeds 64 KB",
84+
);
85+
expect(() => store.writeWidgetState("cost-today", { bad: undefined } as never)).toThrow(
86+
"widget state must be valid JSON",
87+
);
88+
89+
expect(store.readWidgetState("cost-today")).toMatchObject({
90+
state: { text: "safe" },
91+
version: 1,
92+
});
93+
});
94+
});
95+
96+
it("deletes state when a widget is removed or replaced with a different kind", async () => {
97+
await withStore((store) => {
98+
store.writeWidgetState("cost-today", { text: "private" });
99+
store.mutate(
100+
(draft) => {
101+
draft.tabs[0]!.widgets = draft.tabs[0]!.widgets.filter(
102+
(widget) => widget.id !== "cost-today",
103+
);
104+
},
105+
{ actor: "user" },
106+
);
107+
expect(store.readWidgetState("cost-today")).toBeNull();
108+
expect(() => store.writeWidgetState("cost-today", { text: "orphan" })).toThrow(
109+
"workspace widget not found",
110+
);
111+
112+
store.mutate(
113+
(draft) => {
114+
draft.tabs[0]!.widgets.push({
115+
id: "cost-today",
116+
kind: "builtin:markdown",
117+
title: "Replacement",
118+
grid: { x: 0, y: 0, w: 4, h: 2 },
119+
collapsed: false,
120+
hidden: false,
121+
createdBy: "user",
122+
});
123+
},
124+
{ actor: "user" },
125+
);
126+
expect(store.readWidgetState("cost-today")).toBeNull();
127+
expect(
128+
store.writeWidgetState("cost-today", { text: "fresh" }, { expectedVersion: 0 }),
129+
).toEqual({
130+
version: 1,
131+
});
132+
});
133+
});
134+
30135
it("seeds the default workspace on first read", async () => {
31136
await withStore((store) => {
32137
const doc = store.read();

0 commit comments

Comments
 (0)