esoul-sdk 0.3.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.
@@ -0,0 +1,62 @@
1
+ # 10. Testing — the fold contract as tests
2
+
3
+ The workbench runs your tests with jest (`test_app`, seconds; `check_app` adds the platform's
4
+ suites and a type check). Write the contract the reviewer wants to trust:
5
+
6
+ ```ts
7
+ import { noteAddedEvent, noteTextSetEvent, pluginSchema, type StickyNotesData } from "./app";
8
+
9
+ const IDENT = { workspaceId: "ws1", nodeId: "node1", applicationType: "plugin_sticky_notes", instanceName: "My wall" };
10
+ const fresh = (): StickyNotesData => pluginSchema.stateCreator(IDENT as any, {} as any);
11
+ const apply = (def: any, s: StickyNotesData, args: Record<string, any> = {}) => def.processor(s, def.dataCreator({ ...IDENT, ...args }));
12
+
13
+ it("starts empty, adds exactly what it says, and a replay is a no-op", () => {
14
+ expect(fresh().notes).toEqual([]);
15
+ const once = apply(noteAddedEvent, fresh(), { noteId: "n1", text: "milk" });
16
+ const ev = noteAddedEvent.dataCreator({ ...IDENT, noteId: "n1", text: "milk" });
17
+ const twice = noteAddedEvent.processor(noteAddedEvent.processor(fresh(), ev), ev);
18
+ expect(once.notes.map((n) => n.id)).toEqual(["n1"]);
19
+ expect(twice).toEqual(noteAddedEvent.processor(fresh(), ev));
20
+ });
21
+
22
+ it("refuses a payload it cannot trust, and ignores an unknown id", () => {
23
+ expect(noteAddedEvent.processor(fresh(), { eventData: { noteId: 42 } } as any)).toEqual(fresh());
24
+ expect(apply(noteTextSetEvent, fresh(), { noteId: "ghost", text: "x" })).toEqual(fresh());
25
+ });
26
+
27
+ it("no processor mints an id or a time: two folds of one log are identical", () => {
28
+ const log = [noteAddedEvent.dataCreator({ ...IDENT, noteId: "n1", text: "a" }), noteTextSetEvent.dataCreator({ ...IDENT, noteId: "n1", text: "b" })];
29
+ const fold = () => log.reduce((s, e) => (e.eventName === noteAddedEvent.eventName ? noteAddedEvent : noteTextSetEvent).processor(s, e as any), fresh());
30
+ expect(fold()).toEqual(fold());
31
+ });
32
+
33
+ it("the description never claims an emptiness it could not read", () => {
34
+ expect(pluginSchema.getStateDescription({ ...IDENT } as any)).toMatch(/not loaded|incomplete/i);
35
+ expect(pluginSchema.getStateDescription(fresh())).toContain("No notes yet");
36
+ });
37
+ ```
38
+
39
+ A UI test renders once with a fixed state and checks the visible chrome and the empty state.
40
+ Keep suites fast; they run beside the live preview on the same machine.
41
+
42
+ ## Tools
43
+
44
+ Call `execute` with a recording `eventCallback` and assert the events it emitted and the text it
45
+ returned — including the refusal text for a bad id.
46
+
47
+ ## Tasks
48
+
49
+ Fake the context: `step.run` that calls its function, a recording `dispatchEvent`. Run the
50
+ handler twice with the same `eventData`; the second run must not add a second item.
51
+
52
+ ## Connections
53
+
54
+ `esoul-sdk/testing` ships `startMockOAuth()` — a local OAuth server that issues, refreshes and
55
+ revokes tokens, so a connection-backed op can be tested end to end without a real provider.
56
+
57
+ ## The fold corpus
58
+
59
+ Once an app has real history, record it (`scripts/plugins/record-fold-corpus.mjs` in the
60
+ platform) and commit `fold-corpus.json` + hashes. From then on the checks refuse a change that
61
+ alters what history MEANS — the strongest protection an app with data can have. Re-recording is
62
+ declaring a migration; say so in the changelog.
@@ -0,0 +1,70 @@
1
+ # 11. Shipping — submit, review, release, install
2
+
3
+ ## The path
4
+
5
+ 1. **Build and test in the Forge workbench** (docs/01). No repository access is needed: the
6
+ platform clones with its own installation for the app arm.
7
+ 2. **Submit** with `ship_app`. It refuses unless every check is green, commits, pushes the app's
8
+ branch and opens (or updates) a pull request labelled `user-app` and `submitted-by:<you>`.
9
+ The board shows "submitted — awaiting review". An app id already on the base branch under
10
+ another author is refused before a pull request exists — pick another id.
11
+ 3. **The owner reviews** the diff on GitHub and approves.
12
+ 4. **One script releases it**: checks the pull request out cleanly, runs the import wall, the
13
+ sync, your tests and the type check, merges any dependencies you declared onto your branch so
14
+ the reviewer sees them, squash-merges, waits for the production deploy, grants you the
15
+ entitlement, and tells your board "released — add it to a workspace".
16
+ 5. **Add it** in any of your workspaces from the app picker, by chat (`add_app`), or over MCP.
17
+ Its tools are live in chat, voice, agents and MCP; its events ride the timeline.
18
+
19
+ ## The import wall
20
+
21
+ Review is the security boundary — your server code will run with the platform's credentials.
22
+ Review is only tractable if nothing can reach around the SDK, so the wall refuses at check time,
23
+ at sync and at release:
24
+
25
+ - allowed: `esoul-sdk` (and `/server`, `/react`, `/testing`), relative files in your folder,
26
+ `server-only`, npm packages the platform already depends on;
27
+ - refused: `@/…` platform internals, `node:*` and the node built-ins, `prisma`, `next`,
28
+ `inngest`, the Vercel SDKs, and any package not in the platform's `package.json`.
29
+
30
+ Need something the SDK lacks? Ask for it in the SDK — that is the review's pressure valve. Need
31
+ an npm package? Declare it (below); it becomes a visible line in the pull request.
32
+
33
+ ## Declaring npm dependencies
34
+
35
+ Put a `package.json` in your app folder with only `dependencies`:
36
+
37
+ ```json
38
+ { "dependencies": { "date-fns": "^4.1.0" } }
39
+ ```
40
+
41
+ The release script merges them into the platform's `package.json`, updates the lockfile on your
42
+ branch, and pushes that commit onto your pull request before merging. In the workbench the
43
+ package is not yet installed until that happens; a later platform version merges declared
44
+ dependencies at `open`.
45
+
46
+ ## Entitlement — your app on your instance
47
+
48
+ A released app is available to **its submitter** and to no one else until the owner grants
49
+ everyone (`--public`). A plugin that predates entitlements stays public. The picker only offers
50
+ what you may add; the add path enforces it.
51
+
52
+ ## Versions and upgrades
53
+
54
+ Bump `version` on every submission. Events are forever: a change that alters what an existing
55
+ event MEANS rewrites every workspace's history on the next fold — record a fold corpus (docs/10)
56
+ so the checks catch it, and treat such a change as a migration with its own events.
57
+
58
+ ## Dev and deployed are symmetric
59
+
60
+ In the workbench your app can already call other apps (through the board's tab) and its own
61
+ tools can be called (`call_app_tool`) before install. Installed, the same calls go through the
62
+ manifest's grants, and the platform mints your tools natively. Exposing an in-development app's
63
+ tools to the whole workspace — so chat could call them before install — is the next step on
64
+ the platform's side (the board minting proxy tools per app under construction).
65
+
66
+ ## Cost and lifetime
67
+
68
+ A workbench dies ten minutes after the last touch (a tool call, or a board tab watching it).
69
+ `close_workbench` stops it at once. Files and commits persist; reopening resumes and updates the
70
+ platform it runs on.
@@ -0,0 +1,37 @@
1
+ # 12. Rules and failures — every rule with the failure that earned it
2
+
3
+ | Rule | The failure it prevents |
4
+ |---|---|
5
+ | Processors are pure and idempotent | a replay duplicated items; a scrub showed a state that never existed |
6
+ | Ids and timestamps are minted in `dataCreator`, never in a processor | two folds of one log disagreed; snapshots diverged from replay |
7
+ | A processor refuses a bad payload instead of throwing | one malformed webhook took a whole workspace view down |
8
+ | Whole-replace and burst events carry a collapse key per entity and field | fifty timeline rows per sentence; two notes edited together merged into one |
9
+ | `reconstructStateFromEventLog: true` | an app hydrated from a stale column and lost its last edits |
10
+ | `getStateDescription` uses `incompleteStateNotice`; missing ≠ empty | an agent "saw" an empty list, invited a duplicate, and the notebook crashed on a throw |
11
+ | Every tool has a real `onClient` (`= execute`) | the voice agent said "added the note" while nothing ran — an empty stub reads as success |
12
+ | Tool results say what was DONE, refuse what was not | "updated" for an id that did not exist |
13
+ | Server truth through a plugin op, never `fetch("/api/v1/…")` | the token-gated route refused the tool with "not readable" |
14
+ | Cross-app calls only through `useWorkspaceTools` / `callWorkspaceTool` with manifest grants | an app could act on any app in the workspace unseen |
15
+ | Server code reaches the platform only through `esoul-sdk/server` (the import wall) | a demo plugin read the database directly; a reviewer would have had to read every line |
16
+ | Webhooks verify, validate, kick a task, ACK; idempotent by the sender's key | a retried push duplicated an item; a slow webhook timed out mid-work |
17
+ | Every task side effect lives in a `step.run` | a dispatch fired four times, once per replay |
18
+ | No per-app crons; `pollTasks` at 5-minute granularity | Inngest function ids and the plan's concurrency cap |
19
+ | Secrets are env-variable NAMES in the manifest, never values | a client secret in a package that becomes public |
20
+ | The schema module is never `"use client"` | the schema became a server proxy and every processor vanished at build |
21
+ | The UI never imports the store or the registry, only `esoul-sdk/react` | a module cycle that crashed the client on load |
22
+ | Root fills the frame, `overflow:hidden`, content `flex:1 minHeight:0` | the whole app scrolled sideways on a phone |
23
+ | Measure the visible scroller, not the canvas | a 2400 px canvas laid new notes off the right edge |
24
+ | Observe `.dark` at runtime | wrong colours until the person clicked something |
25
+ | Bound every collection and string | an app that could no longer be folded |
26
+ | Bump the manifest version; record a fold corpus once there is data | a "small" event change rewrote every workspace's history |
27
+
28
+ ## Symptom → rule
29
+
30
+ - "It works in the preview and nothing changes after install" → the tool only had `onClient`, or
31
+ the event was `Server`-typed and dispatched from the UI.
32
+ - "The timeline has hundreds of rows for one edit" → no collapse key.
33
+ - "State reverts after reload" → `reconstructStateFromEventLog` unset, or a processor minted ids.
34
+ - "check_app is red on registry" → the manifest failed validation or the import wall refused a
35
+ file; the detail names it.
36
+ - "My tool needs the database" → write an op; call it with `callPluginOp`; expect a refusal in
37
+ the workbench (no database behind a preview) and use `read_app_state` there.