esoul-sdk 0.24.0 → 0.25.2
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/CHANGELOG.md +58 -0
- package/README.md +23 -6
- package/api-reference.md +202 -18
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/machine/shell.js +2 -2
- package/dist/manifest.d.ts +1113 -31
- package/dist/manifest.js +175 -17
- package/dist/react.d.ts +13 -6
- package/dist/react.js +2 -2
- package/dist/server.d.ts +73 -9
- package/dist/server.js +16 -2
- package/dist/testing/credentials.d.ts +42 -0
- package/dist/testing/credentials.js +73 -0
- package/dist/testing/index.d.ts +2 -0
- package/dist/testing/index.js +1 -0
- package/dist/types.d.ts +15 -0
- package/docs/02-manifest.md +11 -3
- package/docs/04-tools.md +6 -0
- package/docs/05-ui.md +25 -0
- package/docs/06-server.md +55 -7
- package/docs/08-connections.md +322 -105
- package/docs/10-testing.md +29 -0
- package/docs/17-editing-and-merging.md +22 -0
- package/docs/18-your-computer.md +35 -13
- package/docs/20-a-service-on-your-computer.md +291 -0
- package/llms-full.txt +823 -134
- package/llms.txt +11 -8
- package/package.json +1 -1
- package/schemas/plugin.schema.json +240 -35
- package/scripts/build-llms.mjs +5 -3
package/docs/10-testing.md
CHANGED
|
@@ -39,6 +39,35 @@ it("the description never claims an emptiness it could not read", () => {
|
|
|
39
39
|
A UI test renders once with a fixed state and checks the visible chrome and the empty state.
|
|
40
40
|
Keep suites fast; they run beside the live preview on the same machine.
|
|
41
41
|
|
|
42
|
+
## The testing kit — `esoul-sdk/testing`
|
|
43
|
+
|
|
44
|
+
Worlds your tests run in, so nothing real is touched and every run is the same:
|
|
45
|
+
|
|
46
|
+
| Helper | Gives you | Chapter |
|
|
47
|
+
|---|---|---|
|
|
48
|
+
| `memoryDb(manifest)` + `fakeViewer(kind, opts)` | your declared tables in memory, with your REAL row rules compiled; a viewer of any kind (`owner`, `member`, `visitor`, `anonymous`, `agent`, roles and attributes) | 14 |
|
|
49
|
+
| `runOp(pluginServer, "op", { viewer, args, db, apps })` | calls one op with a real-shaped `ctx`; returns its result plus what it `emit`ted and `notify`d | 14 |
|
|
50
|
+
| `fakeApps({ slot: { tools } })`, `capture()` | bound apps for `ctx.apps.<slot>` (calls recorded); a recorder for any callback | 16 |
|
|
51
|
+
| `memoryFiles(tree)` | the files door (`filesForOp`) over an in-memory tree: sources, list, read, write, transfers — with `crashAt` / `failItem` to prove a retry leaves no residue | 09 |
|
|
52
|
+
| `scriptedModel(policy)` | `llm(ctx)` answering from your script; `calls` records every request | 19 |
|
|
53
|
+
| `simGmail(opts)` | a Gmail API at the HTTP level (`fetch`, `install()`), with accounts, `deliver(...)`, personas that reply (`addPersona`), a cold-campaign `crowd(n)`, a virtual clock (`advance(ms)`), `faults` (429/5xx/401/network), `snapshot()` / restore; `sent` and `outbox` say what left | 08 |
|
|
54
|
+
| `fakeCredentials(slots)` | `credentials(ctx)` without accounts: per slot a `status`, the `hosts` it may reach and a `fetch` handler; the platform's refusals (undeclared slot, plain http, a host off the list, a slot not ready) and `calls` recording every request | 08 |
|
|
55
|
+
| `startMockOAuth()` | a local OAuth 2 provider for a connection's sign-in and refresh | 08 |
|
|
56
|
+
| `listFailedRequests()`, `subscribeFailedRequests`, `resetFailedRequests` | what `runOptimistic` reverted and named on the banner | 05 |
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
import { memoryDb, fakeViewer, runOp, scriptedModel } from "esoul-sdk/testing";
|
|
60
|
+
import manifest from "./plugin.json";
|
|
61
|
+
import { pluginServer } from "./server";
|
|
62
|
+
|
|
63
|
+
it("a customer sees only their own orders", async () => {
|
|
64
|
+
const db = memoryDb(manifest);
|
|
65
|
+
await runOp(pluginServer, "place-order", { viewer: fakeViewer("visitor", { userId: "a" }), args: { sku: "x" }, db });
|
|
66
|
+
const b = await runOp(pluginServer, "list-orders", { viewer: fakeViewer("visitor", { userId: "b" }), db });
|
|
67
|
+
expect(b.result).toEqual({ orders: [] });
|
|
68
|
+
});
|
|
69
|
+
```
|
|
70
|
+
|
|
42
71
|
## Tools
|
|
43
72
|
|
|
44
73
|
Call `execute` with a recording `eventCallback` and assert the events it emitted and the text it
|
|
@@ -153,3 +153,25 @@ recorded as a conflict. `baseMerge` runs in every fold: keep it pure, exactly li
|
|
|
153
153
|
platform and needs no merge — prefer operations to whole-value saves where you can.
|
|
154
154
|
- Device view state (which page is open, scroll, selection) belongs in component state or
|
|
155
155
|
`sessionStorage`, never in a shared event another device would follow.
|
|
156
|
+
|
|
157
|
+
## 4. Declare `resolveConcurrent` for patch-shaped events
|
|
158
|
+
|
|
159
|
+
`baseMerge` is for events that write one whole value. An event that writes a PATCH — a list of sub-items it
|
|
160
|
+
changed, each with the version it built on — can do better: declare `merge` with `scope` (the sub-item ids),
|
|
161
|
+
and `resolveConcurrent`. The platform calls it only when the patch would land as a conflict (a writer it never
|
|
162
|
+
saw changed one of its sub-items), with the state it lands on; it merges what it can against the bases it
|
|
163
|
+
carries and returns the rewritten event:
|
|
164
|
+
|
|
165
|
+
```ts
|
|
166
|
+
merge: { item: (_d, ctx) => `${ctx.applicationId}:cad:model`, scope: (d) => d.delta.ops.map(keyOf), describe: (d) => d.label },
|
|
167
|
+
resolveConcurrent: (state, eventData, { theirsSess }) => {
|
|
168
|
+
const r = resolveEdit(state, eventData); // pure: three-way per sub-item against the base each op carries
|
|
169
|
+
if (!r) return null; // cannot reason about it → the plain conflict (applied and marked)
|
|
170
|
+
return r.clashes.length === 0
|
|
171
|
+
? { eventData: r.eventData } // everything merged: applied, no marker
|
|
172
|
+
: { eventData: r.eventData, detail: { kind: "cad-edit", clashes: r.clashes, theirsSess } };
|
|
173
|
+
},
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
What it returns is what the fold applies, on every device and on the server. Keep it pure and symmetric; a
|
|
177
|
+
property test over randomised edit pairs is the cheapest proof.
|
package/docs/18-your-computer.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# 18 · Parts of your app that run on the person's computer
|
|
2
2
|
|
|
3
|
+
> Building a service that runs on the computer and talks to your app? Start with the
|
|
4
|
+
> walk-through, [20 · A service on your computer](20-a-service-on-your-computer.md); this page is
|
|
5
|
+
> the reference.
|
|
6
|
+
|
|
3
7
|
Some apps need the person's own machine: a GPU for training, files that never
|
|
4
8
|
leave a laptop, a tool installed there, a local network. The **device arm**
|
|
5
9
|
lets an app run commands on a computer the owner connects, with the output
|
|
@@ -59,12 +63,17 @@ The panel lists connected computers (online, last seen, Disconnect) and a
|
|
|
59
63
|
**Connect a computer** button. It shows one command to paste in a terminal:
|
|
60
64
|
|
|
61
65
|
```
|
|
62
|
-
npx -y -p esoul-sdk@0.19.
|
|
66
|
+
npx -y -p esoul-sdk@0.19.1 esoul-device connect https://externalsoul.com/api/device/m/offers/off_…
|
|
63
67
|
```
|
|
64
68
|
|
|
65
69
|
The runtime ships inside `esoul-sdk` (the `esoul-device` command, and
|
|
66
70
|
`esoul-sdk/machine` for code that runs on the computer). It uses only Node
|
|
67
|
-
built-ins, so a computer needs nothing but Node 22.
|
|
71
|
+
built-ins, so a computer needs nothing but Node 22. The version in the command
|
|
72
|
+
is the runtime's own, pinned by the platform (it moves more slowly than the
|
|
73
|
+
package). A computer without Node uses the panel's other line, which brings a
|
|
74
|
+
pinned Node first, without sudo:
|
|
75
|
+
`curl -fsSL https://externalsoul.com/api/device/install | sh -s -- <link>`.
|
|
76
|
+
The link expires in 10 minutes.
|
|
68
77
|
|
|
69
78
|
The command holds no secret. The computer makes its own key, prints a code
|
|
70
79
|
(`K7Q-4XM`) and the list of commands; the page shows the same code with
|
|
@@ -134,8 +143,10 @@ const { run, finished } = useDeviceRun({ workspaceId, nodeId, commandId });
|
|
|
134
143
|
// run.output streams in as the command prints (first 1 MB kept, then run.tail)
|
|
135
144
|
```
|
|
136
145
|
|
|
137
|
-
`useDeviceActions({ nodeId })` gives the owner's buttons from the UI (run
|
|
138
|
-
cancel
|
|
146
|
+
`useDeviceActions({ nodeId })` gives the owner's buttons from the UI (`run`,
|
|
147
|
+
`cancel`, `disconnect`, `approveUpdate`, `setWorkspace` for the reach, `sh`,
|
|
148
|
+
and `recent` runs) without writing an op. `useMyComputers()` and
|
|
149
|
+
`<YourComputers/>` list the person's computers across apps.
|
|
139
150
|
|
|
140
151
|
## 6. Local settings stay local
|
|
141
152
|
|
|
@@ -144,7 +155,7 @@ manifest declares under `device.config` are set on the computer and never sent
|
|
|
144
155
|
to the platform:
|
|
145
156
|
|
|
146
157
|
```
|
|
147
|
-
npx -y -p esoul-sdk@0.19.
|
|
158
|
+
npx -y -p esoul-sdk@0.19.1 esoul-device config set "My app" HF_TOKEN
|
|
148
159
|
```
|
|
149
160
|
|
|
150
161
|
The command receives them as environment variables. `redact` patterns are
|
|
@@ -163,9 +174,12 @@ applied on the computer before any output leaves it.
|
|
|
163
174
|
|
|
164
175
|
## 8. Testing
|
|
165
176
|
|
|
166
|
-
In a Forge preview
|
|
167
|
-
|
|
168
|
-
`
|
|
177
|
+
Computers connect to INSTALLED apps. In a Forge preview `devices(ctx).list()`
|
|
178
|
+
answers `[]`, every other `devices()` call throws "computers connect to
|
|
179
|
+
INSTALLED apps", `useDevices`/`useDeviceRun` stay empty, `useDeviceActions`
|
|
180
|
+
throws, and `<ConnectComputer/>` says so. Mock `esoul-sdk/server` in unit tests,
|
|
181
|
+
as for any host function, and test a program with a fake `p`
|
|
182
|
+
([20 §5](20-a-service-on-your-computer.md)).
|
|
169
183
|
|
|
170
184
|
## 9. The workspace: agents working on the computer
|
|
171
185
|
|
|
@@ -181,9 +195,9 @@ dev server open — the owner gives the app a **workspace**:
|
|
|
181
195
|
Two parties agree. **The computer** sets a ceiling when it connects:
|
|
182
196
|
|
|
183
197
|
```
|
|
184
|
-
npx -y -p esoul-sdk@0.19.
|
|
185
|
-
npx -y -p esoul-sdk@0.19.
|
|
186
|
-
npx -y -p esoul-sdk@0.19.
|
|
198
|
+
npx -y -p esoul-sdk@0.19.1 esoul-device connect <link> # this folder (the default)
|
|
199
|
+
npx -y -p esoul-sdk@0.19.1 esoul-device connect <link> --folder ~/proj # another folder
|
|
200
|
+
npx -y -p esoul-sdk@0.19.1 esoul-device connect <link> --scope computer # allow the whole computer
|
|
187
201
|
```
|
|
188
202
|
|
|
189
203
|
**The owner** grants each app at most that ceiling from the app's page (the
|
|
@@ -320,8 +334,16 @@ The rules that keep it safe and quiet:
|
|
|
320
334
|
|
|
321
335
|
Where each program stands on each computer — installing, each setup step,
|
|
322
336
|
self-test, running, the program's own `p.status` words, why it failed —
|
|
323
|
-
comes back on `devices(ctx).list()` as `programState
|
|
324
|
-
|
|
337
|
+
comes back on `devices(ctx).list()` as `programState` (one computer:
|
|
338
|
+
`devices(ctx).program(linkId).state()`), and `<ConnectComputer/>` shows it.
|
|
339
|
+
|
|
340
|
+
Limits: a message (`send`) and its answer are each at most 1 MB of JSON; a
|
|
341
|
+
topic matches `[a-zA-Z][a-zA-Z0-9_.:-]{0,63}`; a message for an offline
|
|
342
|
+
computer expires (600 s default). `p.op` calls are resent when no answer has
|
|
343
|
+
come in about 4 s (up to 3 tries) — make those ops idempotent and pass
|
|
344
|
+
`{ key }`. `p.workspaceRoot` is null when the whole computer is granted.
|
|
345
|
+
Nothing reaches INTO the computer: there is no tunnel or open port; a program
|
|
346
|
+
reaches its own localhost and calls out. The reference app is **CPU monitor** (`src/plugins/cpu-monitor`).
|
|
325
347
|
|
|
326
348
|
### An agent on each computer
|
|
327
349
|
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
# 20 · A service on your computer, connected to your app
|
|
2
|
+
|
|
3
|
+
A guide, start to finish: an app built in the Forge that runs its own
|
|
4
|
+
long-lived service on the person's computer — a local model server, a
|
|
5
|
+
sampler, a bridge to a device on the desk, a trainer's supervisor — and
|
|
6
|
+
talks to it both ways. The reference for every piece is
|
|
7
|
+
[18 · Parts of your app that run on the person's computer](18-your-computer.md);
|
|
8
|
+
this page is the path through it.
|
|
9
|
+
|
|
10
|
+
## The shape
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
your app on ExternalSoul the person's computer
|
|
14
|
+
───────────────────────── ─────────────────────────────
|
|
15
|
+
server.ts ── devices(ctx).program(linkId) device/main.ts (your PROGRAM,
|
|
16
|
+
.send("ask", data) ─────────▶ p.on("ask", …) kept running,
|
|
17
|
+
◀───────── answer (≤ 1 MB) sandboxed)
|
|
18
|
+
ops.ts ◀── p.op("report", data) ──────── p.every / events │
|
|
19
|
+
(the op sees viewer.device) ▼
|
|
20
|
+
your service on localhost
|
|
21
|
+
(started by the program,
|
|
22
|
+
installed by setup steps)
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
- **The program is the service's keeper.** Your app ships a `device/` folder;
|
|
26
|
+
the person approves it once on their computer; the runtime installs it,
|
|
27
|
+
runs your setup steps (a Python venv, `npm ci`, a model download), checks it
|
|
28
|
+
with your self-test, and keeps it running (`"resident": true`), restarting
|
|
29
|
+
it when it exits.
|
|
30
|
+
- **App → service:** your server code sends a message to the program; the
|
|
31
|
+
program's handler does the work — usually a `fetch("http://localhost:…")`
|
|
32
|
+
to the service it started — and returns the answer.
|
|
33
|
+
- **Service → app:** the program calls one of your app's own ops with
|
|
34
|
+
`p.op(...)`. The op knows it came from that computer (`ctx.viewer.device`).
|
|
35
|
+
- **Nothing reaches INTO the computer.** There is no tunnel and no open port:
|
|
36
|
+
the computer only calls out. The program can reach its own `localhost` and
|
|
37
|
+
the internet; your app reaches the program only through `send`.
|
|
38
|
+
|
|
39
|
+
## 1. Declare the program (`plugin.json`)
|
|
40
|
+
|
|
41
|
+
```json
|
|
42
|
+
{
|
|
43
|
+
"ops": ["ask", "report", "computers"],
|
|
44
|
+
"device": {
|
|
45
|
+
"program": {
|
|
46
|
+
"main": "device/main.ts",
|
|
47
|
+
"resident": true,
|
|
48
|
+
"setup": [
|
|
49
|
+
{ "id": "venv", "describe": "Python environment", "run": ["python3", "-m", "venv", ".venv"] },
|
|
50
|
+
{
|
|
51
|
+
"id": "deps", "describe": "Install the server",
|
|
52
|
+
"run": [".venv/bin/pip", "install", "-r", "{program}/device/requirements.txt"],
|
|
53
|
+
"inputs": ["device/requirements.txt"]
|
|
54
|
+
}
|
|
55
|
+
],
|
|
56
|
+
"selfTest": { "run": [".venv/bin/python", "{program}/device/self_test.py"] }
|
|
57
|
+
},
|
|
58
|
+
"config": { "MODEL_DIR": { "describe": "Where the model weights are on this computer" } },
|
|
59
|
+
"onAppDeleted": "kill"
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
- `setup` runs once per step and per change of its `inputs` — an app update
|
|
65
|
+
that leaves `requirements.txt` alone does not reinstall. Steps are argv, never
|
|
66
|
+
a shell line; `{program}` is the program's folder. Relative paths (`.venv`)
|
|
67
|
+
live in the program's data folder, which is kept across versions.
|
|
68
|
+
- `selfTest` must exit 0 before the program counts as ready; make it the
|
|
69
|
+
thing your service needs (`claude --version`, a GPU check, an import).
|
|
70
|
+
- `config` names values that stay on the computer (`esoul-device config set
|
|
71
|
+
"<app name>" MODEL_DIR ~/models`); the program reads them as `p.config`.
|
|
72
|
+
|
|
73
|
+
## 2. Write the program (`device/main.ts`)
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
import type { Program } from "esoul-sdk/machine"; // types only — nothing is installed beside the program
|
|
77
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
78
|
+
import { join } from "node:path";
|
|
79
|
+
|
|
80
|
+
const PORT = 8711;
|
|
81
|
+
let server: ChildProcess | null = null;
|
|
82
|
+
|
|
83
|
+
async function healthy(): Promise<boolean> {
|
|
84
|
+
try { return (await fetch(`http://127.0.0.1:${PORT}/health`)).ok; } catch { return false; }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export default {
|
|
88
|
+
async start(p) {
|
|
89
|
+
// Start the service: argv, no shell; its output goes to the program's log.
|
|
90
|
+
server = spawn(join(p.dataDir, ".venv/bin/python"), [join(p.programDir, "device/serve.py"), "--port", String(PORT)], {
|
|
91
|
+
cwd: p.dataDir,
|
|
92
|
+
env: { ...process.env, MODEL_DIR: p.config.MODEL_DIR ?? "" },
|
|
93
|
+
stdio: ["ignore", "inherit", "inherit"],
|
|
94
|
+
});
|
|
95
|
+
for (let i = 0; i < 60 && !(await healthy()); i++) await new Promise((r) => setTimeout(r, 1000));
|
|
96
|
+
p.status((await healthy()) ? "Model server ready" : "Model server did not start", { ready: await healthy() });
|
|
97
|
+
|
|
98
|
+
// App → service: the answer of the handler is the answer of send().
|
|
99
|
+
p.on("ask", async (data) => {
|
|
100
|
+
const r = await fetch(`http://127.0.0.1:${PORT}/ask`, { method: "POST", body: JSON.stringify(data), headers: { "content-type": "application/json" } });
|
|
101
|
+
return await r.json();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Long work: answer at once, report when done (the app's op stores it).
|
|
105
|
+
p.on("job", async (data) => {
|
|
106
|
+
const { requestId } = data as { requestId: string };
|
|
107
|
+
void (async () => {
|
|
108
|
+
const r = await fetch(`http://127.0.0.1:${PORT}/job`, { method: "POST", body: JSON.stringify(data) });
|
|
109
|
+
await p.op("report", { requestId, result: await r.json() }, { key: requestId });
|
|
110
|
+
})();
|
|
111
|
+
return { accepted: true };
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Service → app on a clock.
|
|
115
|
+
p.every(60_000, async () => p.op("report", { kind: "heartbeat", ok: await healthy() }));
|
|
116
|
+
},
|
|
117
|
+
async stop() { server?.kill(); },
|
|
118
|
+
} satisfies Program;
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
What a program may import: Node built-ins (`node:fs`, `node:child_process`, …),
|
|
122
|
+
files of its own `device/` folder, and `esoul-sdk/machine` **types only**. No
|
|
123
|
+
packages — install what the service needs in a setup step. At most 2 MB and
|
|
124
|
+
200 files; download weights and datasets in setup or at start. Node 22.6+
|
|
125
|
+
runs a `.ts` entry directly (no enums, no namespaces).
|
|
126
|
+
|
|
127
|
+
## 3. Talk to it from the app (`server.ts`)
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
import { devices, type PluginOpContext } from "esoul-sdk/server";
|
|
131
|
+
|
|
132
|
+
// App → service. linkId names one connected computer (devices(ctx).list()).
|
|
133
|
+
async function ask(ctx: PluginOpContext, args: { linkId: string; prompt: string }) {
|
|
134
|
+
const r = await devices(ctx).program(args.linkId).send("ask", { prompt: args.prompt }, { waitSeconds: 30 });
|
|
135
|
+
if (!r.ok) return { ok: false, error: r.error ?? "the computer did not answer" }; // offline, not approved, handler threw
|
|
136
|
+
return { ok: true, answer: r.result };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Service → app. Only the program on a computer may report.
|
|
140
|
+
async function report(ctx: PluginOpContext, args: { requestId?: string; result?: unknown; kind?: string }) {
|
|
141
|
+
const device = ctx.viewer.device;
|
|
142
|
+
if (!device) throw new Error("report comes from the program on a computer");
|
|
143
|
+
await ctx.emit("result_reported", { machineId: device.machineId, hostname: device.hostname, ...args });
|
|
144
|
+
return { ok: true };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Which computers there are, and where each program stands.
|
|
148
|
+
async function computers(ctx: PluginOpContext) {
|
|
149
|
+
return (await devices(ctx).list()).map((d) => ({
|
|
150
|
+
linkId: d.linkId, hostname: d.machine?.hostname, online: d.machine?.online ?? false,
|
|
151
|
+
program: d.programState?.phase ?? (d.program ? "approved" : "not approved"),
|
|
152
|
+
status: d.programState?.status?.text ?? null,
|
|
153
|
+
}));
|
|
154
|
+
}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Rules that matter:
|
|
158
|
+
|
|
159
|
+
- **`send` waits at most 55 s** (`waitSeconds`; `0` returns at once with the
|
|
160
|
+
`commandId`). For anything longer, use the job pattern above: answer
|
|
161
|
+
`accepted`, finish in the background, `p.op` the result.
|
|
162
|
+
- **A message and an answer are each at most 1 MB of JSON.** Topics match
|
|
163
|
+
`[a-zA-Z][a-zA-Z0-9_.:-]{0,63}`. For files, hand the program a link
|
|
164
|
+
(`(await filesForOp(ctx)).readGrant(...)` + `fileGrantUrl`) and let it
|
|
165
|
+
download; for results, store them through ops or save files.
|
|
166
|
+
- **A queued message for an offline computer expires** (600 s by default) —
|
|
167
|
+
a click on Monday never runs on Thursday.
|
|
168
|
+
- **`p.op` must be quick and idempotent.** The computer resends a call whose
|
|
169
|
+
answer has not arrived in about 4 s (up to 3 tries). Pass `{ key }` with a
|
|
170
|
+
stable id and make the op a no-op the second time (store by `requestId`).
|
|
171
|
+
- **Who can do what:** `send`, `run`, `sh` and `cancel` need a caller who may
|
|
172
|
+
change the workspace (`ctx.viewer.canWrite`). The program's `p.op` calls run
|
|
173
|
+
as the owner who approved that computer (`viewer.kind === "agent"`,
|
|
174
|
+
`viewer.device` set) through your app's own op and its own rules.
|
|
175
|
+
|
|
176
|
+
`devices(ctx).program(linkId).state()` gives that program's state on that
|
|
177
|
+
computer (installing, a setup step, self-test, running, its last `p.status`
|
|
178
|
+
words, why it failed) — the same that `list()` returns as `programState`.
|
|
179
|
+
|
|
180
|
+
## 4. The page: connect a computer
|
|
181
|
+
|
|
182
|
+
```tsx
|
|
183
|
+
import { ConnectComputer, useDevices } from "esoul-sdk/react";
|
|
184
|
+
|
|
185
|
+
export function Computers({ workspaceId, nodeId }: { workspaceId: string; nodeId: string }) {
|
|
186
|
+
const { devices } = useDevices({ workspaceId, nodeId }); // pick a linkId for ask()
|
|
187
|
+
return <ConnectComputer workspaceId={workspaceId} nodeId={nodeId} title="Computers" />;
|
|
188
|
+
}
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
`<ConnectComputer/>` lists the computers (online, last seen, the program's
|
|
192
|
+
progress and words, Disconnect) and a **Connect a computer** button.
|
|
193
|
+
|
|
194
|
+
## 5. Build it in the Forge — what you can prove before installing
|
|
195
|
+
|
|
196
|
+
The Forge box runs your app in a preview. **Computers connect to installed
|
|
197
|
+
apps only**: in the box `devices(ctx).list()` answers `[]`, every other
|
|
198
|
+
`devices()` call throws "computers connect to INSTALLED apps", and
|
|
199
|
+
`<ConnectComputer/>` shows a note saying so. Plan the work in that order:
|
|
200
|
+
|
|
201
|
+
1. **Prove the program without a computer.** It is a plain object with
|
|
202
|
+
`start(p)`; give it a fake `p` in a test and call its handlers:
|
|
203
|
+
|
|
204
|
+
```ts
|
|
205
|
+
import program from "../device/main";
|
|
206
|
+
|
|
207
|
+
test("ask goes to the local service and comes back", async () => {
|
|
208
|
+
const handlers: Record<string, (d: unknown) => unknown> = {};
|
|
209
|
+
const ops: Array<[string, unknown]> = [];
|
|
210
|
+
const p = {
|
|
211
|
+
computer: { hostname: "test", platform: "linux", arch: "x64", cpus: 4, memoryGb: 8 },
|
|
212
|
+
appName: "Model", programDir: __dirname, dataDir: "/tmp/model-test", workspaceRoot: null, config: {},
|
|
213
|
+
status: () => {}, log: () => {}, every: () => () => {},
|
|
214
|
+
on: (t: string, h: (d: unknown) => unknown) => { handlers[t] = h; },
|
|
215
|
+
op: async (name: string, args: unknown) => { ops.push([name, args]); return { ok: true }; },
|
|
216
|
+
};
|
|
217
|
+
// stub the service: global.fetch = jest.fn(...) answering /health and /ask
|
|
218
|
+
await program.start(p as never);
|
|
219
|
+
expect(await handlers.ask({ prompt: "hi" })).toEqual({ text: "hello" });
|
|
220
|
+
});
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
2. **Prove the app side** with `esoul-sdk/server` mocked (`devices` returning a
|
|
224
|
+
fake whose `program().send` answers), and call `report` with a faked
|
|
225
|
+
`viewer.device` to see the result land in the fold.
|
|
226
|
+
3. **Run the service itself** in the box if it can run there (`run_in_app`:
|
|
227
|
+
`python3 device/serve.py --port 8711 &` then `curl localhost:8711/health`) —
|
|
228
|
+
the same code the program will start.
|
|
229
|
+
4. `check_app` packs `device/` and refuses a program over 2 MB / 200 files or
|
|
230
|
+
one that imports a package.
|
|
231
|
+
|
|
232
|
+
What only the installed app can prove: the program installed and running on
|
|
233
|
+
a real computer, `send`, `p.op`, the approval card and the reach choice.
|
|
234
|
+
|
|
235
|
+
## 6. Install, connect, approve
|
|
236
|
+
|
|
237
|
+
1. `install_app` (the board's tool, or Install on the board). The app is now
|
|
238
|
+
on the platform.
|
|
239
|
+
2. Open the app, press **Connect a computer**. The panel shows one command to
|
|
240
|
+
paste in a terminal on that computer:
|
|
241
|
+
|
|
242
|
+
```
|
|
243
|
+
npx -y -p esoul-sdk@0.19.1 esoul-device connect https://externalsoul.com/api/device/m/offers/off_…
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
No Node there? The panel's other line installs a pinned Node first, without
|
|
247
|
+
sudo: `curl -fsSL https://externalsoul.com/api/device/install | sh -s -- <link>`.
|
|
248
|
+
The link expires in 10 minutes and holds no secret.
|
|
249
|
+
3. The computer prints a code (`K7Q-4XM`); the page shows the same code with
|
|
250
|
+
**Approve**, the program's size and version, its setup steps and
|
|
251
|
+
self-test, and **what the app may reach there**: commands only, its
|
|
252
|
+
folder, or the whole computer (never more than the computer allowed with
|
|
253
|
+
`--scope` / `--folder` when it connected).
|
|
254
|
+
4. The runtime installs itself as a user service (systemd on Linux, a
|
|
255
|
+
LaunchAgent on macOS, a plain background service where neither exists),
|
|
256
|
+
so it comes back after a reboot. Linux and macOS; Windows via WSL2.
|
|
257
|
+
5. The card shows setup progress, then the self-test, then your `p.status`
|
|
258
|
+
words. Your app's `computers` op (or `list()`) says `running`.
|
|
259
|
+
|
|
260
|
+
**Updates.** A new version that changes the program (any file, the setup, the
|
|
261
|
+
self-test) has a new digest and waits for **Approve** on the card; until then
|
|
262
|
+
the computer keeps running the version it has. Changing what the app may
|
|
263
|
+
reach there restarts the program under the new reach.
|
|
264
|
+
|
|
265
|
+
**Where it runs.** Unconfined only with the whole computer granted (needed
|
|
266
|
+
when the service uses the person's own logins, like Claude Code or git).
|
|
267
|
+
Otherwise in an OS sandbox (bubblewrap on Linux, Seatbelt on macOS): it reads
|
|
268
|
+
the system, writes only `p.dataDir` and the granted folder, and its home and
|
|
269
|
+
`/tmp` are private. No sandbox on the computer → refused, never run
|
|
270
|
+
unconfined. The network is not confined either way.
|
|
271
|
+
|
|
272
|
+
## 7. Other ways in, and when to use them
|
|
273
|
+
|
|
274
|
+
| You need | Use |
|
|
275
|
+
|---|---|
|
|
276
|
+
| A long-lived service your app owns, two-way | **A program** (this page) |
|
|
277
|
+
| A known tool run with checked parameters (`nvidia-smi`, a training script) | **Declared commands** — `device.commands` + `devices(ctx).run` ([18 §1–4](18-your-computer.md)) |
|
|
278
|
+
| Agents that work IN a project folder (read, edit, patch, run tests, keep a dev server in a terminal) | **The workspace** — `devices(ctx).sh("term new dev npm run dev", { agent })` ([18 §9](18-your-computer.md)) |
|
|
279
|
+
| A process that is not your program pushes data to your app (a script, a device, a CI job) | **A token route** — declare the route `"access": "token"`, mint with `mintRouteToken(ctx, { route, ttlSeconds })`, the process POSTs with `Authorization: Bearer <token>` ([06 · Routes](06-server.md)). Works in the Forge box too (the box's public preview URL). |
|
|
280
|
+
| The person already runs the My Computer app and the work is one-off shell commands | **`computer(ctx, machineNodeId)`** — shell strings the owner approves, output ≤ 8,000 characters ([06](06-server.md)); works in the box through the board's tab |
|
|
281
|
+
|
|
282
|
+
## 8. When it does not work
|
|
283
|
+
|
|
284
|
+
| You see | It means |
|
|
285
|
+
|---|---|
|
|
286
|
+
| `send` → `ok: false`, `expired` | the computer was offline for the message's whole life (600 s) |
|
|
287
|
+
| `send` → error "not approved" / no program in `list()` | the person has not approved the program (or its new version) on the card |
|
|
288
|
+
| `programState.phase === "failed"` | a setup step or the self-test failed, or the program exited five times in five minutes — the card shows the last log lines |
|
|
289
|
+
| "refused: no sandbox" on the card | the computer has neither bubblewrap nor Seatbelt; grant the whole computer or install bubblewrap |
|
|
290
|
+
| the same report stored twice | the op is not idempotent — key it by `requestId` (`p.op(..., { key })`) |
|
|
291
|
+
| `send` answers `the answer was larger than 1 MB` | return a summary; put the bulk in a file and send a link or an id |
|