agentic-canvas 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +95 -12
- package/dist/{chunk-V477KSZQ.js → chunk-HPUYT5TC.js} +183 -8
- package/dist/{chunk-W7NRP3EX.js → chunk-J52RPFRP.js} +39 -8
- package/dist/{chunk-GGQPE65T.js → chunk-JZA525X5.js} +61 -2
- package/dist/{chunk-AGODNBN7.js → chunk-TSWXD4YG.js} +15 -2
- package/dist/cli.js +13 -3
- package/dist/client.d.ts +13 -2
- package/dist/client.js +2 -0
- package/dist/index.d.ts +4 -4
- package/dist/index.js +3 -3
- package/dist/live-worker.d.ts +2 -2
- package/dist/live-worker.js +3 -3
- package/dist/live.d.ts +7 -2
- package/dist/live.js +12 -11
- package/dist/{read-BY6f-mmy.d.ts → read-Bdg1uiFC.d.ts} +10 -1
- package/dist/{service-Cpl6wKeY.d.ts → service-Cim7TWnJ.d.ts} +24 -5
- package/dist/worker.d.ts +5 -3
- package/dist/worker.js +5 -3
- package/llms.txt +3 -2
- package/package.json +17 -6
package/README.md
CHANGED
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
|
|
8
8
|
A shared whiteboard for diagrams, drawn by people and agents.
|
|
9
9
|
|
|
10
|
-
> Status: 0.
|
|
10
|
+
> Status: 0.4.0. The board model, the agent operations, the exports, the CLI, the
|
|
11
11
|
> MCP server, a small Worker API, and live co-editing work. The CLI, the MCP server,
|
|
12
|
-
> and the SDK can join a live room. A remote client calls the Worker API.
|
|
12
|
+
> and the SDK can join a live room. A remote client calls the Worker API. A small web UI, served by the Worker, is new in 0.4.0.
|
|
13
13
|
|
|
14
14
|
## Install
|
|
15
15
|
|
|
@@ -37,7 +37,7 @@ npx agentic-canvas export demo --out demo.svg
|
|
|
37
37
|
npx agentic-canvas export demo --format png --out demo.png
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
-
Other commands: `add`, `connect`, `move`, `rm`. Run `npx agentic-canvas --help`.
|
|
40
|
+
Other commands: `list`, `add`, `connect`, `update`, `move`, `rm`. Run `npx agentic-canvas --help`.
|
|
41
41
|
`repair` is for the owner only. It needs `CANVAS_ACTOR=human:<id>`.
|
|
42
42
|
Boards live in `./.agentic-canvas` (or `--dir`, or `CANVAS_DIR`). Every write
|
|
43
43
|
needs an actor in `CANVAS_ACTOR`, as `<kind>:<id>`. The operator sets it. A write
|
|
@@ -51,7 +51,7 @@ CANVAS_ACTOR=agent:helper npx agentic-canvas --dir ./boards mcp
|
|
|
51
51
|
```
|
|
52
52
|
|
|
53
53
|
The server speaks MCP on stdio. It has one tool for each CLI command: `create_board`,
|
|
54
|
-
`read_board`, `add_shape`, `connect`, `move`, `remove`, `draw_flow`, `export_svg`, and
|
|
54
|
+
`list_boards`, `read_board`, `add_shape`, `connect`, `update_element`, `move`, `remove`, `draw_flow`, `export_svg`, and
|
|
55
55
|
`export_png`. `repair` is not a tool. Only a human operator runs it, from the CLI. The server will not start unless `CANVAS_ACTOR` is set to a valid
|
|
56
56
|
`<kind>:<id>`. That actor stamps every write. No tool takes an actor, author, or kind, so a
|
|
57
57
|
caller cannot choose who it writes as. An error comes back as a tool error with a code.
|
|
@@ -104,13 +104,91 @@ try {
|
|
|
104
104
|
}
|
|
105
105
|
```
|
|
106
106
|
|
|
107
|
-
There is one method per route: `me`, `createBoard`, `readBoard`, `exportSvg`, `addShape`, `addConnector`,
|
|
108
|
-
`drawFlow`, `move`, `remove`, `uploadImage`, `getImage`, `state`, and `checkpoint`. Each takes
|
|
107
|
+
There is one method per route: `me`, `listBoards`, `createBoard`, `readBoard`, `exportSvg`, `addShape`, `addConnector`,
|
|
108
|
+
`drawFlow`, `update`, `move`, `remove`, `uploadImage`, `getImage`, `state`, and `checkpoint`. Each takes
|
|
109
109
|
`{ signal }` last, so an `AbortSignal` can stop a call. The token sets the actor. A failed call throws
|
|
110
110
|
`CanvasApiError` with the API `code` and the HTTP `status`. A call that gets no reply has code `NETWORK`
|
|
111
111
|
and status 0. The client never retries. A 503 means "try again", and the caller decides, because a
|
|
112
112
|
repeated write can change a board twice. Pass `fetch` to use your own.
|
|
113
113
|
|
|
114
|
+
## Live demo
|
|
115
|
+
|
|
116
|
+
A demo Worker runs on workers.dev: <https://agentic-canvas-demo.hello-fd1.workers.dev>. `GET /health` answers 200
|
|
117
|
+
and the web UI is at the root. The API needs a token, so a request without one gets 401. The demo's D1
|
|
118
|
+
database and R2 bucket are in the EU, and its live rooms use the EU jurisdiction. The demo data can be
|
|
119
|
+
reset at any time.
|
|
120
|
+
|
|
121
|
+
To redeploy it, a maintainer with a Cloudflare token runs this from the repo root. The deploy is manual and
|
|
122
|
+
no CI secret is used:
|
|
123
|
+
|
|
124
|
+
```sh
|
|
125
|
+
npm ci && npm run build
|
|
126
|
+
WRANGLER_SEND_METRICS=false CLOUDFLARE_API_TOKEN=... npx wrangler deploy -c wrangler.demo.jsonc
|
|
127
|
+
npx wrangler secret put CANVAS_API_TOKENS -c wrangler.demo.jsonc # only to set or rotate tokens
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
`wrangler.demo.jsonc` holds the config. The tokens are a Worker secret and are not in the repo. To run your
|
|
131
|
+
own copy, create a D1 database and an R2 bucket (both in the EU), put their names and id in a copy of that
|
|
132
|
+
file, and deploy it the same way.
|
|
133
|
+
|
|
134
|
+
## UI
|
|
135
|
+
|
|
136
|
+
The Worker serves a small web UI at its root. It uses the published `agentic-design` package for
|
|
137
|
+
tokens, controls, and overlays, and the typed `agentic-canvas/client` for every call. It is
|
|
138
|
+
bundled at build time, so a build needs `agentic-design` from npm and the browser loads nothing
|
|
139
|
+
from another site.
|
|
140
|
+
|
|
141
|
+
Run it on your machine:
|
|
142
|
+
|
|
143
|
+
```sh
|
|
144
|
+
npm ci
|
|
145
|
+
npm run build # writes the UI to ui-dist/
|
|
146
|
+
cat > .dev.vars <<'EOF2'
|
|
147
|
+
CANVAS_REGION=unknown
|
|
148
|
+
CANVAS_API_TOKENS={"ada-token-0123456789-0123456789-0123456789":{"actor":"ada","kind":"human"}}
|
|
149
|
+
EOF2
|
|
150
|
+
WRANGLER_SEND_METRICS=false npx wrangler dev
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Open the address that wrangler prints and paste the token. A token has 32 characters or more. Its
|
|
154
|
+
grant sets the name, the kind (a person or an agent), and the role. A `viewer` token reads and
|
|
155
|
+
cannot change a board. The token stays in the tab's session storage.
|
|
156
|
+
|
|
157
|
+
What it does:
|
|
158
|
+
|
|
159
|
+
- Lists boards, makes a board, and opens one. Empty, loading, and error states have a retry.
|
|
160
|
+
- Draws shapes, connectors, text, and sticky notes. Select, move, resize, connect, and delete an
|
|
161
|
+
item. Pan by dragging the background, zoom with the wheel or the toolbar.
|
|
162
|
+
- Marks an item an agent wrote with an "Agent" tag and in its accessible name. The side panel shows
|
|
163
|
+
who made the item and who last edited it.
|
|
164
|
+
- Shows who is in the board, from the live room (`agentic-realtime`). Edits go over the HTTP API.
|
|
165
|
+
After a save, the editor raises a counter in its room presence, and the other editors read the
|
|
166
|
+
board again. The page also reads the board again every 5 seconds, so a change made through the API, the CLI, or an agent shows without a reload, a few seconds late.
|
|
167
|
+
- Works with a keyboard. Tab moves between items. Arrow keys move the selected item, Shift with an
|
|
168
|
+
arrow resizes it, Delete removes it, Escape clears the selection. With nothing selected, arrow
|
|
169
|
+
keys pan, `+` and `-` zoom, and `0` resets the view. The side panel also sets text, position, and size,
|
|
170
|
+
and connects shapes, with fields, and the toolbar has pan buttons. A person who cannot drag
|
|
171
|
+
can do everything a drag does.
|
|
172
|
+
|
|
173
|
+
Develop and test the UI:
|
|
174
|
+
|
|
175
|
+
```sh
|
|
176
|
+
npm run build # UI and library
|
|
177
|
+
npx playwright-core install chromium
|
|
178
|
+
npm run test:e2e # Playwright against `wrangler dev`, with axe (WCAG 2.2 AA)
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
The E2E run creates a board, adds a shape and a note, moves them, reloads, and checks each view
|
|
182
|
+
with axe in the light and the dark theme. It saves screenshots to `test-results/screens/`. The committed set in `docs/ui/` shows the UI at this release. Run `UPDATE_SCREENSHOTS=1 npm run test:e2e` to refresh it.
|
|
183
|
+
|
|
184
|
+
A browser cannot set a header on a WebSocket, so the UI sends its token to the live room as a WebSocket sub-protocol (`Sec-WebSocket-Protocol`). A proxy that logs request headers can record it. The API itself takes the token only in an `Authorization` header, and the sub-protocol only on a WebSocket upgrade.
|
|
185
|
+
|
|
186
|
+
The UI saves each edit in order, and shows "Saving…" until the last one is stored. It asks the browser to confirm before you leave a page with unsaved edits. It reads the board again at most once a second.
|
|
187
|
+
|
|
188
|
+
UI limits: the UI draws with the theme colors and ignores the `fill` and `stroke` of an element.
|
|
189
|
+
It shows no image pixels. A text field of a shape is plain text. Two editors who move one item
|
|
190
|
+
at the same time keep the last write. The board list shows the first 100 boards.
|
|
191
|
+
|
|
114
192
|
## Features
|
|
115
193
|
|
|
116
194
|
- Shapes: rectangle, ellipse, diamond, text, sticky note, frame, image.
|
|
@@ -132,23 +210,28 @@ repeated write can change a board twice. Pass `fetch` to use your own.
|
|
|
132
210
|
(EU jurisdiction), through the shared `Store` and `Blobs` ports. Set `CANVAS_API_TOKENS`
|
|
133
211
|
(a JSON map of token to `{ actor, kind }`, tokens of 32 characters or more) and `CANVAS_REGION`.
|
|
134
212
|
Routes: `POST /boards`, `GET /boards/:id`, `GET /boards/:id/svg`, `POST /boards/:id/shapes`,
|
|
135
|
-
`/connectors`, `/flow`, `/move`, `DELETE /boards/:id/elements/:element`, `POST /images`,
|
|
213
|
+
`/connectors`, `/flow`, `/move`, `GET /boards`, `PATCH /boards/:id/elements/:element`, `DELETE /boards/:id/elements/:element`, `POST /images`,
|
|
136
214
|
`GET /images/:id`. An image is stored under the SHA-256 of its bytes, and the reply gives
|
|
137
215
|
the `blob:` ref for a shape. A busy database or bucket answers 503: try again.
|
|
138
216
|
- Live rooms (`agentic-canvas/live-worker`, on `agentic-realtime`). One Durable Object holds one
|
|
139
217
|
board. A token may carry `"role": "viewer"`: a viewer reads and cannot write. `GET /boards/:id/ws`
|
|
140
|
-
opens a room, `GET /me` says who a token is
|
|
141
|
-
|
|
142
|
-
|
|
218
|
+
opens a room, `GET /me` says who a token is. The Worker seeds a room from the stored board when a client
|
|
219
|
+
joins, and saves the room to the stored board before it serves any board read or write. A raw WebSocket
|
|
220
|
+
client needs no seed and no save call. A room that no request reaches stays unsaved in the Store until the
|
|
221
|
+
next request, and it is safe in the room's own storage. `GET /boards/:id/state` and
|
|
222
|
+
`POST /boards/:id/checkpoint` still work for a client that saves by hand. Every save checks author stamps,
|
|
223
|
+
and the Worker sets them from the room's log of who sent each update (the token's actor and kind): a client
|
|
224
|
+
cannot claim another principal, and an agent cannot pass as a person, even by naming one who is in the room.
|
|
225
|
+
A new item that the log lacks (it keeps a capped number of ranges, and a hand save may hold content the room never had) has no sender: the save deletes a new element and undoes a change. A hand save puts the part of its update that the room lacks into the room as the poster, so the room refuses a client id that it holds for someone else and its log names the poster. It drops the part the room holds, saves the room, and fails if the room cannot be saved. It is one message of 1,000,000 bytes at most. A stored board over 1,000,000 bytes cannot seed a room: a live join answers 422 for it.
|
|
143
226
|
- The SDK joins a room: `import { LiveBoard, LiveService } from "agentic-canvas/live"`. The CLI and the MCP
|
|
144
227
|
server join when `CANVAS_SERVER` and `CANVAS_TOKEN` are set. The token sets the actor, so
|
|
145
228
|
`CANVAS_ACTOR` is not needed. `--dir`, `CANVAS_DIR`, and `repair` stay local.
|
|
146
229
|
- Yjs underneath: two copies of a board merge without conflicts.
|
|
147
|
-
-
|
|
230
|
+
- The board model has no UI code. It is plain TypeScript on Yjs, with no outbound calls.
|
|
148
231
|
|
|
149
232
|
## Limits
|
|
150
233
|
|
|
151
|
-
- Canvas takes principal kinds, regions, and the Store and Blobs ports from `agentic-identity` 0.
|
|
234
|
+
- Canvas takes principal kinds, regions, and the Store and Blobs ports from `agentic-identity` 0.5.
|
|
152
235
|
It does not use the sharing API yet. Its editor and viewer tokens are a separate table in
|
|
153
236
|
`CANVAS_API_TOKENS`. It has no board delete or erase. When it adopts sharing, grants are keyed
|
|
154
237
|
on kind and id, and a delete calls `deleteResource`.
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import {
|
|
2
2
|
CanvasService,
|
|
3
3
|
MAX_LIVE_BYTES
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-J52RPFRP.js";
|
|
5
5
|
import {
|
|
6
6
|
CanvasError,
|
|
7
7
|
principalSchema
|
|
8
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-JZA525X5.js";
|
|
9
9
|
|
|
10
10
|
// src/worker.ts
|
|
11
11
|
import { parseRegion } from "agentic-identity";
|
|
@@ -1788,6 +1788,145 @@ var Hono3 = class extends Hono {
|
|
|
1788
1788
|
}
|
|
1789
1789
|
};
|
|
1790
1790
|
|
|
1791
|
+
// src/room-sync.ts
|
|
1792
|
+
import * as Y from "yjs";
|
|
1793
|
+
var SEEDER = { kind: "agent", id: "canvas-room" };
|
|
1794
|
+
var MAX_SEED_BYTES = 1e6;
|
|
1795
|
+
var SYNC = 0;
|
|
1796
|
+
var AUTH = 2;
|
|
1797
|
+
var SYNC_STEP2 = 1;
|
|
1798
|
+
var SYNC_UPDATE = 2;
|
|
1799
|
+
var same = (a, b) => a.kind === b.kind && a.id === b.id;
|
|
1800
|
+
function varUint(value) {
|
|
1801
|
+
const out = [];
|
|
1802
|
+
for (; value > 127; value = Math.floor(value / 128)) out.push(128 | value % 128);
|
|
1803
|
+
return [...out, value];
|
|
1804
|
+
}
|
|
1805
|
+
async function roomState(live, id) {
|
|
1806
|
+
const meta = await live.meta(id);
|
|
1807
|
+
if (meta.updates === 0 && meta.compactions === 0) return null;
|
|
1808
|
+
const reader = (await live.exportHistory(id)).getReader();
|
|
1809
|
+
const decoder = new TextDecoder();
|
|
1810
|
+
let text2 = "";
|
|
1811
|
+
try {
|
|
1812
|
+
for (; ; ) {
|
|
1813
|
+
const { done, value } = await reader.read();
|
|
1814
|
+
if (value) text2 += decoder.decode(value, { stream: true });
|
|
1815
|
+
for (let end = text2.indexOf("\n"); end >= 0; end = text2.indexOf("\n")) {
|
|
1816
|
+
let record;
|
|
1817
|
+
try {
|
|
1818
|
+
record = JSON.parse(text2.slice(0, end));
|
|
1819
|
+
} catch {
|
|
1820
|
+
throw new CanvasError("UNAVAILABLE", "the room sent an export this server cannot read");
|
|
1821
|
+
}
|
|
1822
|
+
text2 = text2.slice(end + 1);
|
|
1823
|
+
if (record.type === "state" && typeof record.base64 === "string") return Uint8Array.from(atob(record.base64), (c) => c.charCodeAt(0));
|
|
1824
|
+
}
|
|
1825
|
+
if (done) throw new CanvasError("UNAVAILABLE", "the room sent no state");
|
|
1826
|
+
}
|
|
1827
|
+
} finally {
|
|
1828
|
+
await reader.cancel().catch(() => void 0);
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
async function send(live, id, update, roomVector, who) {
|
|
1832
|
+
if (update.length > MAX_SEED_BYTES) throw new CanvasError("LIMIT", "the update is too big to put in a room");
|
|
1833
|
+
const upgrade = new Request("https://room.internal/ws", { headers: { upgrade: "websocket" } });
|
|
1834
|
+
const response = await live.connect(id, upgrade, { principal: who.principal, role: "editor", name: who.principal.id });
|
|
1835
|
+
const socket = response.webSocket;
|
|
1836
|
+
if (!socket) throw new CanvasError("UNAVAILABLE", "the room did not open");
|
|
1837
|
+
socket.accept();
|
|
1838
|
+
await new Promise((resolve, reject) => {
|
|
1839
|
+
const timer = setTimeout(() => fail2(new CanvasError("UNAVAILABLE", "the room did not answer")), 1e4);
|
|
1840
|
+
const fail2 = (error) => (clearTimeout(timer), reject(error));
|
|
1841
|
+
socket.addEventListener("message", ({ data }) => {
|
|
1842
|
+
const bytes = new Uint8Array(data);
|
|
1843
|
+
if (bytes[0] === AUTH && bytes[1] === 0) fail2(new CanvasError(who.refusal, "the room refused the update"));
|
|
1844
|
+
else if (bytes[0] === SYNC && bytes[1] === SYNC_STEP2) clearTimeout(timer), resolve();
|
|
1845
|
+
});
|
|
1846
|
+
socket.addEventListener("close", () => fail2(new CanvasError("UNAVAILABLE", "the room closed before it took the update")));
|
|
1847
|
+
socket.send(new Uint8Array([SYNC, SYNC_UPDATE, ...varUint(update.length), ...update]));
|
|
1848
|
+
socket.send(new Uint8Array([SYNC, 0, ...varUint(roomVector.length), ...roomVector]));
|
|
1849
|
+
}).finally(() => socket.close(1e3, "done"));
|
|
1850
|
+
}
|
|
1851
|
+
async function writers(live, id) {
|
|
1852
|
+
const log = await live.attribution(id);
|
|
1853
|
+
if (!log) throw new CanvasError("UNAVAILABLE", "the room has no log of who sent its updates");
|
|
1854
|
+
const ranges = /* @__PURE__ */ new Map();
|
|
1855
|
+
for (const range of log.ranges) ranges.set(range.client, [...ranges.get(range.client) ?? [], range]);
|
|
1856
|
+
const reach = /* @__PURE__ */ new Map();
|
|
1857
|
+
for (const [client, list] of ranges) {
|
|
1858
|
+
list.sort((a, b) => a.clock - b.clock);
|
|
1859
|
+
reach.set(client, list.map((r) => r.clock + r.length));
|
|
1860
|
+
const ends = reach.get(client);
|
|
1861
|
+
for (let i = 1; i < ends.length; i++) ends[i] = Math.max(ends[i], ends[i - 1]);
|
|
1862
|
+
}
|
|
1863
|
+
return ({ client, clock }) => {
|
|
1864
|
+
const list = ranges.get(client) ?? [];
|
|
1865
|
+
let low = 0;
|
|
1866
|
+
let high = list.length;
|
|
1867
|
+
while (low < high) {
|
|
1868
|
+
const mid = low + high >> 1;
|
|
1869
|
+
if (list[mid].clock <= clock) low = mid + 1;
|
|
1870
|
+
else high = mid;
|
|
1871
|
+
}
|
|
1872
|
+
let first;
|
|
1873
|
+
for (let i = low - 1; i >= 0 && reach.get(client)[i] > clock && !first; i--) {
|
|
1874
|
+
const r = list[i];
|
|
1875
|
+
if (clock < r.clock + r.length) first = r;
|
|
1876
|
+
}
|
|
1877
|
+
const who = first ? log.authors[first.author] : void 0;
|
|
1878
|
+
return who && !same(who, SEEDER) ? { id: who.id, kind: who.kind } : void 0;
|
|
1879
|
+
};
|
|
1880
|
+
}
|
|
1881
|
+
async function handSave(live, service, id, update, poster) {
|
|
1882
|
+
const state = await roomState(live, id);
|
|
1883
|
+
const vector = Y.encodeStateVectorFromUpdate(state ?? Y.encodeStateAsUpdate(new Y.Doc()));
|
|
1884
|
+
let missing;
|
|
1885
|
+
try {
|
|
1886
|
+
missing = Y.diffUpdate(update, vector);
|
|
1887
|
+
} catch {
|
|
1888
|
+
throw new CanvasError("INVALID", "the update is not a Yjs update");
|
|
1889
|
+
}
|
|
1890
|
+
const { structs, ds } = Y.decodeUpdate(missing);
|
|
1891
|
+
if (structs.length > 0 || ds.clients.size > 0) await send(live, id, missing, vector, { principal: poster, refusal: "FORBIDDEN" });
|
|
1892
|
+
const saved2 = await pull(live, service, id, true);
|
|
1893
|
+
if (saved2) return saved2;
|
|
1894
|
+
const board = await service.read(id);
|
|
1895
|
+
return { elements: board.nodes.length + board.edges.length + board.groups.length, fixed: [] };
|
|
1896
|
+
}
|
|
1897
|
+
async function roomAuthors(live, id) {
|
|
1898
|
+
return (await live.meta(id)).contributors.filter((c) => !same(c, SEEDER)).map(({ id: who, kind }) => ({ id: who, kind }));
|
|
1899
|
+
}
|
|
1900
|
+
var saved = /* @__PURE__ */ new Map();
|
|
1901
|
+
function remember(id, mark) {
|
|
1902
|
+
if (saved.size > 500) saved.clear();
|
|
1903
|
+
saved.set(id, mark);
|
|
1904
|
+
}
|
|
1905
|
+
async function pull(live, service, id, force = false) {
|
|
1906
|
+
const { updates, compactions } = await live.meta(id);
|
|
1907
|
+
const mark = `${updates}:${compactions}`;
|
|
1908
|
+
if (!force && saved.get(id) === mark) return null;
|
|
1909
|
+
const state = await roomState(live, id);
|
|
1910
|
+
if (!state) return null;
|
|
1911
|
+
let result;
|
|
1912
|
+
try {
|
|
1913
|
+
result = await service.checkpoint(id, state, null, await roomAuthors(live, id), await writers(live, id));
|
|
1914
|
+
} catch (error) {
|
|
1915
|
+
if (error instanceof CanvasError && error.code !== "UNAVAILABLE") remember(id, mark);
|
|
1916
|
+
throw error;
|
|
1917
|
+
}
|
|
1918
|
+
remember(id, mark);
|
|
1919
|
+
return result;
|
|
1920
|
+
}
|
|
1921
|
+
async function seed(live, service, id) {
|
|
1922
|
+
const state = await roomState(live, id);
|
|
1923
|
+
const missing = await service.missingFrom(id, state ?? void 0);
|
|
1924
|
+
if (!missing) return;
|
|
1925
|
+
const known = new Y.Doc();
|
|
1926
|
+
if (state) Y.applyUpdate(known, state);
|
|
1927
|
+
await send(live, id, missing, Y.encodeStateVector(known), { principal: SEEDER, refusal: "UNAVAILABLE" });
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1791
1930
|
// src/worker.ts
|
|
1792
1931
|
var MIN_TOKEN_LENGTH = 32;
|
|
1793
1932
|
var DEFAULT_MAX_IMAGE_BYTES = 1e6;
|
|
@@ -1809,8 +1948,19 @@ function sameText(a, b) {
|
|
|
1809
1948
|
for (let i = 0; i < Math.max(x.length, y.length); i++) diff |= (x[i] ?? 0) ^ (y[i] ?? 0);
|
|
1810
1949
|
return diff === 0;
|
|
1811
1950
|
}
|
|
1951
|
+
var WS_PROTOCOL = "agentic-canvas.bearer";
|
|
1952
|
+
function socketToken(request) {
|
|
1953
|
+
if (request.headers.get("upgrade") !== "websocket") return void 0;
|
|
1954
|
+
const [name, encoded, ...more] = (request.headers.get("sec-websocket-protocol") ?? "").split(",").map((p) => p.trim());
|
|
1955
|
+
if (name !== WS_PROTOCOL || !encoded || more.length > 0 || !/^[A-Za-z0-9_-]+$/.test(encoded)) return void 0;
|
|
1956
|
+
try {
|
|
1957
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(Uint8Array.from(atob(encoded.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0)));
|
|
1958
|
+
} catch {
|
|
1959
|
+
return void 0;
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1812
1962
|
function authenticate(request, env) {
|
|
1813
|
-
const token = /^Bearer (.+)$/.exec(request.headers.get("authorization") ?? "")?.[1];
|
|
1963
|
+
const token = /^Bearer (.+)$/.exec(request.headers.get("authorization") ?? "")?.[1] ?? socketToken(request);
|
|
1814
1964
|
if (!token || !env.CANVAS_API_TOKENS) return null;
|
|
1815
1965
|
let grants;
|
|
1816
1966
|
try {
|
|
@@ -1826,7 +1976,8 @@ function authenticate(request, env) {
|
|
|
1826
1976
|
if (typeof found !== "object" || found === null) return null;
|
|
1827
1977
|
const { actor, kind, role = "editor" } = found;
|
|
1828
1978
|
const parsed = principalSchema.safeParse({ kind, id: actor });
|
|
1829
|
-
|
|
1979
|
+
const taken = parsed.success && parsed.data.kind === SEEDER.kind && parsed.data.id === SEEDER.id;
|
|
1980
|
+
return parsed.success && !taken && (role === "editor" || role === "viewer") ? { actor: parsed.data, role } : null;
|
|
1830
1981
|
}
|
|
1831
1982
|
function sniffImage(bytes) {
|
|
1832
1983
|
const at = (offset, ...sig) => sig.every((b, i) => bytes[offset + i] === b);
|
|
@@ -1916,13 +2067,35 @@ function createApp(options = {}) {
|
|
|
1916
2067
|
await next();
|
|
1917
2068
|
});
|
|
1918
2069
|
api.get("/me", (c) => c.json({ actor: c.var.actor.id, kind: c.var.actor.kind, role: c.var.role }));
|
|
2070
|
+
const saveRoom = async (c, seeding) => {
|
|
2071
|
+
if (!options.live) return;
|
|
2072
|
+
const id = c.req.param("id");
|
|
2073
|
+
await c.var.service.assertExists(id);
|
|
2074
|
+
const live = options.live(c.env);
|
|
2075
|
+
await pull(live, c.var.service, id).catch((error) => console.error(JSON.stringify({ level: "error", op: "save-room", message: loggable(error) })));
|
|
2076
|
+
if (seeding) {
|
|
2077
|
+
await seed(live, c.var.service, id).catch((error) => {
|
|
2078
|
+
console.error(JSON.stringify({ level: "error", op: "seed-room", message: loggable(error) }));
|
|
2079
|
+
throw error instanceof CanvasError ? error : new CanvasError("UNAVAILABLE", "the room could not take the stored board. Try again");
|
|
2080
|
+
});
|
|
2081
|
+
}
|
|
2082
|
+
};
|
|
2083
|
+
const sync = async (c, next) => {
|
|
2084
|
+
if (!/\/(ws|checkpoint)$/.test(new URL(c.req.url).pathname)) await saveRoom(c, false);
|
|
2085
|
+
await next();
|
|
2086
|
+
};
|
|
2087
|
+
api.use("/boards/:id/*", sync);
|
|
1919
2088
|
api.get("/boards/:id/ws", async (c) => {
|
|
1920
2089
|
if (!options.live) return fail("NOT_FOUND", "this server hosts no live rooms", 404);
|
|
1921
2090
|
if (c.req.header("upgrade") !== "websocket") return fail("UPGRADE_REQUIRED", "open this route as a WebSocket", 426);
|
|
1922
2091
|
const id = c.req.param("id");
|
|
1923
|
-
await c
|
|
2092
|
+
await saveRoom(c, true);
|
|
1924
2093
|
const { actor, role } = c.var;
|
|
1925
|
-
|
|
2094
|
+
const opened = await options.live(c.env).connect(id, c.req.raw, { principal: { id: actor.id, kind: actor.kind }, role, name: actor.id });
|
|
2095
|
+
if (opened.status !== 101 || c.req.header("authorization") || !socketToken(c.req.raw)) return opened;
|
|
2096
|
+
const headers = new Headers(opened.headers);
|
|
2097
|
+
headers.set("sec-websocket-protocol", WS_PROTOCOL);
|
|
2098
|
+
return new Response(null, { status: 101, headers, webSocket: opened.webSocket });
|
|
1926
2099
|
});
|
|
1927
2100
|
api.get("/boards/:id/state", async (c) => !options.live ? fail("NOT_FOUND", "this server hosts no live rooms", 404) : c.body(new Uint8Array(await c.var.service.state(c.req.param("id"))), 200, { "content-type": "application/octet-stream" }));
|
|
1928
2101
|
api.post("/boards/:id/checkpoint", async (c) => {
|
|
@@ -1931,9 +2104,9 @@ function createApp(options = {}) {
|
|
|
1931
2104
|
await c.var.service.assertExists(id);
|
|
1932
2105
|
const update = await readLimited(c.req.raw, MAX_LIVE_BYTES);
|
|
1933
2106
|
if (!update) throw new CanvasError("INVALID", `the update must be at most ${MAX_LIVE_BYTES} bytes`);
|
|
1934
|
-
|
|
1935
|
-
return c.json(await c.var.service.checkpoint(id, update, c.var.actor, authors));
|
|
2107
|
+
return c.json(await handSave(options.live(c.env), c.var.service, id, update, c.var.actor));
|
|
1936
2108
|
});
|
|
2109
|
+
api.get("/boards", async (c) => c.json({ boards: await c.var.service.list() }));
|
|
1937
2110
|
api.post("/boards", async (c) => {
|
|
1938
2111
|
const b = await jsonBody(c);
|
|
1939
2112
|
const id = b.id === void 0 ? void 0 : text(b.id, "id");
|
|
@@ -1949,6 +2122,7 @@ function createApp(options = {}) {
|
|
|
1949
2122
|
const b = await jsonBody(c);
|
|
1950
2123
|
return c.json(await c.var.service.move(c.req.param("id"), text(b.element, "element"), num(b.dx, "dx"), num(b.dy, "dy"), c.var.actor));
|
|
1951
2124
|
});
|
|
2125
|
+
api.patch("/boards/:id/elements/:element", async (c) => c.json(await c.var.service.update(c.req.param("id"), c.req.param("element"), await jsonBody(c), c.var.actor)));
|
|
1952
2126
|
api.delete("/boards/:id/elements/:element", async (c) => {
|
|
1953
2127
|
const removed = await c.var.service.remove(c.req.param("id"), c.req.param("element"));
|
|
1954
2128
|
console.log(JSON.stringify({ level: "info", op: "remove", actorKind: c.var.actor.kind }));
|
|
@@ -1998,6 +2172,7 @@ var app = createApp();
|
|
|
1998
2172
|
var worker_default = { fetch: (request, env, ctx) => app.fetch(request, env, ctx) };
|
|
1999
2173
|
|
|
2000
2174
|
export {
|
|
2175
|
+
WS_PROTOCOL,
|
|
2001
2176
|
authenticate,
|
|
2002
2177
|
sniffImage,
|
|
2003
2178
|
createApp,
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
principalSchema,
|
|
6
6
|
readBoard,
|
|
7
7
|
toSvg
|
|
8
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-JZA525X5.js";
|
|
9
9
|
import {
|
|
10
10
|
FileStoreError
|
|
11
11
|
} from "./chunk-NWQED42L.js";
|
|
@@ -21,6 +21,7 @@ function toBase64(bytes) {
|
|
|
21
21
|
for (let i = 0; i < bytes.length; i += 8192) text += String.fromCharCode(...bytes.subarray(i, i + 8192));
|
|
22
22
|
return btoa(text);
|
|
23
23
|
}
|
|
24
|
+
var sameBytes = (a, b) => a.length === b.length && a.every((v, i) => v === b[i]);
|
|
24
25
|
var fromBase64 = (text) => Uint8Array.from(atob(text), (c) => c.charCodeAt(0));
|
|
25
26
|
function boardKey(id) {
|
|
26
27
|
try {
|
|
@@ -51,6 +52,17 @@ var CanvasService = class {
|
|
|
51
52
|
return { id, title: board.title };
|
|
52
53
|
});
|
|
53
54
|
}
|
|
55
|
+
/** The first 100 boards (the store sorts by id), with titles. A board whose stored bytes are damaged shows an empty title: it stays in the list so a person can find and repair it. */
|
|
56
|
+
async list() {
|
|
57
|
+
const page = await this.store.query(COLLECTION, { limit: 100 }).catch(storeFailure);
|
|
58
|
+
return page.items.map(({ id, data }) => {
|
|
59
|
+
try {
|
|
60
|
+
return { id, title: decodeStored(data, id, Board.decode).title };
|
|
61
|
+
} catch {
|
|
62
|
+
return { id, title: "" };
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
}
|
|
54
66
|
/** Throws NOT_FOUND unless the board exists. A live room opens only for a board that exists. */
|
|
55
67
|
async assertExists(id) {
|
|
56
68
|
boardKey(id);
|
|
@@ -76,6 +88,10 @@ var CanvasService = class {
|
|
|
76
88
|
connect(id, connector, actor) {
|
|
77
89
|
return this.change(id, (b) => b.addConnector(connector, actor));
|
|
78
90
|
}
|
|
91
|
+
/** Change fields of one shape or connector: position, size, text, colors. */
|
|
92
|
+
update(id, elementId, patch, actor) {
|
|
93
|
+
return this.change(id, (b) => b.update(elementId, patch, actor));
|
|
94
|
+
}
|
|
79
95
|
move(id, elementId, dx, dy, actor) {
|
|
80
96
|
return this.change(id, (b) => ({ moved: b.move(elementId, dx, dy, actor) }));
|
|
81
97
|
}
|
|
@@ -94,22 +110,37 @@ var CanvasService = class {
|
|
|
94
110
|
boardKey(id);
|
|
95
111
|
return this.store.transaction(async (tx) => (await this.load(tx.get(COLLECTION, id), id)).encode());
|
|
96
112
|
}
|
|
113
|
+
/** What the stored board has that a room with state `room` lacks, as a Yjs update, or null when the room has all of it. */
|
|
114
|
+
async missingFrom(id, room) {
|
|
115
|
+
boardKey(id);
|
|
116
|
+
const known = new Y.Doc();
|
|
117
|
+
if (room) Y.applyUpdate(known, room);
|
|
118
|
+
const diff = await this.store.transaction(async (tx) => Y.encodeStateAsUpdate((await this.load(tx.get(COLLECTION, id), id)).doc, Y.encodeStateVector(known)));
|
|
119
|
+
const before = Y.encodeStateAsUpdate(known);
|
|
120
|
+
Y.applyUpdate(known, diff);
|
|
121
|
+
return Y.decodeUpdate(diff).structs.length === 0 && sameBytes(before, Y.encodeStateAsUpdate(known)) ? null : diff;
|
|
122
|
+
}
|
|
97
123
|
/**
|
|
98
|
-
*
|
|
124
|
+
* `writer` names who sent each Yjs item, from the room's log. It sets the stamps where it knows (see `Board.restamp`).
|
|
125
|
+
* Merge a live room's state into the stored board. `authors` are the principals the room saw send updates. A null
|
|
126
|
+
* `actor` trusts only them: the Worker saves a room that way, with nobody present to vouch.
|
|
99
127
|
* An element stamped by anyone else is deleted, and a changed author is put back (see `Board.restamp`),
|
|
100
128
|
* so a client that writes raw Yjs cannot put another author on its work. Broken entries are repaired and
|
|
101
129
|
* a board past the element cap is refused. Returns the ids the checks fixed.
|
|
102
130
|
*
|
|
103
131
|
* Limits: the save corrects the stored board only. The room keeps what its clients wrote, so a client that sees
|
|
104
132
|
* a non-empty `fixed` must join again, or its later edits are lost at the next save. The room remembers its
|
|
105
|
-
* authors for good, so
|
|
133
|
+
* authors for good, so an OLD item that the log does not name (a seeded one, or a range the log dropped) keeps the older rule: its
|
|
134
|
+
* stamp may name anyone who once sent an update in that room. A NEW item with no writer is deleted or undone.
|
|
135
|
+
* The log records who adds an item, not who deletes a key: a deleted optional key is credited to the writer of the editor
|
|
136
|
+
* stamp written in the same save, or undone if there is none.
|
|
106
137
|
* A client that adds a root type of its own makes every later save of that room fail, because the room keeps it.
|
|
107
138
|
*/
|
|
108
|
-
async checkpoint(id, update, actor, authors = []) {
|
|
139
|
+
async checkpoint(id, update, actor, authors = [], writer) {
|
|
109
140
|
boardKey(id);
|
|
110
|
-
const by = principalSchema.safeParse(actor);
|
|
111
|
-
if (!by.success) throw new CanvasError("INVALID", "the actor must be a valid principal");
|
|
112
|
-
const trusted = (who) => [by.data, ...authors].some((a) => a.kind === who.kind && a.id === who.id);
|
|
141
|
+
const by = actor === null ? null : principalSchema.safeParse(actor);
|
|
142
|
+
if (by && !by.success) throw new CanvasError("INVALID", "the actor must be a valid principal");
|
|
143
|
+
const trusted = (who) => [...by?.success ? [by.data] : [], ...authors].some((a) => a.kind === who.kind && a.id === who.id);
|
|
113
144
|
return this.store.transaction(async (tx) => {
|
|
114
145
|
const prior = await this.load(tx.get(COLLECTION, id), id);
|
|
115
146
|
const next = prior.clone();
|
|
@@ -121,7 +152,7 @@ var CanvasService = class {
|
|
|
121
152
|
if (next.doc.store.pendingStructs || next.doc.store.pendingDs) throw new CanvasError("INVALID", "the update is not complete: it needs changes that the board does not have");
|
|
122
153
|
const strange = [...next.doc.share.keys()].filter((key) => key !== "elements" && key !== "meta");
|
|
123
154
|
if (strange.length > 0) throw new CanvasError("INVALID", "the update holds data that is not part of a board");
|
|
124
|
-
const fixed = /* @__PURE__ */ new Set([...next.restamp(prior, trusted), ...next.repair()]);
|
|
155
|
+
const fixed = /* @__PURE__ */ new Set([...next.restamp(prior, trusted, writer), ...next.repair()]);
|
|
125
156
|
const saved = next.encode();
|
|
126
157
|
if (saved.length > MAX_LIVE_BYTES) throw new CanvasError("LIMIT", `the board would hold over ${MAX_LIVE_BYTES} bytes`);
|
|
127
158
|
await tx.put(COLLECTION, id, { update: toBase64(saved) });
|
|
@@ -184,12 +184,58 @@ var Board = class _Board {
|
|
|
184
184
|
* author, and a changed one needs a trusted editor or it goes back to its prior state. An editor stamp changed alone
|
|
185
185
|
* to an untrusted principal is put back. Unknown keys, nested Yjs types, and bad or foreign title values are deleted
|
|
186
186
|
* or undone. Returns the ids it deleted or restored.
|
|
187
|
+
*
|
|
188
|
+
* `writer` says who sent the update that holds a Yjs item, or undefined when it does not know. Where it knows, it
|
|
189
|
+
* decides: a new element gets its creator as author and editor, and an old one gets the sender of its changes as editor.
|
|
190
|
+
* A client cannot claim another principal, and an agent cannot pass as a person. A new item that the log lacks has no
|
|
191
|
+
* sender: it is deleted or undone. Only an old item falls back to `trusted`. A hand save names its poster as the sender
|
|
192
|
+
* of every item that the room does not hold, in `writer`.
|
|
187
193
|
*/
|
|
188
|
-
restamp(prior, trusted) {
|
|
194
|
+
restamp(prior, trusted, writer) {
|
|
189
195
|
const before = new Map(prior.allStored().map((e) => [e.id, e]));
|
|
190
196
|
const body = ({ author: _a, editedBy: _e, ...rest }) => JSON.stringify(rest);
|
|
191
197
|
const fixed = [];
|
|
198
|
+
const isNew = (item) => item.clock >= Y.getState(prior.doc.store, item.client);
|
|
199
|
+
const who = (item) => writer?.(item);
|
|
200
|
+
const pick = (writers, stamped) => writers.find((w) => isMachineMade(w.kind)) ?? (stamped && (writers.length === 0 || writers.some((w) => samePrincipal(w, stamped))) ? stamped : writers[0]);
|
|
192
201
|
this.doc.transact(() => {
|
|
202
|
+
for (const el of writer ? this.allStored() : []) {
|
|
203
|
+
const entry = this.elements.get(el.id);
|
|
204
|
+
const was = prior.elements.get(el.id);
|
|
205
|
+
const by2 = (key) => {
|
|
206
|
+
const item = entry._map.get(key);
|
|
207
|
+
return item && !item.deleted ? who(item.id) : void 0;
|
|
208
|
+
};
|
|
209
|
+
if (!was) {
|
|
210
|
+
const creator = entry._item ? who(entry._item.id) : void 0;
|
|
211
|
+
const writers2 = [...entry.keys()].filter((k) => k !== "author" && k !== "editedBy").map(by2);
|
|
212
|
+
if (!creator || writers2.some((w) => !w)) this.elements.delete(el.id);
|
|
213
|
+
else {
|
|
214
|
+
const editor2 = pick(writers2, by2("editedBy")) ?? creator;
|
|
215
|
+
if (samePrincipal(el.author, creator) && samePrincipal(el.editedBy, editor2)) continue;
|
|
216
|
+
entry.set("author", creator);
|
|
217
|
+
entry.set("editedBy", editor2);
|
|
218
|
+
}
|
|
219
|
+
fixed.push(el.id);
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
const raw = (map, key) => JSON.stringify(map.get(key));
|
|
223
|
+
const changed = [.../* @__PURE__ */ new Set([...entry.keys(), ...was.keys()])].filter((k) => k !== "author" && k !== "editedBy" && raw(entry, k) !== raw(was, k));
|
|
224
|
+
const found = entry._map.get("editedBy");
|
|
225
|
+
const stamp = found?.deleted ? void 0 : found;
|
|
226
|
+
const stamped = stamp && isNew(stamp.id) ? who(stamp.id) : void 0;
|
|
227
|
+
const writers = changed.map((k) => entry.has(k) ? by2(k) : stamped);
|
|
228
|
+
if (writers.some((w) => !w) || stamp && isNew(stamp.id) && !stamped) {
|
|
229
|
+
this.elements.set(el.id, new Y.Map([...was.entries()]));
|
|
230
|
+
fixed.push(el.id);
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
const editor = pick(writers, stamped);
|
|
234
|
+
if (editor && !samePrincipal(el.editedBy, editor)) {
|
|
235
|
+
entry.set("editedBy", editor);
|
|
236
|
+
fixed.push(el.id);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
193
239
|
for (const el of this.allStored()) {
|
|
194
240
|
const old = before.get(el.id);
|
|
195
241
|
if (!old) {
|
|
@@ -216,9 +262,22 @@ var Board = class _Board {
|
|
|
216
262
|
if (extra.length > 0 && !fixed.includes(el.id)) fixed.push(el.id);
|
|
217
263
|
}
|
|
218
264
|
const same = (key) => !(this.meta.get(key) instanceof Y.AbstractType) && JSON.stringify(this.meta.get(key)) === JSON.stringify(prior.meta.get(key));
|
|
265
|
+
let titleBroken = false;
|
|
266
|
+
if (writer) {
|
|
267
|
+
const found = this.meta._map.get("title");
|
|
268
|
+
const titleItem = found?.deleted ? void 0 : found;
|
|
269
|
+
const byItem = this.meta._map.get("titleBy");
|
|
270
|
+
const written = [titleItem, byItem?.deleted ? void 0 : byItem].filter((item) => item !== void 0 && isNew(item.id)).map((item) => who(item.id));
|
|
271
|
+
titleBroken = !titleItem && prior.meta.has("title") || written.some((w) => !w);
|
|
272
|
+
const titleWriter = titleBroken ? void 0 : pick(written);
|
|
273
|
+
if (titleWriter && this.titleBy && !samePrincipal(this.titleBy, titleWriter)) {
|
|
274
|
+
this.meta.set("titleBy", titleWriter);
|
|
275
|
+
fixed.push("title");
|
|
276
|
+
}
|
|
277
|
+
}
|
|
219
278
|
const by = this.titleBy;
|
|
220
279
|
const title = this.meta.get("title");
|
|
221
|
-
const honest = by !== void 0 && trusted(by) && (title === void 0 || titleSchema.safeParse(title).success);
|
|
280
|
+
const honest = !titleBroken && by !== void 0 && trusted(by) && (title === void 0 || titleSchema.safeParse(title).success);
|
|
222
281
|
if (!honest && !(same("title") && same("titleBy"))) {
|
|
223
282
|
for (const key of ["title", "titleBy"]) {
|
|
224
283
|
const old = prior.meta.get(key);
|
|
@@ -3,11 +3,11 @@ import {
|
|
|
3
3
|
PRINCIPAL_KINDS,
|
|
4
4
|
SHAPE_TYPES,
|
|
5
5
|
principalSchema
|
|
6
|
-
} from "./chunk-
|
|
6
|
+
} from "./chunk-JZA525X5.js";
|
|
7
7
|
|
|
8
8
|
// src/version.ts
|
|
9
9
|
import { createRequire } from "module";
|
|
10
|
-
var VERSION = true ? "0.
|
|
10
|
+
var VERSION = true ? "0.4.0" : readPackageVersion();
|
|
11
11
|
|
|
12
12
|
// src/brand.ts
|
|
13
13
|
var BRAND = {
|
|
@@ -91,6 +91,11 @@ function createMcpServer(service, actor) {
|
|
|
91
91
|
{ description: "Make a board.", inputSchema: { id: z.string().optional(), title: z.string().optional() } },
|
|
92
92
|
tool(async (a) => json(await service.create(actor, a)))
|
|
93
93
|
);
|
|
94
|
+
server.registerTool(
|
|
95
|
+
"list_boards",
|
|
96
|
+
{ description: "List the first 100 boards with their titles.", inputSchema: {}, annotations: read },
|
|
97
|
+
tool(async () => json({ boards: await service.list() }))
|
|
98
|
+
);
|
|
94
99
|
server.registerTool(
|
|
95
100
|
"read_board",
|
|
96
101
|
{ description: "Read a board as data: nodes, edges, groups, text, and who made what.", inputSchema: { board }, annotations: read },
|
|
@@ -121,6 +126,14 @@ function createMcpServer(service, actor) {
|
|
|
121
126
|
{ description: "Join two shapes with an arrow.", inputSchema: { board, from: z.string(), to: z.string(), id: z.string().optional(), text: z.string().optional() } },
|
|
122
127
|
tool(async (a) => json(await service.connect(a.board, { id: a.id, from: { id: a.from }, to: { id: a.to }, text: a.text }, actor)))
|
|
123
128
|
);
|
|
129
|
+
server.registerTool(
|
|
130
|
+
"update_element",
|
|
131
|
+
{
|
|
132
|
+
description: "Change fields of a shape (x, y, w, h, text, fill, stroke) or a connector. Resize with w and h.",
|
|
133
|
+
inputSchema: { board, element: z.string(), x: num.optional(), y: num.optional(), w: num.optional(), h: num.optional(), text: z.string().optional(), fill: z.string().optional(), stroke: z.string().optional() }
|
|
134
|
+
},
|
|
135
|
+
tool(async ({ board: b, element, ...patch }) => json(await service.update(b, element, patch, actor)))
|
|
136
|
+
);
|
|
124
137
|
server.registerTool(
|
|
125
138
|
"move",
|
|
126
139
|
{ description: "Move an element, or a whole group.", inputSchema: { board, element: z.string(), dx: num, dy: num } },
|
package/dist/cli.js
CHANGED
|
@@ -5,16 +5,16 @@ import {
|
|
|
5
5
|
operatorActor,
|
|
6
6
|
runMcp,
|
|
7
7
|
svgToPng
|
|
8
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-TSWXD4YG.js";
|
|
9
9
|
import {
|
|
10
10
|
openLocalStore
|
|
11
11
|
} from "./chunk-UKST5A57.js";
|
|
12
12
|
import {
|
|
13
13
|
CanvasService
|
|
14
|
-
} from "./chunk-
|
|
14
|
+
} from "./chunk-J52RPFRP.js";
|
|
15
15
|
import {
|
|
16
16
|
CanvasError
|
|
17
|
-
} from "./chunk-
|
|
17
|
+
} from "./chunk-JZA525X5.js";
|
|
18
18
|
import "./chunk-NWQED42L.js";
|
|
19
19
|
|
|
20
20
|
// src/cli.ts
|
|
@@ -29,9 +29,11 @@ Usage: ${BRAND.command} [--dir <path>] <command> [flags]
|
|
|
29
29
|
|
|
30
30
|
Commands:
|
|
31
31
|
new [--id <id>] [--title <t>] Make a board.
|
|
32
|
+
list List the boards with their titles.
|
|
32
33
|
read <board> Print the board as JSON.
|
|
33
34
|
add <board> --type <t> --x <n> --y <n> Add a shape. Also: --w --h --text --fill --stroke --src --id.
|
|
34
35
|
connect <board> <from> <to> [--text <t>] Join two shapes with an arrow.
|
|
36
|
+
update <board> <element> [--x --y --w --h --text --fill --stroke] Change fields of an element. Resize with --w and --h.
|
|
35
37
|
move <board> <element> --dx <n> --dy <n> Move an element or a group.
|
|
36
38
|
rm <board> <element> Delete an element.
|
|
37
39
|
flow <board> --text <t> | --file <p> Draw a flowchart from text ("A -> B: label"). Use --file - for stdin.
|
|
@@ -118,6 +120,9 @@ async function run(argv) {
|
|
|
118
120
|
case "new":
|
|
119
121
|
need(0, "[--id <id>] [--title <t>]");
|
|
120
122
|
return print(await service.create(actor(), { id: v.id, title: v.title }));
|
|
123
|
+
case "list":
|
|
124
|
+
need(0, "");
|
|
125
|
+
return print({ boards: await service.list() });
|
|
121
126
|
case "read":
|
|
122
127
|
need(1, "<board>");
|
|
123
128
|
return print(await service.read(board));
|
|
@@ -127,6 +132,11 @@ async function run(argv) {
|
|
|
127
132
|
case "connect":
|
|
128
133
|
need(3, "<board> <from> <to> [--text <t>]");
|
|
129
134
|
return print(await service.connect(board, { id: v.id, from: { id: rest[0] }, to: { id: rest[1] }, text: v.text }, actor()));
|
|
135
|
+
case "update": {
|
|
136
|
+
need(2, "<board> <element> [--x <n>] [--y <n>] [--w <n>] [--h <n>] [--text <t>] [--fill <c>] [--stroke <c>]");
|
|
137
|
+
const patch = Object.fromEntries(Object.entries({ x: num(v.x), y: num(v.y), w: num(v.w), h: num(v.h), text: v.text, fill: v.fill, stroke: v.stroke }).filter(([, value]) => value !== void 0));
|
|
138
|
+
return print(await service.update(board, rest[0], patch, actor()));
|
|
139
|
+
}
|
|
130
140
|
case "move":
|
|
131
141
|
need(2, "<board> <element> --dx <n> --dy <n>");
|
|
132
142
|
return print(await service.move(board, rest[0], num(v.dx) ?? Number.NaN, num(v.dy) ?? Number.NaN, actor()));
|
package/dist/client.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { E as ErrorCode, B as BoardData, S as ShapeType, a as Shape, b as Endpoint, C as Connector } from './read-
|
|
1
|
+
import { E as ErrorCode, B as BoardData, S as ShapeType, a as Shape, b as Endpoint, C as Connector } from './read-Bdg1uiFC.js';
|
|
2
2
|
import { PrincipalKind } from 'agentic-identity';
|
|
3
3
|
import 'yjs';
|
|
4
4
|
import 'zod';
|
|
@@ -37,6 +37,8 @@ interface ConnectorInput {
|
|
|
37
37
|
endArrow?: boolean;
|
|
38
38
|
stroke?: string;
|
|
39
39
|
}
|
|
40
|
+
type ShapePatch = Partial<Pick<ShapeInput, "x" | "y" | "w" | "h" | "text" | "fill" | "stroke" | "src">>;
|
|
41
|
+
type ConnectorPatch = Partial<Pick<ConnectorInput, "from" | "to" | "text" | "startArrow" | "endArrow" | "stroke">>;
|
|
40
42
|
interface ImageInfo {
|
|
41
43
|
id: string;
|
|
42
44
|
ref: string;
|
|
@@ -62,6 +64,13 @@ interface CanvasClient {
|
|
|
62
64
|
kind: PrincipalKind;
|
|
63
65
|
role: "editor" | "viewer";
|
|
64
66
|
}>;
|
|
67
|
+
/** The first 100 boards, with titles. */
|
|
68
|
+
listBoards(options?: CallOptions): Promise<{
|
|
69
|
+
boards: {
|
|
70
|
+
id: string;
|
|
71
|
+
title: string;
|
|
72
|
+
}[];
|
|
73
|
+
}>;
|
|
65
74
|
createBoard(input?: {
|
|
66
75
|
id?: string;
|
|
67
76
|
title?: string;
|
|
@@ -81,6 +90,8 @@ interface CanvasClient {
|
|
|
81
90
|
nodes: string[];
|
|
82
91
|
edges: string[];
|
|
83
92
|
}>;
|
|
93
|
+
/** Change fields of one shape or connector, such as its size or text. The patch holds at least one field. */
|
|
94
|
+
update(id: string, element: string, patch: ShapePatch | ConnectorPatch, options?: CallOptions): Promise<Shape | Connector>;
|
|
84
95
|
/** Move an element. Returns the ids that moved: a shape takes its connectors with it. */
|
|
85
96
|
move(id: string, element: string, dx: number, dy: number, options?: CallOptions): Promise<{
|
|
86
97
|
moved: string[];
|
|
@@ -105,4 +116,4 @@ interface CanvasClient {
|
|
|
105
116
|
}
|
|
106
117
|
declare function createClient(options: ClientOptions): CanvasClient;
|
|
107
118
|
|
|
108
|
-
export { type ApiErrorCode, type CallOptions, CanvasApiError, type CanvasClient, type ClientOptions, type ConnectorInput, type ImageInfo, type ShapeInput, createClient };
|
|
119
|
+
export { type ApiErrorCode, type CallOptions, CanvasApiError, type CanvasClient, type ClientOptions, type ConnectorInput, type ConnectorPatch, type ImageInfo, type ShapeInput, type ShapePatch, createClient };
|
package/dist/client.js
CHANGED
|
@@ -55,12 +55,14 @@ function createClient(options) {
|
|
|
55
55
|
const bytes = async (response) => new Uint8Array(await response.arrayBuffer());
|
|
56
56
|
return {
|
|
57
57
|
me: (o) => json("GET", "/me", void 0, o),
|
|
58
|
+
listBoards: (o) => json("GET", "/boards", void 0, o),
|
|
58
59
|
createBoard: (input = {}, o) => json("POST", "/boards", input, o),
|
|
59
60
|
readBoard: (id, o) => json("GET", `/boards/${seg(id)}`, void 0, o),
|
|
60
61
|
exportSvg: async (id, o) => (await send("GET", `/boards/${seg(id)}/svg`, {}, o)).text(),
|
|
61
62
|
addShape: (id, shape, o) => json("POST", `/boards/${seg(id)}/shapes`, shape, o),
|
|
62
63
|
addConnector: (id, connector, o) => json("POST", `/boards/${seg(id)}/connectors`, connector, o),
|
|
63
64
|
drawFlow: (id, text, o) => json("POST", `/boards/${seg(id)}/flow`, { text }, o),
|
|
65
|
+
update: (id, element, patch, o) => json("PATCH", `/boards/${seg(id)}/elements/${seg(element)}`, patch, o),
|
|
64
66
|
move: (id, element, dx, dy, o) => json("POST", `/boards/${seg(id)}/move`, { element, dx, dy }, o),
|
|
65
67
|
remove: (id, element, o) => json("DELETE", `/boards/${seg(id)}/elements/${seg(element)}`, void 0, o),
|
|
66
68
|
uploadImage: async (data, o) => parse(await send("POST", "/images", { body: data, contentType: "application/octet-stream" }, o), o),
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { c as Board, P as Principal } from './read-
|
|
2
|
-
export { B as BoardData, d as CanvasElement, e as CanvasError, C as Connector, f as EdgeData, b as Endpoint, E as ErrorCode, G as Group, M as MAX_ELEMENTS, g as MAX_TEXT, N as NodeData, h as SHAPE_TYPES, a as Shape, S as ShapeType, p as principalSchema, r as readBoard } from './read-
|
|
3
|
-
import { B as BoardAccess } from './service-
|
|
4
|
-
export { C as CanvasService, D as DrawOptions, F as FlowEdge, a as FlowGraph, b as FlowNode, M as MAX_FLOW_EDGES, c as MAX_FLOW_NODES, P as Placement, d as drawFlow, l as layoutFlow, p as parseFlow } from './service-
|
|
1
|
+
import { c as Board, P as Principal } from './read-Bdg1uiFC.js';
|
|
2
|
+
export { B as BoardData, d as CanvasElement, e as CanvasError, C as Connector, f as EdgeData, b as Endpoint, E as ErrorCode, G as Group, M as MAX_ELEMENTS, g as MAX_TEXT, N as NodeData, h as SHAPE_TYPES, a as Shape, S as ShapeType, p as principalSchema, r as readBoard } from './read-Bdg1uiFC.js';
|
|
3
|
+
import { B as BoardAccess } from './service-Cim7TWnJ.js';
|
|
4
|
+
export { C as CanvasService, D as DrawOptions, F as FlowEdge, a as FlowGraph, b as FlowNode, M as MAX_FLOW_EDGES, c as MAX_FLOW_NODES, P as Placement, d as drawFlow, l as layoutFlow, p as parseFlow } from './service-Cim7TWnJ.js';
|
|
5
5
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
6
6
|
export { FileStoreOptions, createFileStore, openLocalStore } from './store-file.js';
|
|
7
7
|
export { PRINCIPAL_KINDS, PrincipalKind, isMachineMade } from 'agentic-identity';
|
package/dist/index.js
CHANGED
|
@@ -6,14 +6,14 @@ import {
|
|
|
6
6
|
operatorActor,
|
|
7
7
|
runMcp,
|
|
8
8
|
svgToPng
|
|
9
|
-
} from "./chunk-
|
|
9
|
+
} from "./chunk-TSWXD4YG.js";
|
|
10
10
|
import {
|
|
11
11
|
createFileStore,
|
|
12
12
|
openLocalStore
|
|
13
13
|
} from "./chunk-UKST5A57.js";
|
|
14
14
|
import {
|
|
15
15
|
CanvasService
|
|
16
|
-
} from "./chunk-
|
|
16
|
+
} from "./chunk-J52RPFRP.js";
|
|
17
17
|
import {
|
|
18
18
|
Board,
|
|
19
19
|
CanvasError,
|
|
@@ -30,7 +30,7 @@ import {
|
|
|
30
30
|
principalSchema,
|
|
31
31
|
readBoard,
|
|
32
32
|
toSvg
|
|
33
|
-
} from "./chunk-
|
|
33
|
+
} from "./chunk-JZA525X5.js";
|
|
34
34
|
import "./chunk-NWQED42L.js";
|
|
35
35
|
export {
|
|
36
36
|
ActorConfigError,
|
package/dist/live-worker.d.ts
CHANGED
|
@@ -5,10 +5,10 @@ import 'agentic-identity';
|
|
|
5
5
|
import 'agentic-realtime';
|
|
6
6
|
import 'agentic-identity/cloudflare';
|
|
7
7
|
import 'hono';
|
|
8
|
-
import './read-
|
|
8
|
+
import './read-Bdg1uiFC.js';
|
|
9
9
|
import 'yjs';
|
|
10
10
|
import 'zod';
|
|
11
|
-
import './service-
|
|
11
|
+
import './service-Cim7TWnJ.js';
|
|
12
12
|
|
|
13
13
|
type Namespace = Parameters<typeof createDurableObjectRealtime>[0];
|
|
14
14
|
interface LiveEnv extends WorkerEnv {
|
package/dist/live-worker.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import {
|
|
2
2
|
createApp
|
|
3
|
-
} from "./chunk-
|
|
4
|
-
import "./chunk-
|
|
3
|
+
} from "./chunk-HPUYT5TC.js";
|
|
4
|
+
import "./chunk-J52RPFRP.js";
|
|
5
5
|
import {
|
|
6
6
|
CanvasError
|
|
7
|
-
} from "./chunk-
|
|
7
|
+
} from "./chunk-JZA525X5.js";
|
|
8
8
|
import "./chunk-NWQED42L.js";
|
|
9
9
|
|
|
10
10
|
// src/live-worker.ts
|
package/dist/live.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { c as Board, P as Principal, B as BoardData, a as Shape, C as Connector } from './read-
|
|
1
|
+
import { c as Board, P as Principal, B as BoardData, a as Shape, C as Connector, d as CanvasElement } from './read-Bdg1uiFC.js';
|
|
2
2
|
import { Peer } from 'agentic-realtime/client';
|
|
3
|
-
import { B as BoardAccess, D as DrawOptions } from './service-
|
|
3
|
+
import { B as BoardAccess, D as DrawOptions } from './service-Cim7TWnJ.js';
|
|
4
4
|
import 'yjs';
|
|
5
5
|
import 'zod';
|
|
6
6
|
import 'agentic-identity';
|
|
@@ -79,12 +79,17 @@ declare class LiveService implements BoardAccess {
|
|
|
79
79
|
id: string;
|
|
80
80
|
title: string;
|
|
81
81
|
}>;
|
|
82
|
+
list(): Promise<{
|
|
83
|
+
id: string;
|
|
84
|
+
title: string;
|
|
85
|
+
}[]>;
|
|
82
86
|
read(id: string): Promise<BoardData & {
|
|
83
87
|
id: string;
|
|
84
88
|
}>;
|
|
85
89
|
exportSvg(id: string): Promise<string>;
|
|
86
90
|
add(id: string, shape: unknown, _actor?: Principal): Promise<Shape>;
|
|
87
91
|
connect(id: string, connector: unknown, _actor?: Principal): Promise<Connector>;
|
|
92
|
+
update(id: string, element: string, patch: unknown, _actor?: Principal): Promise<CanvasElement>;
|
|
88
93
|
move(id: string, element: string, dx: number, dy: number, _actor?: Principal): Promise<{
|
|
89
94
|
moved: string[];
|
|
90
95
|
}>;
|
package/dist/live.js
CHANGED
|
@@ -5,11 +5,10 @@ import {
|
|
|
5
5
|
principalSchema,
|
|
6
6
|
readBoard,
|
|
7
7
|
toSvg
|
|
8
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-JZA525X5.js";
|
|
9
9
|
|
|
10
10
|
// src/live.ts
|
|
11
11
|
import { SyncClient } from "agentic-realtime/client";
|
|
12
|
-
import * as Y from "yjs";
|
|
13
12
|
var CODES = ["INVALID", "DUPLICATE_ID", "NOT_FOUND", "BAD_REFERENCE", "LIMIT", "UNAVAILABLE", "FORBIDDEN"];
|
|
14
13
|
var base = (server) => server.replace(/\/$/, "") + "/api/v1/canvas";
|
|
15
14
|
async function refusal(response, fallback) {
|
|
@@ -61,7 +60,7 @@ var LiveBoard = class _LiveBoard {
|
|
|
61
60
|
const id = encodeURIComponent(options.board);
|
|
62
61
|
const stored = await call(options.server, options.token, `/boards/${id}/state`);
|
|
63
62
|
if (!stored.ok) throw await refusal(stored, "could not read the stored board");
|
|
64
|
-
|
|
63
|
+
await stored.body?.cancel();
|
|
65
64
|
const url = new URL(`${base(options.server)}/boards/${id}/ws`);
|
|
66
65
|
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
67
66
|
let client;
|
|
@@ -70,14 +69,6 @@ var LiveBoard = class _LiveBoard {
|
|
|
70
69
|
} catch (error) {
|
|
71
70
|
throw unreachable(error);
|
|
72
71
|
}
|
|
73
|
-
try {
|
|
74
|
-
Y.applyUpdate(client.doc, state);
|
|
75
|
-
await client.flush();
|
|
76
|
-
if (me.role === "editor" && client.denied.length > 0) throw new CanvasError("FORBIDDEN", `the room refused the stored board: ${client.denied[0]}`);
|
|
77
|
-
} catch (error) {
|
|
78
|
-
await client.disconnect();
|
|
79
|
-
throw unreachable(error);
|
|
80
|
-
}
|
|
81
72
|
return new _LiveBoard(new Board(client.doc), me, client, options.board, options, client.denied.length);
|
|
82
73
|
}
|
|
83
74
|
/** The other people and agents in the room. `machine` is true for an agent. */
|
|
@@ -142,6 +133,13 @@ var LiveService = class {
|
|
|
142
133
|
if (typeof id !== "string" || typeof title !== "string") throw new CanvasError("INVALID", "the server sent a board this client does not know");
|
|
143
134
|
return { id, title };
|
|
144
135
|
}
|
|
136
|
+
async list() {
|
|
137
|
+
const response = await call(this.options.server, this.options.token, "/boards");
|
|
138
|
+
if (!response.ok) throw await refusal(response, "the server refused the request");
|
|
139
|
+
const { boards } = await json(response);
|
|
140
|
+
if (!Array.isArray(boards) || !boards.every((b) => typeof b?.id === "string" && typeof b?.title === "string")) throw new CanvasError("INVALID", "the server sent a board list this client does not know");
|
|
141
|
+
return boards;
|
|
142
|
+
}
|
|
145
143
|
async read(id) {
|
|
146
144
|
return this.use(id, async (live) => ({ id, ...readBoard(live.board) }));
|
|
147
145
|
}
|
|
@@ -154,6 +152,9 @@ var LiveService = class {
|
|
|
154
152
|
connect(id, connector, _actor) {
|
|
155
153
|
return this.write(id, (board, actor) => board.addConnector(connector, actor));
|
|
156
154
|
}
|
|
155
|
+
update(id, element, patch, _actor) {
|
|
156
|
+
return this.write(id, (board, actor) => board.update(element, patch, actor));
|
|
157
|
+
}
|
|
157
158
|
move(id, element, dx, dy, _actor) {
|
|
158
159
|
return this.write(id, (board, actor) => ({ moved: board.move(element, dx, dy, actor) }));
|
|
159
160
|
}
|
|
@@ -108,8 +108,17 @@ declare class Board {
|
|
|
108
108
|
* author, and a changed one needs a trusted editor or it goes back to its prior state. An editor stamp changed alone
|
|
109
109
|
* to an untrusted principal is put back. Unknown keys, nested Yjs types, and bad or foreign title values are deleted
|
|
110
110
|
* or undone. Returns the ids it deleted or restored.
|
|
111
|
+
*
|
|
112
|
+
* `writer` says who sent the update that holds a Yjs item, or undefined when it does not know. Where it knows, it
|
|
113
|
+
* decides: a new element gets its creator as author and editor, and an old one gets the sender of its changes as editor.
|
|
114
|
+
* A client cannot claim another principal, and an agent cannot pass as a person. A new item that the log lacks has no
|
|
115
|
+
* sender: it is deleted or undone. Only an old item falls back to `trusted`. A hand save names its poster as the sender
|
|
116
|
+
* of every item that the room does not hold, in `writer`.
|
|
111
117
|
*/
|
|
112
|
-
restamp(prior: Board, trusted: (who: Principal) => boolean
|
|
118
|
+
restamp(prior: Board, trusted: (who: Principal) => boolean, writer?: (item: {
|
|
119
|
+
client: number;
|
|
120
|
+
clock: number;
|
|
121
|
+
}) => Principal | undefined): string[];
|
|
113
122
|
addShape(input: unknown, actor: Principal): Shape;
|
|
114
123
|
addConnector(input: unknown, actor: Principal): Connector;
|
|
115
124
|
update(elementId: string, patch: unknown, actor: Principal): CanvasElement;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { S as ShapeType, c as Board, P as Principal, B as BoardData, a as Shape, C as Connector } from './read-
|
|
1
|
+
import { S as ShapeType, c as Board, P as Principal, B as BoardData, a as Shape, C as Connector, d as CanvasElement } from './read-Bdg1uiFC.js';
|
|
2
2
|
import { Store } from 'agentic-identity';
|
|
3
3
|
|
|
4
4
|
declare const MAX_FLOW_NODES = 500;
|
|
@@ -47,6 +47,11 @@ declare function drawFlow(board: Board, source: string, actor: Principal, option
|
|
|
47
47
|
edges: string[];
|
|
48
48
|
};
|
|
49
49
|
|
|
50
|
+
/** Who sent the Yjs item with this id, if the room knows. */
|
|
51
|
+
type Writer = (item: {
|
|
52
|
+
client: number;
|
|
53
|
+
clock: number;
|
|
54
|
+
}) => Principal | undefined;
|
|
50
55
|
/** One place for every board operation. The CLI, the MCP server, and the Worker call it. */
|
|
51
56
|
declare class CanvasService {
|
|
52
57
|
private readonly store;
|
|
@@ -58,6 +63,11 @@ declare class CanvasService {
|
|
|
58
63
|
id: string;
|
|
59
64
|
title: string;
|
|
60
65
|
}>;
|
|
66
|
+
/** The first 100 boards (the store sorts by id), with titles. A board whose stored bytes are damaged shows an empty title: it stays in the list so a person can find and repair it. */
|
|
67
|
+
list(): Promise<{
|
|
68
|
+
id: string;
|
|
69
|
+
title: string;
|
|
70
|
+
}[]>;
|
|
61
71
|
/** Throws NOT_FOUND unless the board exists. A live room opens only for a board that exists. */
|
|
62
72
|
assertExists(id: string): Promise<void>;
|
|
63
73
|
read(id: string): Promise<BoardData & {
|
|
@@ -67,6 +77,8 @@ declare class CanvasService {
|
|
|
67
77
|
change<R>(id: string, change: (board: Board) => R): Promise<R>;
|
|
68
78
|
add(id: string, shape: unknown, actor: Principal): Promise<Shape>;
|
|
69
79
|
connect(id: string, connector: unknown, actor: Principal): Promise<Connector>;
|
|
80
|
+
/** Change fields of one shape or connector: position, size, text, colors. */
|
|
81
|
+
update(id: string, elementId: string, patch: unknown, actor: Principal): Promise<CanvasElement>;
|
|
70
82
|
move(id: string, elementId: string, dx: number, dy: number, actor: Principal): Promise<{
|
|
71
83
|
moved: string[];
|
|
72
84
|
}>;
|
|
@@ -80,18 +92,25 @@ declare class CanvasService {
|
|
|
80
92
|
exportSvg(id: string): Promise<string>;
|
|
81
93
|
/** The stored board as a Yjs update. A client applies it to a room to seed it: the room does not read it. */
|
|
82
94
|
state(id: string): Promise<Uint8Array>;
|
|
95
|
+
/** What the stored board has that a room with state `room` lacks, as a Yjs update, or null when the room has all of it. */
|
|
96
|
+
missingFrom(id: string, room?: Uint8Array): Promise<Uint8Array | null>;
|
|
83
97
|
/**
|
|
84
|
-
*
|
|
98
|
+
* `writer` names who sent each Yjs item, from the room's log. It sets the stamps where it knows (see `Board.restamp`).
|
|
99
|
+
* Merge a live room's state into the stored board. `authors` are the principals the room saw send updates. A null
|
|
100
|
+
* `actor` trusts only them: the Worker saves a room that way, with nobody present to vouch.
|
|
85
101
|
* An element stamped by anyone else is deleted, and a changed author is put back (see `Board.restamp`),
|
|
86
102
|
* so a client that writes raw Yjs cannot put another author on its work. Broken entries are repaired and
|
|
87
103
|
* a board past the element cap is refused. Returns the ids the checks fixed.
|
|
88
104
|
*
|
|
89
105
|
* Limits: the save corrects the stored board only. The room keeps what its clients wrote, so a client that sees
|
|
90
106
|
* a non-empty `fixed` must join again, or its later edits are lost at the next save. The room remembers its
|
|
91
|
-
* authors for good, so
|
|
107
|
+
* authors for good, so an OLD item that the log does not name (a seeded one, or a range the log dropped) keeps the older rule: its
|
|
108
|
+
* stamp may name anyone who once sent an update in that room. A NEW item with no writer is deleted or undone.
|
|
109
|
+
* The log records who adds an item, not who deletes a key: a deleted optional key is credited to the writer of the editor
|
|
110
|
+
* stamp written in the same save, or undone if there is none.
|
|
92
111
|
* A client that adds a root type of its own makes every later save of that room fail, because the room keeps it.
|
|
93
112
|
*/
|
|
94
|
-
checkpoint(id: string, update: Uint8Array, actor: Principal, authors?: Principal[]): Promise<{
|
|
113
|
+
checkpoint(id: string, update: Uint8Array, actor: Principal | null, authors?: Principal[], writer?: Writer): Promise<{
|
|
95
114
|
elements: number;
|
|
96
115
|
fixed: string[];
|
|
97
116
|
}>;
|
|
@@ -104,6 +123,6 @@ declare class CanvasService {
|
|
|
104
123
|
private load;
|
|
105
124
|
}
|
|
106
125
|
/** What the CLI and the MCP server need. `CanvasService` runs it on a store, and `LiveService` runs it in a live room. */
|
|
107
|
-
type BoardAccess = Pick<CanvasService, "create" | "read" | "add" | "connect" | "move" | "remove" | "flow" | "exportSvg">;
|
|
126
|
+
type BoardAccess = Pick<CanvasService, "create" | "list" | "read" | "update" | "add" | "connect" | "move" | "remove" | "flow" | "exportSvg">;
|
|
108
127
|
|
|
109
128
|
export { type BoardAccess as B, CanvasService as C, type DrawOptions as D, type FlowEdge as F, MAX_FLOW_EDGES as M, type Placement as P, type FlowGraph as a, type FlowNode as b, MAX_FLOW_NODES as c, drawFlow as d, layoutFlow as l, parseFlow as p };
|
package/dist/worker.d.ts
CHANGED
|
@@ -2,8 +2,8 @@ import { Blobs } from 'agentic-identity';
|
|
|
2
2
|
import { Realtime } from 'agentic-realtime';
|
|
3
3
|
import { D1Like, R2Like } from 'agentic-identity/cloudflare';
|
|
4
4
|
import { Hono } from 'hono';
|
|
5
|
-
import { P as Principal } from './read-
|
|
6
|
-
import { C as CanvasService } from './service-
|
|
5
|
+
import { P as Principal } from './read-Bdg1uiFC.js';
|
|
6
|
+
import { C as CanvasService } from './service-Cim7TWnJ.js';
|
|
7
7
|
import 'yjs';
|
|
8
8
|
import 'zod';
|
|
9
9
|
|
|
@@ -40,6 +40,8 @@ interface AppOptions {
|
|
|
40
40
|
/** Makes the live-room server from the Worker bindings. Without it, the ws route answers 404. */
|
|
41
41
|
live?: (env: WorkerEnv) => Realtime;
|
|
42
42
|
}
|
|
43
|
+
/** A browser cannot set a header on a WebSocket. It offers the token as the sub-protocols `agentic-canvas.bearer` and the token in base64url. */
|
|
44
|
+
declare const WS_PROTOCOL = "agentic-canvas.bearer";
|
|
43
45
|
/** The principal and role for a bearer token, or null. The kind is never defaulted: a grant without a valid kind gets null. */
|
|
44
46
|
declare function authenticate(request: Request, env: WorkerEnv): {
|
|
45
47
|
actor: Principal;
|
|
@@ -52,4 +54,4 @@ declare const _default: {
|
|
|
52
54
|
fetch: (request: Request, env: WorkerEnv, ctx: unknown) => Response | Promise<Response>;
|
|
53
55
|
};
|
|
54
56
|
|
|
55
|
-
export { type AppOptions, type Role, type WorkerEnv, authenticate, createApp, _default as default, sniffImage };
|
|
57
|
+
export { type AppOptions, type Role, WS_PROTOCOL, type WorkerEnv, authenticate, createApp, _default as default, sniffImage };
|
package/dist/worker.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import {
|
|
2
|
+
WS_PROTOCOL,
|
|
2
3
|
authenticate,
|
|
3
4
|
createApp,
|
|
4
5
|
sniffImage,
|
|
5
6
|
worker_default
|
|
6
|
-
} from "./chunk-
|
|
7
|
-
import "./chunk-
|
|
8
|
-
import "./chunk-
|
|
7
|
+
} from "./chunk-HPUYT5TC.js";
|
|
8
|
+
import "./chunk-J52RPFRP.js";
|
|
9
|
+
import "./chunk-JZA525X5.js";
|
|
9
10
|
import "./chunk-NWQED42L.js";
|
|
10
11
|
export {
|
|
12
|
+
WS_PROTOCOL,
|
|
11
13
|
authenticate,
|
|
12
14
|
createApp,
|
|
13
15
|
worker_default as default,
|
package/llms.txt
CHANGED
|
@@ -5,10 +5,11 @@ A shared whiteboard for diagrams, drawn by people and agents.
|
|
|
5
5
|
Keywords: whiteboard,canvas,diagrams,drawing,collaboration,agents,mcp,cli.
|
|
6
6
|
|
|
7
7
|
- SDK: import { Board, drawFlow, readBoard, toSvg } from "agentic-canvas".
|
|
8
|
-
- CLI: agentic-canvas new|read|add|connect|move|rm|flow|export.
|
|
8
|
+
- CLI: agentic-canvas new|list|read|add|connect|update|move|rm|flow|export.
|
|
9
|
+
- UI: the Worker serves a web UI at its root (run `npm run build`, then `wrangler dev`). A person signs in with a bearer token.
|
|
9
10
|
- Live: set CANVAS_SERVER and CANVAS_TOKEN, and the CLI and `agentic-canvas mcp` edit the board in its live room.
|
|
10
11
|
- SDK: import { LiveBoard, LiveService } from "agentic-canvas/live".
|
|
11
|
-
- SDK: import { createClient, CanvasApiError } from "agentic-canvas/client". It calls the Worker API with fetch. Methods: me, createBoard, readBoard, exportSvg, addShape, addConnector, drawFlow, move, remove, uploadImage, getImage, state, checkpoint. A failure throws CanvasApiError with `code` and `status`. No retries.
|
|
12
|
+
- SDK: import { createClient, CanvasApiError } from "agentic-canvas/client". It calls the Worker API with fetch. Methods: me, listBoards, createBoard, readBoard, exportSvg, addShape, addConnector, drawFlow, update, move, remove, uploadImage, getImage, state, checkpoint. A failure throws CanvasApiError with `code` and `status`. No retries.
|
|
12
13
|
|
|
13
14
|
## Docs
|
|
14
15
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentic-canvas",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "A shared whiteboard for diagrams, drawn by people and agents.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"whiteboard",
|
|
@@ -64,17 +64,19 @@
|
|
|
64
64
|
"agentic-canvas": "./dist/cli.js"
|
|
65
65
|
},
|
|
66
66
|
"scripts": {
|
|
67
|
-
"build": "tsup",
|
|
68
|
-
"typecheck": "tsc --noEmit",
|
|
67
|
+
"build": "tsup && vite build",
|
|
68
|
+
"typecheck": "tsc --noEmit && tsc --noEmit -p ui",
|
|
69
69
|
"test": "vitest run",
|
|
70
70
|
"clean": "rm -rf dist",
|
|
71
|
-
"ci:local": "npm run build && npm run typecheck && npm test"
|
|
71
|
+
"ci:local": "npm run build && npm run typecheck && npm test && npm run test:e2e",
|
|
72
|
+
"deploy:demo": "wrangler deploy -c wrangler.demo.jsonc",
|
|
73
|
+
"test:e2e": "playwright test -c e2e/playwright.config.ts"
|
|
72
74
|
},
|
|
73
75
|
"dependencies": {
|
|
74
76
|
"@dagrejs/dagre": "^3.1.3",
|
|
75
77
|
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
76
|
-
"agentic-identity": "^0.
|
|
77
|
-
"agentic-realtime": "^0.
|
|
78
|
+
"agentic-identity": "^0.5.0",
|
|
79
|
+
"agentic-realtime": "^0.3.0",
|
|
78
80
|
"yjs": "^13.6.0",
|
|
79
81
|
"zod": "^3.25.0"
|
|
80
82
|
},
|
|
@@ -87,12 +89,21 @@
|
|
|
87
89
|
}
|
|
88
90
|
},
|
|
89
91
|
"devDependencies": {
|
|
92
|
+
"@axe-core/playwright": "^4.14.0",
|
|
93
|
+
"@playwright/test": "^1.64.1",
|
|
90
94
|
"@types/node": "^22.0.0",
|
|
95
|
+
"@types/react": "^19.3.0",
|
|
96
|
+
"@types/react-dom": "^19.3.0",
|
|
97
|
+
"@vitejs/plugin-react": "^5.2.0",
|
|
98
|
+
"agentic-design": "^0.2.0",
|
|
91
99
|
"hono": "^4.13.14",
|
|
92
100
|
"miniflare": "^4.20260730.0",
|
|
93
101
|
"playwright-core": "^1.64.1",
|
|
102
|
+
"react": "^19.3.1",
|
|
103
|
+
"react-dom": "^19.3.1",
|
|
94
104
|
"tsup": "^8.0.0",
|
|
95
105
|
"typescript": "^5.6.0",
|
|
106
|
+
"vite": "^7.3.7",
|
|
96
107
|
"vitest": "^3.0.0",
|
|
97
108
|
"wrangler": "^4.155.0"
|
|
98
109
|
}
|